Merge branch 'main' into feat/call-tags

This commit is contained in:
Abhishek Kumar 2026-02-18 13:30:07 +05:30
commit 5c4cf14b07
117 changed files with 7365 additions and 5193 deletions

6
.claude/settings.json Normal file
View file

@ -0,0 +1,6 @@
{
"effortLevel": "high",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}

View file

@ -1,3 +1,3 @@
{
".": "1.13.0"
".": "1.14.0"
}

View file

@ -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)

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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",

Binary file not shown.

Binary file not shown.

View file

@ -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"))

View file

@ -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,

View file

@ -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
]

View file

@ -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:

View file

@ -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.)

View file

@ -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"

View file

@ -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,

View file

@ -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}"

View file

@ -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
)

View file

@ -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}

View file

@ -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."""

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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]:

View file

@ -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",

View file

@ -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,

View file

@ -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
)

View file

@ -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,

View file

@ -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}"

View file

@ -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,

View file

@ -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"

View file

@ -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 {}

View file

@ -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

View file

@ -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()

File diff suppressed because it is too large Load diff

View file

@ -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"<ARIManagerConnection id={self.id} caller={self.caller_channel_id} "
f"em={self.em_channel_id} bridge={self.bridge_id} state={'closed' if self._closed else 'open'}>"
)

View file

@ -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

View file

@ -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

View file

@ -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,
]

View file

@ -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"
)

View file

@ -680,3 +680,30 @@ class CloudonixProvider(TelephonyProvider):
</Response>"""
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

View file

@ -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):
</Connect>
<Pause length="40"/>
</Response>"""
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):
</Response>"""
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"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>You have answered a transfer call. Connecting you now.</Say>
<Dial>
<Conference endConferenceOnExit="true">{conference_name}</Conference>
</Dial>
</Response>"""
# 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

View file

@ -533,3 +533,30 @@ class VobizProvider(TelephonyProvider):
</Response>"""
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"<StasisRTPConnection id={self.id} channel={self.channel_id} "
f"caller={self.caller_channel_id} em={self.em_channel_id} "
f"state={'closed' if self._closed_by_stasis_end else 'open'}>"
)

View file

@ -1,116 +0,0 @@
# Copyright (c) 20242025, 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

View file

@ -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)

View file

@ -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())

View file

@ -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())

View file

@ -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}"

View file

@ -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

View file

@ -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."""

View file

@ -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}")

View file

@ -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"
)

View file

@ -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)

94
api/utils/hold_audio.py Normal file
View file

@ -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()),
}

View file

@ -214,4 +214,4 @@ volumes:
networks:
app-network:
driver: bridge
driver: bridge

View file

@ -90,6 +90,7 @@
"integrations/telephony/vonage",
"integrations/telephony/cloudonix",
"integrations/telephony/vobiz",
"integrations/telephony/asterisk-ari",
"integrations/telephony/webhooks",
"integrations/telephony/custom"
]

View file

@ -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
<Note>
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.
</Note>
## 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
```
<Note>
The username (section name, e.g., `dograh`) and password here must match the **Stasis App Name** and **App Password** you configure in Dograh.
</Note>
### 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
```
<Note>
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.
</Note>
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`).
<Note>
If no Inbound Workflow ID is configured, inbound calls will be hung up immediately. You must set this field for inbound calling to work.
</Note>
**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
<AccordionGroup>
<Accordion title="Cannot connect to ARI endpoint">
- 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
</Accordion>
<Accordion title="Authentication failed">
- 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
</Accordion>
<Accordion title="No audio during calls">
- 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
</Accordion>
<Accordion title="Calls not reaching 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
</Accordion>
<Accordion title="Inbound calls are immediately hung up">
- 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"
</Accordion>
<Accordion title="WebSocket client connection issues">
- 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
</Accordion>
</AccordionGroup>
## 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

View file

@ -19,6 +19,9 @@ Dograh AI supports inbound calling across all supported telephony providers. Whe
<Card title="Vobiz" href="/integrations/telephony/vobiz">
Cloud-based telephony with global reach and competitive pricing
</Card>
<Card title="Asterisk ARI" href="/integrations/telephony/asterisk-ari">
Connect to your own Asterisk PBX via the Asterisk REST Interface
</Card>
</CardGroup>
<Note>
@ -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

@ -1 +1 @@
Subproject commit e180bd3c2abc3cebbdf5e2d7955d9928cca5d219
Subproject commit fbc9a768445e8f683721744659fc8904d4012081

179
ui/package-lock.json generated
View file

@ -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"
},

View file

@ -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",

View file

@ -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';

View file

@ -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<string, unknown>, 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<string, unknown>) => ({
...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<string, unknown>) => ({
...base,
backgroundColor: 'var(--popover)',
padding: 0,
}),
option: (base: Record<string, unknown>, 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<string, unknown>) => ({ ...base, color: 'var(--foreground)' }),
input: (base: Record<string, unknown>) => ({ ...base, color: 'var(--foreground)' }),
placeholder: (base: Record<string, unknown>) => ({ ...base, color: 'var(--muted-foreground)' }),
indicatorSeparator: (base: Record<string, unknown>) => ({ ...base, backgroundColor: 'var(--border)' }),
dropdownIndicator: (base: Record<string, unknown>) => ({
...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 (
<div className="space-y-6">
{/* Max Concurrent Calls */}
<div className="space-y-2">
<Label htmlFor="max-concurrency">Max Concurrent Calls</Label>
<Input
id="max-concurrency"
type="number"
placeholder={`Default: ${effectiveLimit}`}
value={maxConcurrency}
onChange={(e) => onMaxConcurrencyChange(e.target.value)}
min={1}
max={effectiveLimit}
/>
<p className="text-sm text-muted-foreground">
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}.`}
</p>
{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a>.
</p>
)}
{fromNumbersCount === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No phone numbers configured. Add CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a> before running the campaign.
</p>
)}
</div>
{/* Retry Configuration */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="retry-enabled">Enable Retries</Label>
<p className="text-sm text-muted-foreground">
Automatically retry failed calls
</p>
</div>
<Switch
id="retry-enabled"
checked={retryEnabled}
onCheckedChange={onRetryEnabledChange}
/>
</div>
{retryEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max-retries">Max Retries</Label>
<Input
id="max-retries"
type="number"
value={maxRetries}
onChange={(e) => onMaxRetriesChange(e.target.value)}
min={0}
max={10}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay">Retry Delay (seconds)</Label>
<Input
id="retry-delay"
type="number"
value={retryDelaySeconds}
onChange={(e) => onRetryDelaySecondsChange(e.target.value)}
min={30}
max={3600}
/>
</div>
</div>
<div className="space-y-3">
<Label>Retry On</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Busy Signal</span>
<Switch checked={retryOnBusy} onCheckedChange={onRetryOnBusyChange} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm">No Answer</span>
<Switch checked={retryOnNoAnswer} onCheckedChange={onRetryOnNoAnswerChange} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Voicemail</span>
<Switch checked={retryOnVoicemail} onCheckedChange={onRetryOnVoicemailChange} />
</div>
</div>
</div>
</div>
)}
</div>
<Separator />
{/* Call Schedule */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="schedule-enabled">Call Schedule</Label>
<p className="text-sm text-muted-foreground">
Restrict when calls are made
</p>
</div>
<Switch
id="schedule-enabled"
checked={scheduleEnabled}
onCheckedChange={onScheduleEnabledChange}
/>
</div>
{scheduleEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="space-y-2">
<Label>Timezone</Label>
<TimezoneSelect
instanceId={timezoneSelectId}
value={scheduleTimezone}
onChange={onScheduleTimezoneChange}
styles={timezoneSelectStyles}
/>
</div>
<div className="space-y-3">
<Label>Time Slots</Label>
{timeSlots.map((slot, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={String(slot.day_of_week)}
onValueChange={(val) => {
const updated = [...timeSlots];
updated[index] = { ...updated[index], day_of_week: parseInt(val) };
onTimeSlotsChange(updated);
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, i) => (
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="time"
value={slot.start_time}
onChange={(e) => {
const updated = [...timeSlots];
updated[index] = { ...updated[index], start_time: e.target.value };
onTimeSlotsChange(updated);
}}
className="w-[130px]"
/>
<span className="text-sm text-muted-foreground">to</span>
<Input
type="time"
value={slot.end_time}
onChange={(e) => {
const updated = [...timeSlots];
updated[index] = { ...updated[index], end_time: e.target.value };
onTimeSlotsChange(updated);
}}
className="w-[130px]"
/>
{timeSlots.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onTimeSlotsChange(timeSlots.filter((_, i) => i !== index))}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onTimeSlotsChange([...timeSlots, { day_of_week: 0, start_time: '09:00', end_time: '17:00' }])}
>
<Plus className="h-4 w-4 mr-1" />
Add Time Slot
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -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<CampaignResponse | null>(null);
// Form state
const [campaignName, setCampaignName] = useState('');
const [maxConcurrency, setMaxConcurrency] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Limits state
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
const [fromNumbersCount, setFromNumbersCount] = useState<number>(0);
// Retry config state
const [retryEnabled, setRetryEnabled] = useState(true);
const [maxRetries, setMaxRetries] = useState<string>('2');
const [retryDelaySeconds, setRetryDelaySeconds] = useState<string>('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<ITimezoneOption | string>('UTC');
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([
{ 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 (
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-1/4 mb-4"></div>
<div className="h-64 bg-muted rounded"></div>
</div>
</div>
);
}
if (!campaign) {
return (
<div className="container mx-auto p-6 space-y-6 max-w-2xl">
<p className="text-center text-muted-foreground">Campaign not found</p>
</div>
);
}
return (
<div className="container mx-auto p-6 pb-12 space-y-6 max-w-2xl">
<div>
<Button
variant="ghost"
onClick={handleBack}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Campaign
</Button>
<h1 className="text-3xl font-bold mb-2">Edit Campaign</h1>
<p className="text-muted-foreground">Modify campaign settings</p>
</div>
<Card>
<CardHeader>
<CardTitle>Campaign Settings</CardTitle>
<CardDescription>
Update name, concurrency, retry, and schedule configuration
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Campaign Name */}
<div className="space-y-2">
<Label htmlFor="campaign-name">Campaign Name</Label>
<Input
id="campaign-name"
placeholder="Enter campaign name"
value={campaignName}
onChange={(e) => setCampaignName(e.target.value)}
maxLength={255}
required
/>
</div>
<Separator />
<CampaignAdvancedSettings
maxConcurrency={maxConcurrency}
onMaxConcurrencyChange={setMaxConcurrency}
effectiveLimit={effectiveLimit}
orgConcurrentLimit={orgConcurrentLimit}
fromNumbersCount={fromNumbersCount}
retryEnabled={retryEnabled}
onRetryEnabledChange={setRetryEnabled}
maxRetries={maxRetries}
onMaxRetriesChange={setMaxRetries}
retryDelaySeconds={retryDelaySeconds}
onRetryDelaySecondsChange={setRetryDelaySeconds}
retryOnBusy={retryOnBusy}
onRetryOnBusyChange={setRetryOnBusy}
retryOnNoAnswer={retryOnNoAnswer}
onRetryOnNoAnswerChange={setRetryOnNoAnswer}
retryOnVoicemail={retryOnVoicemail}
onRetryOnVoicemailChange={setRetryOnVoicemail}
scheduleEnabled={scheduleEnabled}
onScheduleEnabledChange={setScheduleEnabled}
scheduleTimezone={scheduleTimezone}
onScheduleTimezoneChange={setScheduleTimezone}
timeSlots={timeSlots}
onTimeSlotsChange={setTimeSlots}
/>
{submitError && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{submitError}
</div>
)}
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName.trim()}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleBack}
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 ? (
<Button variant="outline" onClick={() => router.push(`/campaigns/${campaignId}/edit`)}>
<Pencil className="h-4 w-4 mr-2" />
Edit Campaign
</Button>
) : null;
switch (campaign.state) {
case 'created':
return (
<Button onClick={handleStart} disabled={isExecutingAction}>
<Play className="h-4 w-4 mr-2" />
Start Campaign
</Button>
<div className="flex items-center gap-2">
{editButton}
<Button onClick={handleStart} disabled={isExecutingAction}>
<Play className="h-4 w-4 mr-2" />
Start Campaign
</Button>
</div>
);
case 'running':
return (
<Button onClick={handlePause} disabled={isExecutingAction}>
<Pause className="h-4 w-4 mr-2" />
Pause Campaign
</Button>
<div className="flex items-center gap-2">
{editButton}
<Button onClick={handlePause} disabled={isExecutingAction}>
<Pause className="h-4 w-4 mr-2" />
Pause Campaign
</Button>
</div>
);
case 'paused':
return (
<Button onClick={handleResume} disabled={isExecutingAction}>
<RefreshCw className="h-4 w-4 mr-2" />
Resume Campaign
</Button>
<div className="flex items-center gap-2">
{editButton}
<Button onClick={handleResume} disabled={isExecutingAction}>
<RefreshCw className="h-4 w-4 mr-2" />
Resume Campaign
</Button>
</div>
);
default:
return null;
@ -449,6 +468,51 @@ export default function CampaignDetailPage() {
</div>
)}
</div>
<Separator />
{/* Call Schedule (read-only) */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Call Schedule</span>
<div className="flex items-center gap-2">
{campaign.schedule_config?.enabled ? (
<Badge variant="default" className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Enabled
</Badge>
) : (
<Badge variant="secondary" className="flex items-center gap-1">
<X className="h-3 w-3" />
Not configured
</Badge>
)}
</div>
</div>
{campaign.schedule_config?.enabled && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Timezone</dt>
<dd className="mt-1 font-medium">{campaign.schedule_config.timezone.replace(/_/g, ' ')}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Time Slots</dt>
<dd className="mt-1 flex flex-wrap gap-2">
{campaign.schedule_config.slots.map((slot, index) => {
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return (
<div key={index} className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">{dayNames[slot.day_of_week]}</Badge>
<span className="text-sm">{slot.start_time} - {slot.end_time}</span>
</div>
);
})}
</dd>
</div>
</div>
)}
</div>
</CardContent>
</Card>

View file

@ -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<ITimezoneOption | string>(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC';
}
});
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([
{ 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() {
<ChevronRight className="h-4 w-4" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4 space-y-6">
{/* Max Concurrent Calls */}
<div className="space-y-2">
<Label htmlFor="max-concurrency">Max Concurrent Calls</Label>
<Input
id="max-concurrency"
type="number"
placeholder={`Default: ${effectiveLimit}`}
value={maxConcurrency}
onChange={(e) => setMaxConcurrency(e.target.value)}
min={1}
max={effectiveLimit}
/>
<p className="text-sm text-muted-foreground">
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}.`}
</p>
{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a>.
</p>
)}
{fromNumbersCount === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No phone numbers configured. Add CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a> before running the campaign.
</p>
)}
</div>
{/* Retry Configuration */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="retry-enabled">Enable Retries</Label>
<p className="text-sm text-muted-foreground">
Automatically retry failed calls
</p>
</div>
<Switch
id="retry-enabled"
checked={retryEnabled}
onCheckedChange={setRetryEnabled}
/>
</div>
{retryEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max-retries">Max Retries</Label>
<Input
id="max-retries"
type="number"
value={maxRetries}
onChange={(e) => setMaxRetries(e.target.value)}
min={0}
max={10}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay">Retry Delay (seconds)</Label>
<Input
id="retry-delay"
type="number"
value={retryDelaySeconds}
onChange={(e) => setRetryDelaySeconds(e.target.value)}
min={30}
max={3600}
/>
</div>
</div>
<div className="space-y-3">
<Label>Retry On</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Busy Signal</span>
<Switch
checked={retryOnBusy}
onCheckedChange={setRetryOnBusy}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">No Answer</span>
<Switch
checked={retryOnNoAnswer}
onCheckedChange={setRetryOnNoAnswer}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Voicemail</span>
<Switch
checked={retryOnVoicemail}
onCheckedChange={setRetryOnVoicemail}
/>
</div>
</div>
</div>
</div>
)}
</div>
<CollapsibleContent className="px-4 pb-4">
<CampaignAdvancedSettings
maxConcurrency={maxConcurrency}
onMaxConcurrencyChange={setMaxConcurrency}
effectiveLimit={effectiveLimit}
orgConcurrentLimit={orgConcurrentLimit}
fromNumbersCount={fromNumbersCount}
retryEnabled={retryEnabled}
onRetryEnabledChange={setRetryEnabled}
maxRetries={maxRetries}
onMaxRetriesChange={setMaxRetries}
retryDelaySeconds={retryDelaySeconds}
onRetryDelaySecondsChange={setRetryDelaySeconds}
retryOnBusy={retryOnBusy}
onRetryOnBusyChange={setRetryOnBusy}
retryOnNoAnswer={retryOnNoAnswer}
onRetryOnNoAnswerChange={setRetryOnNoAnswer}
retryOnVoicemail={retryOnVoicemail}
onRetryOnVoicemailChange={setRetryOnVoicemail}
scheduleEnabled={scheduleEnabled}
onScheduleEnabledChange={setScheduleEnabled}
scheduleTimezone={scheduleTimezone}
onScheduleTimezoneChange={setScheduleTimezone}
timeSlots={timeSlots}
onTimeSlotsChange={setTimeSlots}
/>
</CollapsibleContent>
</Collapsible>

View file

@ -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<DocumentResponseSchema[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [error, setError] = useState<string | null>(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) {

View file

@ -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) {

View file

@ -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<string>('');
// 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 (
<div className="container mx-auto px-4 py-8">
<div className="space-y-4">
@ -75,7 +62,6 @@ export default function FilesPage() {
</CardHeader>
<CardContent>
<DocumentList
accessToken={accessToken}
refreshTrigger={refreshKey}
/>
</CardContent>
@ -92,7 +78,6 @@ export default function FilesPage() {
</CardHeader>
<CardContent>
<DocumentUpload
accessToken={accessToken}
onUploadSuccess={handleUploadSuccess}
/>
</CardContent>

View file

@ -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<DailyReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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) {

View file

@ -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<number | null>(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) {

View file

@ -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
</CardTitle>
<CardDescription>
{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.<br/><br/>
<iframe
@ -325,7 +368,27 @@ export default function ConfigureTelephonyPage() {
</CardDescription>
</CardHeader>
<CardContent>
{selectedProvider === "twilio" || selectedProvider === "vonage" ? (
{selectedProvider === "ari" ? (
<div className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">Getting Started with Asterisk ARI:</h4>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Enable the ARI module in your Asterisk configuration (ari.conf)</li>
<li>Create an ARI user with a password in ari.conf</li>
<li>Create a Stasis application in your dialplan (extensions.conf)</li>
<li>Ensure the ARI HTTP endpoint is accessible from Dograh</li>
<li>Enter your ARI endpoint URL, app name, and password below</li>
</ol>
</div>
<div className="bg-muted border border-border rounded p-3">
<p className="text-sm">
<strong>Note:</strong> ARI uses WebSocket connections for real-time
event listening. The ARI manager process will automatically connect
to your Asterisk instance once configured.
</p>
</div>
</div>
) : selectedProvider === "twilio" || selectedProvider === "vonage" ? (
<div className="aspect-video">
<iframe
style={{ border: 0 }}
@ -407,6 +470,7 @@ export default function ConfigureTelephonyPage() {
<SelectItem value="vonage">Vonage</SelectItem>
<SelectItem value="vobiz">Vobiz</SelectItem>
<SelectItem value="cloudonix">Cloudonix</SelectItem>
<SelectItem value="ari">Asterisk (ARI)</SelectItem>
</SelectContent>
</Select>
{hasExistingConfig && (
@ -771,6 +835,140 @@ export default function ConfigureTelephonyPage() {
</>
)}
{/* ARI-specific fields */}
{selectedProvider === "ari" && (
<>
<div className="space-y-2">
<Label htmlFor="ari_endpoint">ARI Endpoint URL</Label>
<Input
id="ari_endpoint"
placeholder="http://asterisk.example.com:8088"
{...register("ari_endpoint", {
required:
selectedProvider === "ari"
? "ARI endpoint URL is required"
: false,
})}
/>
{errors.ari_endpoint && (
<p className="text-sm text-red-500">
{errors.ari_endpoint.message}
</p>
)}
<p className="text-xs text-muted-foreground">
The HTTP base URL for your Asterisk ARI (e.g., http://host:8088)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="app_name">Stasis App Name</Label>
<Input
id="app_name"
placeholder="dograh"
{...register("app_name", {
required:
selectedProvider === "ari"
? "Stasis app name is required"
: false,
})}
/>
{errors.app_name && (
<p className="text-sm text-red-500">
{errors.app_name.message}
</p>
)}
<p className="text-xs text-muted-foreground">
The ARI username and Stasis application name configured in ari.conf
</p>
</div>
<div className="space-y-2">
<Label htmlFor="app_password">App Password</Label>
<Input
id="app_password"
type="password"
autoComplete="current-password"
placeholder={
hasExistingConfig
? "Leave masked to keep existing"
: "Enter your ARI password"
}
{...register("app_password", {
required:
selectedProvider === "ari" && !hasExistingConfig
? "App password is required"
: false,
})}
/>
{errors.app_password && (
<p className="text-sm text-red-500">
{errors.app_password.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ws_client_name">WebSocket Client Name</Label>
<Input
id="ws_client_name"
placeholder="dograh_staging"
{...register("ws_client_name")}
/>
<p className="text-xs text-muted-foreground">
Connection name from Asterisk&apos;s websocket_client.conf for external media streaming
</p>
</div>
<div className="space-y-2">
<Label htmlFor="inbound_workflow_id">Inbound Workflow ID (Optional)</Label>
<Input
id="inbound_workflow_id"
type="number"
placeholder="e.g. 42"
{...register("inbound_workflow_id", { valueAsNumber: true })}
/>
<p className="text-xs text-muted-foreground">
Workflow to activate for inbound calls received via ARI
</p>
</div>
<div className="space-y-2">
<Label>SIP Extensions / Numbers (Optional)</Label>
{fromNumbers.map((number, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="PJSIP/6001 or 6001"
value={number}
onChange={(e) => updatePhoneNumber(index, e.target.value)}
/>
{fromNumbers.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Extension
</Button>
<p className="text-xs text-muted-foreground">
SIP extensions or trunk numbers for outbound calls
</p>
</div>
</>
)}
<div className="pt-4 space-y-3">
<Button
type="submit"

View file

@ -0,0 +1,170 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { type EndCallMessageType } from "../../config";
export interface TransferCallToolConfigProps {
name: string;
onNameChange: (name: string) => void;
description: string;
onDescriptionChange: (description: string) => void;
destination: string;
onDestinationChange: (destination: string) => void;
messageType: EndCallMessageType;
onMessageTypeChange: (messageType: EndCallMessageType) => void;
customMessage: string;
onCustomMessageChange: (message: string) => void;
timeout?: number; // Make optional to match API type
onTimeoutChange: (timeout: number) => void;
}
export function TransferCallToolConfig({
name,
onNameChange,
description,
onDescriptionChange,
destination,
onDestinationChange,
messageType,
onMessageTypeChange,
customMessage,
onCustomMessageChange,
timeout,
onTimeoutChange,
}: TransferCallToolConfigProps) {
// Basic E.164 validation pattern
const isValidPhoneNumber = (phone: string): boolean => {
const e164Pattern = /^\+[1-9]\d{1,14}$/;
return e164Pattern.test(phone);
};
const phoneNumberError = destination && !isValidPhoneNumber(destination);
return (
<Card>
<CardHeader>
<CardTitle>Transfer Call Configuration</CardTitle>
<CardDescription>
Configure call transfer settings (Twilio only)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-2">
<Label>Tool Name</Label>
<Label className="text-xs text-muted-foreground">
A descriptive name for this tool
</Label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Transfer Call"
/>
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Label className="text-xs text-muted-foreground">
Helps the LLM understand when to use this tool
</Label>
<Textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="When should the AI transfer the call?"
rows={3}
/>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Destination Phone Number</Label>
<Label className="text-xs text-muted-foreground">
Phone number to transfer the call to (E.164 format with country code)
</Label>
<Input
value={destination}
onChange={(e) => onDestinationChange(e.target.value)}
placeholder="+1234567890"
className={phoneNumberError ? "border-red-500 focus:border-red-500" : ""}
/>
{phoneNumberError && (
<Label className="text-xs text-red-500">
Please enter a valid phone number in E.164 format (e.g., +1234567890)
</Label>
)}
</div>
<div className="grid gap-4 pt-4 border-t">
<Label>Pre-Transfer Message</Label>
<Label className="text-xs text-muted-foreground">
Choose whether to play a message before transferring
</Label>
<RadioGroup
value={messageType}
onValueChange={(v) => onMessageTypeChange(v as EndCallMessageType)}
className="space-y-3"
>
<label
htmlFor="none"
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 cursor-pointer"
>
<RadioGroupItem value="none" id="none" />
<div className="flex-1">
<span className="font-medium">No Message</span>
<p className="text-xs text-muted-foreground">
Transfer the call immediately without any message
</p>
</div>
</label>
<div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50">
<RadioGroupItem value="custom" id="custom" className="mt-1" />
<label htmlFor="custom" className="flex-1 space-y-2 cursor-pointer">
<span className="font-medium">Custom Message</span>
<p className="text-xs text-muted-foreground">
Play a custom message before transferring
</p>
</label>
</div>
{messageType === "custom" && (
<div className="pl-8">
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}
placeholder="e.g., Please hold while I transfer your call."
rows={2}
/>
</div>
)}
</RadioGroup>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Transfer Timeout</Label>
<Label className="text-xs text-muted-foreground">
Maximum time to wait for destination to answer (5-120 seconds)
</Label>
<Input
type="number"
value={timeout ?? 30}
onChange={(e) => {
const value = parseInt(e.target.value) || 30;
// Clamp value between 5 and 120 seconds
const clampedValue = Math.min(Math.max(value, 5), 120);
onTimeoutChange(clampedValue);
}}
placeholder="30"
min="5"
max="120"
className="w-32"
/>
<Label className="text-xs text-muted-foreground">
Default: 30 seconds
</Label>
</div>
</CardContent>
</Card>
);
}

View file

@ -1,2 +1,3 @@
export { EndCallToolConfig, type EndCallToolConfigProps } from "./EndCallToolConfig";
export { HttpApiToolConfig, type HttpApiToolConfigProps } from "./HttpApiToolConfig";
export { TransferCallToolConfig, type TransferCallToolConfigProps } from "./TransferCallToolConfig";

View file

@ -8,7 +8,7 @@ import {
getToolApiV1ToolsToolUuidGet,
updateToolApiV1ToolsToolUuidPut,
} from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import type { ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
import { type HttpMethod, type KeyValueItem, type ToolParameter, validateUrl } from "@/components/http";
import { Button } from "@/components/ui/button";
import {
@ -29,7 +29,7 @@ import {
renderToolIcon,
type ToolCategory,
} from "../config";
import { EndCallToolConfig, HttpApiToolConfig } from "./components";
import { EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
// Extended HttpApiConfig with parameters (until client types are regenerated)
interface HttpApiConfigWithParams {
@ -69,6 +69,12 @@ export default function ToolDetailPage() {
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
const [endCallCustomMessage, setEndCallCustomMessage] = useState("");
// Transfer Call form state
const [transferDestination, setTransferDestination] = useState("");
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
const [transferCustomMessage, setTransferCustomMessage] = useState("");
const [transferTimeout, setTransferTimeout] = useState(30);
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
@ -117,6 +123,20 @@ export default function ToolDetailPage() {
setEndCallMessageType("none");
setEndCallCustomMessage("");
}
} else if (tool.category === "transfer_call") {
// Populate transfer call specific fields
const config = tool.definition?.config as APITransferCallConfig | undefined;
if (config) {
setTransferDestination(config.destination || "");
setTransferMessageType(config.messageType || "none");
setTransferCustomMessage(config.customMessage || "");
setTransferTimeout(config.timeout ?? 30);
} else {
setTransferDestination("");
setTransferMessageType("none");
setTransferCustomMessage("");
setTransferTimeout(30);
}
} else {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
@ -163,7 +183,14 @@ export default function ToolDetailPage() {
if (!tool) return;
// Validation based on tool type
if (tool.category !== "end_call") {
if (tool.category === "transfer_call") {
// Validate destination phone number for Transfer Call tools
const e164Pattern = /^\+[1-9]\d{1,14}$/;
if (!transferDestination || !e164Pattern.test(transferDestination)) {
setError("Please enter a valid phone number in E.164 format (e.g., +1234567890)");
return;
}
} else if (tool.category !== "end_call") {
// Validate URL for HTTP API tools
const urlValidation = validateUrl(url);
if (!urlValidation.valid) {
@ -201,6 +228,22 @@ export default function ToolDetailPage() {
},
},
};
} else if (tool.category === "transfer_call") {
// Build transfer call request body
requestBody = {
name,
description: description || undefined,
definition: {
schema_version: 1,
type: "transfer_call",
config: {
destination: transferDestination,
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? transferCustomMessage : undefined,
timeout: transferTimeout,
},
},
};
} else {
// Build HTTP API request body
const headersObject: Record<string, string> = {};
@ -331,6 +374,7 @@ const data = await response.json();`;
}
const isEndCallTool = tool.category === "end_call";
const isTransferCallTool = tool.category === "transfer_call";
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
@ -366,7 +410,7 @@ const data = await response.json();`;
</div>
</div>
<div className="flex items-center gap-2">
{!isEndCallTool && (
{!isEndCallTool && !isTransferCallTool && (
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
@ -375,34 +419,9 @@ const data = await response.json();`;
View Code
</Button>
)}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save
</>
)}
</Button>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
{error}
</div>
)}
{saveSuccess && (
<div className="mb-4 p-4 bg-green-500/10 border border-green-500/20 rounded-lg text-green-600">
Tool saved successfully!
</div>
)}
{isEndCallTool ? (
<EndCallToolConfig
name={name}
@ -414,6 +433,21 @@ const data = await response.json();`;
customMessage={endCallCustomMessage}
onCustomMessageChange={setEndCallCustomMessage}
/>
) : isTransferCallTool ? (
<TransferCallToolConfig
name={name}
onNameChange={setName}
description={description}
onDescriptionChange={setDescription}
destination={transferDestination}
onDestinationChange={setTransferDestination}
messageType={transferMessageType}
onMessageTypeChange={setTransferMessageType}
customMessage={transferCustomMessage}
onCustomMessageChange={setTransferCustomMessage}
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
/>
) : (
<HttpApiToolConfig
name={name}
@ -434,6 +468,34 @@ const data = await response.json();`;
onTimeoutMsChange={setTimeoutMs}
/>
)}
{error && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
{error}
</div>
)}
{saveSuccess && (
<div className="mt-4 p-4 bg-green-500/10 border border-green-500/20 rounded-lg text-green-600">
Tool saved successfully!
</div>
)}
<div className="flex justify-end mt-6">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save
</>
)}
</Button>
</div>
</div>
</div>

View file

@ -1,9 +1,9 @@
"use client";
import { Cog, Globe, type LucideIcon,PhoneOff, Puzzle } from "lucide-react";
import { Cog, Globe, type LucideIcon, PhoneForwarded, PhoneOff, Puzzle } from "lucide-react";
import { type ReactNode } from "react";
export type ToolCategory = "http_api" | "end_call" | "native" | "integration";
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "native" | "integration";
export type EndCallMessageType = "none" | "custom";
@ -42,6 +42,18 @@ export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
description: "End the call when either user asks to disconnect the call, or when you believe its time to end the conversation",
},
},
{
value: "transfer_call",
label: "Transfer Call",
description: "Transfer the call to another phone number (Twilio only)",
icon: PhoneForwarded,
iconName: "phone-forwarded",
iconColor: "#10B981",
autoFill: {
name: "Transfer Call",
description: "Transfer the caller to another phone number when requested",
},
},
{
value: "native",
label: "Native (Coming Soon)",
@ -85,6 +97,8 @@ export function getToolTypeLabel(category: string): string {
switch (category) {
case "end_call":
return "End Call Tool";
case "transfer_call":
return "Transfer Call Tool";
case "http_api":
return "HTTP API Tool";
case "native":
@ -107,6 +121,21 @@ export const DEFAULT_END_CALL_CONFIG: EndCallConfig = {
customMessage: "",
};
// Transfer Call tool specific configuration
export interface TransferCallConfig {
destination: string;
messageType: EndCallMessageType; // Reuse the same type
customMessage?: string;
timeout: number;
}
export const DEFAULT_TRANSFER_CALL_CONFIG: TransferCallConfig = {
destination: "",
messageType: "none",
customMessage: "",
timeout: 30,
};
// Tool definition types for different categories
export interface HttpApiToolDefinition {
schema_version: number;
@ -132,7 +161,13 @@ export interface EndCallToolDefinition {
config: EndCallConfig;
}
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition;
export interface TransferCallToolDefinition {
schema_version: number;
type: "transfer_call";
config: TransferCallConfig;
}
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition | TransferCallToolDefinition;
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
return {
@ -142,6 +177,14 @@ export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefin
};
}
export function createTransferCallDefinition(config: TransferCallConfig): TransferCallToolDefinition {
return {
schema_version: 1,
type: "transfer_call",
config,
};
}
export function createHttpApiDefinition(): HttpApiToolDefinition {
return {
schema_version: 1,
@ -157,6 +200,8 @@ export function createToolDefinition(category: ToolCategory): ToolDefinition {
switch (category) {
case "end_call":
return createEndCallDefinition(DEFAULT_END_CALL_CONFIG);
case "transfer_call":
return createTransferCallDefinition(DEFAULT_TRANSFER_CALL_CONFIG);
case "http_api":
default:
return createHttpApiDefinition();

View file

@ -23,6 +23,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { usageFilterAttributes } from '@/lib/filterAttributes';
import { decodeFiltersFromURL, encodeFiltersToURL } from '@/lib/filters';
import { ActiveFilter, DateRangeValue } from '@/types/filters';
@ -33,7 +34,8 @@ const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { userConfig, saveUserConfig, loading: userConfigLoading, accessToken, organizationPricing } = useUserConfig();
const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig();
const auth = useAuth();
// Current usage state
const [currentUsage, setCurrentUsage] = useState<CurrentUsageResponse | null>(null);
@ -58,7 +60,7 @@ export default function UsagePage() {
});
// Media preview dialog
const mediaPreview = MediaPreviewDialog({ accessToken });
const mediaPreview = MediaPreviewDialog();
// Timezone state - initialize with empty string to avoid hydration mismatch
const localTimezone = getLocalTimezone();
@ -68,13 +70,9 @@ export default function UsagePage() {
// Fetch current usage
const fetchCurrentUsage = useCallback(async () => {
if (!accessToken) return;
if (!auth.isAuthenticated) return;
try {
const response = await getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
const response = await getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet();
if (response.data) {
setCurrentUsage(response.data);
@ -84,11 +82,11 @@ export default function UsagePage() {
} finally {
setIsLoadingCurrent(false);
}
}, [accessToken]);
}, [auth.isAuthenticated]);
// Fetch usage history
const fetchUsageHistory = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!accessToken) return;
if (!auth.isAuthenticated) return;
setIsLoadingHistory(true);
try {
let filterParam = undefined;
@ -132,9 +130,6 @@ export default function UsagePage() {
...(endDate && { end_date: endDate }),
...(filterParam && { filters: filterParam })
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
@ -145,19 +140,16 @@ export default function UsagePage() {
} finally {
setIsLoadingHistory(false);
}
}, [accessToken]);
}, [auth.isAuthenticated]);
// Fetch daily usage breakdown
const fetchDailyUsage = useCallback(async () => {
if (!accessToken || !organizationPricing?.price_per_second_usd) return;
if (!auth.isAuthenticated || !organizationPricing?.price_per_second_usd) return;
setIsLoadingDaily(true);
try {
const response = await getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet({
query: { days: 7 },
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
@ -168,7 +160,7 @@ export default function UsagePage() {
} finally {
setIsLoadingDaily(false);
}
}, [accessToken, organizationPricing]);
}, [auth.isAuthenticated, organizationPricing]);
// Handle timezone change
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
@ -200,20 +192,20 @@ export default function UsagePage() {
}
}, [userConfig, userConfigLoading, localTimezone]);
// Initial load - fetch when accessToken becomes available
// Initial load - fetch when auth becomes available
useEffect(() => {
if (accessToken) {
if (auth.isAuthenticated) {
fetchCurrentUsage();
fetchUsageHistory(currentPage, activeFilters);
}
}, [accessToken, currentPage, activeFilters, fetchUsageHistory, fetchCurrentUsage]);
}, [auth.isAuthenticated, currentPage, activeFilters, fetchUsageHistory, fetchCurrentUsage]);
// Fetch daily usage when organizationPricing becomes available
useEffect(() => {
if (accessToken && organizationPricing?.price_per_second_usd) {
if (auth.isAuthenticated && organizationPricing?.price_per_second_usd) {
fetchDailyUsage();
}
}, [accessToken, organizationPricing, fetchDailyUsage]);
}, [auth.isAuthenticated, organizationPricing, fetchDailyUsage]);
// Update URL with query parameters
const updateUrlParams = useCallback((params: { page?: number; filters?: ActiveFilter[] }) => {

View file

@ -58,10 +58,9 @@ interface RenderWorkflowProps {
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
user: { id: string; email?: string };
getAccessToken: () => Promise<string>;
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: RenderWorkflowProps) {
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user }: RenderWorkflowProps) {
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false);
@ -100,18 +99,14 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
initialTemplateContextVariables,
initialWorkflowConfigurations,
user,
getAccessToken
});
// Fetch documents and tools once for the entire workflow
useEffect(() => {
const fetchData = async () => {
try {
const accessToken = await getAccessToken();
// Fetch documents
const documentsResponse = await listDocumentsApiV1KnowledgeBaseDocumentsGet({
headers: { Authorization: `Bearer ${accessToken}` },
query: { limit: 100 },
});
if (documentsResponse.data) {
@ -119,9 +114,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
}
// Fetch tools
const toolsResponse = await listToolsApiV1ToolsGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
const toolsResponse = await listToolsApiV1ToolsGet({});
if (toolsResponse.data) {
setTools(toolsResponse.data);
}
@ -131,7 +124,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
};
fetchData();
}, [getAccessToken]);
}, []);
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
const defaultEdgeOptions = useMemo(() => ({
@ -159,7 +152,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
workflowId={workflowId}
saveWorkflow={saveWorkflow}
user={user}
getAccessToken={getAccessToken}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
/>
@ -388,14 +380,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName}
getAccessToken={getAccessToken}
/>
<PhoneCallDialog
open={isPhoneCallDialogOpen}
onOpenChange={setIsPhoneCallDialogOpen}
workflowId={workflowId}
getAccessToken={getAccessToken}
user={user}
/>
</div>
@ -409,8 +399,7 @@ export default React.memo(RenderWorkflow, (prevProps, nextProps) => {
return (
prevProps.workflowId === nextProps.workflowId &&
prevProps.initialWorkflowName === nextProps.initialWorkflowName &&
prevProps.user.id === nextProps.user.id &&
prevProps.getAccessToken === nextProps.getAccessToken
prevProps.user.id === nextProps.user.id
// Note: We intentionally don't compare initialFlow, initialTemplateContextVariables,
// or initialWorkflowConfigurations because they're only used for initialization
);

View file

@ -1,7 +1,6 @@
import { Check, Copy, Loader2, Plus, Rocket, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { client } from "@/client/client.gen";
import {
createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost,
deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete,
@ -32,7 +31,6 @@ interface EmbedDialogProps {
onOpenChange: (open: boolean) => void;
workflowId: number;
workflowName: string;
getAccessToken: () => Promise<string>;
}
interface EmbedToken {
@ -53,7 +51,6 @@ export function EmbedDialog({
onOpenChange,
workflowId,
workflowName,
getAccessToken,
}: EmbedDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -72,12 +69,6 @@ export function EmbedDialog({
const loadEmbedToken = useCallback(async () => {
setLoading(true);
try {
const token = await getAccessToken();
client.setConfig({
baseUrl: window.location.origin.replace(/:\d+$/, ':8000'),
headers: { Authorization: `Bearer ${token}` },
});
const response = await getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet({
path: { workflow_id: workflowId },
});
@ -105,7 +96,7 @@ export function EmbedDialog({
} finally {
setLoading(false);
}
}, [workflowId, getAccessToken]);
}, [workflowId]);
useEffect(() => {
if (open) {
@ -116,12 +107,6 @@ export function EmbedDialog({
const handleSave = async () => {
setSaving(true);
try {
const token = await getAccessToken();
client.setConfig({
baseUrl: window.location.origin.replace(/:\d+$/, ':8000'),
headers: { Authorization: `Bearer ${token}` },
});
if (!isEnabled && embedToken) {
// Deactivate token
await deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete({

View file

@ -21,13 +21,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useUserConfig } from "@/context/UserConfigContext";
interface PhoneCallDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowId: number;
getAccessToken: () => Promise<string>;
user: { id: string; email?: string };
}
@ -35,7 +35,6 @@ export const PhoneCallDialog = ({
open,
onOpenChange,
workflowId,
getAccessToken,
user,
}: PhoneCallDialogProps) => {
const router = useRouter();
@ -47,6 +46,7 @@ export const PhoneCallDialog = ({
const [phoneChanged, setPhoneChanged] = useState(false);
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
// Check telephony configuration when dialog opens
useEffect(() => {
@ -55,12 +55,9 @@ export const PhoneCallDialog = ({
setCheckingConfig(true);
try {
const accessToken = await getAccessToken();
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
headers: { 'Authorization': `Bearer ${accessToken}` },
});
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({});
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix)) {
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix && !configResponse.data?.ari)) {
setNeedsConfiguration(true);
} else {
setNeedsConfiguration(false);
@ -74,7 +71,7 @@ export const PhoneCallDialog = ({
};
checkConfig();
}, [open, getAccessToken]);
}, [open]);
// Reset state when dialog closes
useEffect(() => {
@ -89,7 +86,9 @@ export const PhoneCallDialog = ({
// Keep phoneNumber in sync with userConfig when dialog opens
useEffect(() => {
if (open) {
setPhoneNumber(userConfig?.test_phone_number || "");
const saved = userConfig?.test_phone_number || "";
setPhoneNumber(saved);
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
@ -115,7 +114,6 @@ export const PhoneCallDialog = ({
setCallSuccessMsg(null);
try {
if (!user || !userConfig) return;
const accessToken = await getAccessToken();
// Save phone number if it has changed
if (phoneChanged) {
@ -128,7 +126,6 @@ export const PhoneCallDialog = ({
workflow_id: workflowId,
phone_number: phoneNumber
},
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
@ -189,14 +186,29 @@ export const PhoneCallDialog = ({
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. The number will be saved automatically.
Enter the phone number or SIP endpoint to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
{sipMode ? (
<Input
value={phoneNumber}
onChange={(e) => handlePhoneInputChange(e.target.value)}
placeholder="PJSIP/1234 or SIP/1234"
/>
) : (
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
)}
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline"
onClick={() => { setSipMode(!sipMode); setPhoneNumber(""); setPhoneChanged(true); }}
>
{sipMode ? "Use phone number instead" : "Use SIP endpoint instead"}
</button>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
@ -219,9 +231,14 @@ export const PhoneCallDialog = ({
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => onOpenChange(false)}>
Close
</Button>
<>
<Button variant="outline" onClick={() => { setCallSuccessMsg(null); setCallError(null); }}>
Call Again
</Button>
<Button onClick={() => onOpenChange(false)}>
Close
</Button>
</>
)}
</div>
</DialogFooter>

View file

@ -30,7 +30,6 @@ interface WorkflowEditorHeaderProps {
workflowId: number;
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
getAccessToken: () => Promise<string>;
onPhoneCallClick: () => void;
}

View file

@ -7,7 +7,7 @@ import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1Workflo
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { WorkflowRunsTable } from "@/components/workflow-runs";
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
@ -39,7 +39,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
return order === 'asc' ? 'asc' : 'desc';
});
const { accessToken } = useUserConfig();
const { isAuthenticated } = useAuth();
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
@ -53,11 +53,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
// Load disposition codes from workflow configuration
const loadDispositionCodes = useCallback(async () => {
if (!accessToken) return;
if (!isAuthenticated) return;
try {
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: { workflow_id: Number(workflowId) },
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const workflow = response.data;
@ -81,7 +80,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
} catch (err) {
console.error("Failed to load disposition codes:", err);
}
}, [workflowId, accessToken]);
}, [workflowId, isAuthenticated]);
useEffect(() => {
loadDispositionCodes();
@ -93,7 +92,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
sortByParam?: string | null,
sortOrderParam?: 'asc' | 'desc'
) => {
if (!accessToken) return;
if (!isAuthenticated) return;
try {
setLoading(true);
// Prepare filter data for API
@ -116,9 +115,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
...(sortByParam && { sort_by: sortByParam }),
...(sortOrderParam && { sort_order: sortOrderParam }),
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.error) {
@ -138,7 +134,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
} finally {
setLoading(false);
}
}, [workflowId, accessToken]);
}, [workflowId, isAuthenticated]);
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[], sortByParam?: string | null, sortOrderParam?: 'asc' | 'desc') => {
const params = new URLSearchParams();
@ -234,7 +230,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
sortOrder={sortOrder}
onSort={handleSort}
workflowId={workflowId}
accessToken={accessToken}
onReload={handleReload}
/>
</div>

View file

@ -107,7 +107,6 @@ interface UseWorkflowStateProps {
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
user: { id: string; email?: string }; // Minimal user type needed
getAccessToken: () => Promise<string>;
}
export const useWorkflowState = ({
@ -117,7 +116,6 @@ export const useWorkflowState = ({
initialTemplateContextVariables,
initialWorkflowConfigurations,
user,
getAccessToken
}: UseWorkflowStateProps) => {
const router = useRouter();
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
@ -245,14 +243,10 @@ export const useWorkflowState = ({
const validateWorkflow = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
workflow_id: workflowId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Clear validation errors first
@ -305,13 +299,12 @@ export const useWorkflowState = ({
} catch (error) {
logger.error(`Unexpected validation error: ${error}`);
}
}, [workflowId, user, getAccessToken, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]);
}, [workflowId, user, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]);
// Save workflow function
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
if (!user || !rfInstance.current) return;
const flow = rfInstance.current.toObject();
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
@ -321,9 +314,6 @@ export const useWorkflowState = ({
name: workflowName,
workflow_definition: updateWorkflowDefinition ? flow : null,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setIsDirty(false);
} catch (error) {
@ -332,7 +322,7 @@ export const useWorkflowState = ({
// Validate after saving
await validateWorkflow();
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, validateWorkflow]);
}, [workflowId, workflowName, setIsDirty, user, validateWorkflow]);
// Set up keyboard shortcut for save (Cmd/Ctrl + S)
useEffect(() => {
@ -386,7 +376,6 @@ export const useWorkflowState = ({
const onRun = async (mode: string) => {
if (!user) return;
const workflowRunName = `WR-${getRandomId()}`;
const accessToken = await getAccessToken();
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
workflow_id: workflowId,
@ -395,9 +384,6 @@ export const useWorkflowState = ({
mode,
name: workflowRunName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
router.push(`/workflow/${workflowId}/run/${response.data?.id}`);
};
@ -405,7 +391,6 @@ export const useWorkflowState = ({
// Save template context variables
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
if (!user) return;
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
@ -416,9 +401,6 @@ export const useWorkflowState = ({
workflow_definition: null,
template_context_variables: variables,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setTemplateContextVariables(variables);
logger.info('Template context variables saved successfully');
@ -426,12 +408,11 @@ export const useWorkflowState = ({
logger.error(`Error saving template context variables: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken, setTemplateContextVariables]);
}, [workflowId, workflowName, user, setTemplateContextVariables]);
// Save workflow configurations
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
if (!user) return;
const accessToken = await getAccessToken();
// Preserve the current dictionary when saving other configurations
const currentDictionary = useWorkflowStore.getState().dictionary;
const configurationsWithDictionary: WorkflowConfigurations = { ...configurations, dictionary: currentDictionary };
@ -445,9 +426,6 @@ export const useWorkflowState = ({
workflow_definition: null,
workflow_configurations: configurationsWithDictionary as Record<string, unknown>,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setWorkflowConfigurations(configurationsWithDictionary);
setWorkflowName(newWorkflowName);
@ -456,12 +434,11 @@ export const useWorkflowState = ({
logger.error(`Error saving workflow configurations: ${error}`);
throw error;
}
}, [workflowId, user, getAccessToken, setWorkflowConfigurations, setWorkflowName]);
}, [workflowId, user, setWorkflowConfigurations, setWorkflowName]);
// Save dictionary
const saveDictionary = useCallback(async (newDictionary: string) => {
if (!user) return;
const accessToken = await getAccessToken();
const currentConfigurations = useWorkflowStore.getState().workflowConfigurations ?? DEFAULT_WORKFLOW_CONFIGURATIONS;
const updatedConfigurations: WorkflowConfigurations = { ...currentConfigurations, dictionary: newDictionary };
try {
@ -474,9 +451,6 @@ export const useWorkflowState = ({
workflow_definition: null,
workflow_configurations: updatedConfigurations as Record<string, unknown>,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setDictionary(newDictionary);
setWorkflowConfigurations(updatedConfigurations);
@ -484,7 +458,7 @@ export const useWorkflowState = ({
logger.error(`Error saving dictionary: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken, setDictionary, setWorkflowConfigurations]);
}, [workflowId, workflowName, user, setDictionary, setWorkflowConfigurations]);
// Update rfInstance when it changes
useEffect(() => {

View file

@ -19,7 +19,7 @@ export default function WorkflowDetailPage() {
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
const { user, redirectToLogin, loading: authLoading } = useAuth();
// Redirect if not authenticated
useEffect(() => {
@ -32,14 +32,10 @@ export default function WorkflowDetailPage() {
const fetchWorkflow = async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: {
workflow_id: Number(params.workflowId)
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const workflow = response.data;
setWorkflow(workflow);
@ -54,11 +50,9 @@ export default function WorkflowDetailPage() {
if (user) {
fetchWorkflow();
}
}, [params.workflowId, user, getAccessToken]);
}, [params.workflowId, user]);
// Memoize user and getAccessToken to prevent unnecessary re-renders
const stableUser = useMemo(() => user, [user]);
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
if (loading) {
return (
@ -89,7 +83,6 @@ export default function WorkflowDetailPage() {
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
user={stableUser}
getAccessToken={stableGetAccessToken}
/>
) : null;
}

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth";
import {
ApiKeyErrorDialog,
@ -14,15 +15,23 @@ import {
} from "./components";
import { useWebSocketRTC } from "./hooks";
const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
accessToken: string | null,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const auth = useAuth();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [checkingForRecording, setCheckingForRecording] = useState(false);
// Get access token for WebSocket connection (non-SDK usage)
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const {
audioRef,
audioInputs,
@ -47,7 +56,7 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
// Poll for recording availability after call ends
useEffect(() => {
if (!isCompleted || !accessToken) return;
if (!isCompleted || !auth.isAuthenticated) return;
setCheckingForRecording(true);
const intervalId = setInterval(async () => {
@ -57,9 +66,6 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
workflow_id: workflowId,
run_id: workflowRunId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.transcript_url || response.data?.recording_url) {
@ -83,7 +89,7 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [isCompleted, accessToken, workflowId, workflowRunId]);
}, [isCompleted, auth.isAuthenticated, workflowId, workflowRunId]);
const navigateToApiKeys = () => {
router.push('/api-keys');

View file

@ -74,7 +74,6 @@ export default function WorkflowRunPage() {
const [isLoading, setIsLoading] = useState(true);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
const customizeButtonRef = useRef<HTMLButtonElement>(null);
@ -85,21 +84,13 @@ export default function WorkflowRunPage() {
}
}, [auth]);
// Get access token
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const { openPreview, dialog } = MediaPreviewDialog({ accessToken });
const { openPreview, dialog } = MediaPreviewDialog();
useEffect(() => {
const fetchWorkflowRun = async () => {
if (!auth.isAuthenticated || auth.loading) return;
setIsLoading(true);
const token = await auth.getAccessToken();
const workflowId = params.workflowId;
const runId = params.runId;
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
@ -107,9 +98,6 @@ export default function WorkflowRunPage() {
workflow_id: Number(workflowId),
run_id: Number(runId),
},
headers: {
'Authorization': `Bearer ${token}`,
},
});
setIsLoading(false);
setWorkflowRun({
@ -197,8 +185,8 @@ export default function WorkflowRunPage() {
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url, accessToken!)}
disabled={!workflowRun?.transcript_url || !accessToken}
onClick={() => downloadFile(workflowRun?.transcript_url)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
@ -206,8 +194,8 @@ export default function WorkflowRunPage() {
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url, accessToken!)}
disabled={!workflowRun?.recording_url || !accessToken}
onClick={() => downloadFile(workflowRun?.recording_url)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
@ -265,7 +253,6 @@ export default function WorkflowRunPage() {
<BrowserCall
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
accessToken={accessToken}
initialContextVariables={
workflowRun?.initial_context
? Object.fromEntries(

View file

@ -16,8 +16,6 @@ async function WorkflowList() {
const authProvider = getServerAuthProvider();
const accessToken = await getServerAccessToken();
logger.debug(`In WorkflowList, authProvider: ${authProvider}, accessToken: ${accessToken}`);
if (!accessToken) {
// If no token, user needs to sign in
const { redirect } = await import('next/navigation');

File diff suppressed because one or more lines are too long

View file

@ -19,6 +19,50 @@ export type ApiKeyStatusResponse = {
status: Array<ApiKeyStatus>;
};
/**
* Request schema for Asterisk ARI configuration.
*/
export type AriConfigurationRequest = {
provider?: string;
/**
* ARI base URL (e.g., http://asterisk.example.com:8088)
*/
ari_endpoint: string;
/**
* Stasis application name registered in Asterisk
*/
app_name: string;
/**
* ARI user password
*/
app_password: string;
/**
* websocket_client.conf connection name for externalMedia (e.g., dograh_staging)
*/
ws_client_name?: string;
/**
* Workflow ID for inbound calls
*/
inbound_workflow_id?: number | null;
/**
* List of SIP extensions/numbers for outbound calls (optional)
*/
from_numbers?: Array<string>;
};
/**
* Response schema for ARI configuration with masked sensitive fields.
*/
export type AriConfigurationResponse = {
provider: string;
ari_endpoint: string;
app_name: string;
app_password: string;
ws_client_name?: string;
inbound_workflow_id?: number | null;
from_numbers: Array<string>;
};
export type AccessTokenResponse = {
access_token: string | null;
refresh_token: string | null;
@ -80,6 +124,7 @@ export type CampaignResponse = {
completed_at: string | null;
retry_config: RetryConfigResponse;
max_concurrency?: number | null;
schedule_config?: ScheduleConfigResponse | null;
};
/**
@ -200,6 +245,7 @@ export type CreateCampaignRequest = {
source_id: string;
retry_config?: RetryConfigRequest | null;
max_concurrency?: number | null;
schedule_config?: ScheduleConfigRequest | null;
};
/**
@ -259,7 +305,9 @@ export type CreateToolRequest = {
type?: 'http_api';
} & HttpApiToolDefinition) | ({
type?: 'end_call';
} & EndCallToolDefinition);
} & EndCallToolDefinition) | ({
type?: 'transfer_call';
} & TransferCallToolDefinition);
};
export type CreateWorkflowRequest = {
@ -731,6 +779,18 @@ export type S3SignedUrlResponse = {
expires_in: number;
};
export type ScheduleConfigRequest = {
enabled?: boolean;
timezone?: string;
slots: Array<TimeSlotRequest>;
};
export type ScheduleConfigResponse = {
enabled: boolean;
timezone: string;
slots: Array<TimeSlotResponse>;
};
export type ServiceKeyResponse = {
name: string;
id: number;
@ -793,6 +853,7 @@ export type TelephonyConfigurationResponse = {
vonage?: VonageConfigurationResponse | null;
vobiz?: VobizConfigurationResponse | null;
cloudonix?: CloudonixConfigurationResponse | null;
ari?: AriConfigurationResponse | null;
};
export type TestSessionResponse = {
@ -815,6 +876,18 @@ export type TestSessionResponse = {
completed_at: string | null;
};
export type TimeSlotRequest = {
day_of_week: number;
start_time: string;
end_time: string;
};
export type TimeSlotResponse = {
day_of_week: number;
start_time: string;
end_time: string;
};
/**
* A parameter that the tool accepts.
*/
@ -857,6 +930,57 @@ export type ToolResponse = {
created_by?: CreatedByResponse | null;
};
/**
* Configuration for Transfer Call tools.
*/
export type TransferCallConfig = {
/**
* Phone number to transfer the call to (E.164 format, e.g., +1234567890)
*/
destination: string;
/**
* Type of message to play before transfer
*/
messageType?: 'none' | 'custom';
/**
* Custom message to play before transferring the call
*/
customMessage?: string | null;
/**
* Maximum time in seconds to wait for destination to answer (5-120 seconds)
*/
timeout?: number;
};
/**
* Request model for initiating a call transfer.
*/
export type TransferCallRequest = {
destination: string;
organization_id: number;
transfer_id: string;
conference_name: string;
timeout?: number | null;
};
/**
* Tool definition for Transfer Call tools.
*/
export type TransferCallToolDefinition = {
/**
* Schema version
*/
schema_version?: number;
/**
* Tool type
*/
type: 'transfer_call';
/**
* Transfer Call configuration
*/
config: TransferCallConfig;
};
/**
* Request model for triggering a call via API
*/
@ -915,6 +1039,13 @@ export type TwilioConfigurationResponse = {
from_numbers: Array<string>;
};
export type UpdateCampaignRequest = {
name?: string | null;
retry_config?: RetryConfigRequest | null;
max_concurrency?: number | null;
schedule_config?: ScheduleConfigRequest | null;
};
/**
* Request schema for updating a webhook credential.
*/
@ -945,7 +1076,9 @@ export type UpdateToolRequest = {
type?: 'http_api';
} & HttpApiToolDefinition) | ({
type?: 'end_call';
} & EndCallToolDefinition)) | null;
} & EndCallToolDefinition) | ({
type?: 'transfer_call';
} & TransferCallToolDefinition)) | null;
status?: string | null;
};
@ -1527,6 +1660,62 @@ export type HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostResponses = {
200: unknown;
};
export type InitiateCallTransferApiV1TelephonyCallTransferPostData = {
body: TransferCallRequest;
path?: never;
query?: never;
url: '/api/v1/telephony/call-transfer';
};
export type InitiateCallTransferApiV1TelephonyCallTransferPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type InitiateCallTransferApiV1TelephonyCallTransferPostError = InitiateCallTransferApiV1TelephonyCallTransferPostErrors[keyof InitiateCallTransferApiV1TelephonyCallTransferPostErrors];
export type InitiateCallTransferApiV1TelephonyCallTransferPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData = {
body?: never;
path: {
transfer_id: string;
};
query?: never;
url: '/api/v1/telephony/transfer-result/{transfer_id}';
};
export type CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError = CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors[keyof CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostErrors];
export type CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type ImpersonateApiV1SuperuserImpersonatePostData = {
body: ImpersonateRequest;
headers?: {
@ -2571,6 +2760,41 @@ export type GetCampaignApiV1CampaignCampaignIdGetResponses = {
export type GetCampaignApiV1CampaignCampaignIdGetResponse = GetCampaignApiV1CampaignCampaignIdGetResponses[keyof GetCampaignApiV1CampaignCampaignIdGetResponses];
export type UpdateCampaignApiV1CampaignCampaignIdPatchData = {
body: UpdateCampaignRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
campaign_id: number;
};
query?: never;
url: '/api/v1/campaign/{campaign_id}';
};
export type UpdateCampaignApiV1CampaignCampaignIdPatchErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateCampaignApiV1CampaignCampaignIdPatchError = UpdateCampaignApiV1CampaignCampaignIdPatchErrors[keyof UpdateCampaignApiV1CampaignCampaignIdPatchErrors];
export type UpdateCampaignApiV1CampaignCampaignIdPatchResponses = {
/**
* Successful Response
*/
200: CampaignResponse;
};
export type UpdateCampaignApiV1CampaignCampaignIdPatchResponse = UpdateCampaignApiV1CampaignCampaignIdPatchResponses[keyof UpdateCampaignApiV1CampaignCampaignIdPatchResponses];
export type StartCampaignApiV1CampaignCampaignIdStartPostData = {
body?: never;
headers?: {
@ -3350,7 +3574,7 @@ export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetRespons
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse = GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses[keyof GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses];
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData = {
body: TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest | CloudonixConfigurationRequest;
body: TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest | CloudonixConfigurationRequest | AriConfigurationRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;

View file

@ -14,11 +14,7 @@ import {
} from '@/components/ui/dialog';
import { downloadFile, getSignedUrl } from '@/lib/files';
interface MediaPreviewDialogProps {
accessToken: string | null;
}
export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
export function MediaPreviewDialog() {
const [isOpen, setIsOpen] = useState(false);
const [audioSignedUrl, setAudioSignedUrl] = useState<string | null>(null);
const [transcriptContent, setTranscriptContent] = useState<string | null>(null);
@ -29,7 +25,7 @@ export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
const openPreview = useCallback(
async (recordingUrl: string | null, transcriptUrl: string | null, runId: number) => {
if (!accessToken || (!recordingUrl && !transcriptUrl)) return;
if (!recordingUrl && !transcriptUrl) return;
setMediaLoading(true);
setAudioSignedUrl(null);
setTranscriptContent(null);
@ -39,8 +35,8 @@ export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
setIsOpen(true);
const [audioResult, transcriptResult] = await Promise.all([
recordingUrl ? getSignedUrl(recordingUrl, accessToken) : null,
transcriptUrl ? getSignedUrl(transcriptUrl, accessToken, true) : null,
recordingUrl ? getSignedUrl(recordingUrl) : null,
transcriptUrl ? getSignedUrl(transcriptUrl, true) : null,
]);
if (audioResult) {
@ -59,7 +55,7 @@ export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
setMediaLoading(false);
},
[accessToken],
[],
);
return {
@ -102,13 +98,13 @@ export function MediaPreviewDialog({ accessToken }: MediaPreviewDialogProps) {
<Button variant="secondary">Close</Button>
</DialogClose>
<div className="flex gap-2">
{recordingKey && accessToken && (
<Button variant="outline" onClick={() => downloadFile(recordingKey, accessToken)}>
{recordingKey && (
<Button variant="outline" onClick={() => downloadFile(recordingKey)}>
Download Recording
</Button>
)}
{transcriptKey && accessToken && (
<Button variant="outline" onClick={() => downloadFile(transcriptKey, accessToken)}>
{transcriptKey && (
<Button variant="outline" onClick={() => downloadFile(transcriptKey)}>
Download Transcript
</Button>
)}

View file

@ -10,7 +10,6 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useUserConfig } from "@/context/UserConfigContext";
import { cn } from "@/lib/utils";
// Providers that have MPS voice endpoints
@ -30,7 +29,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
onChange,
className,
}) => {
const { accessToken } = useUserConfig();
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [isManualInput, setIsManualInput] = useState(false);
@ -60,7 +58,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
const fetchVoices = useCallback(async () => {
const providerKey = getProviderKey(provider);
if (!providerKey || !accessToken) {
if (!providerKey) {
setVoices([]);
return;
}
@ -71,9 +69,6 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
try {
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
path: { provider: providerKey },
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.data?.voices) {
@ -86,7 +81,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
} finally {
setIsLoading(false);
}
}, [provider, getProviderKey, accessToken]);
}, [provider, getProviderKey]);
useEffect(() => {
if (provider) {

View file

@ -36,7 +36,7 @@ export function CredentialSelector({
description = "Select a credential for authentication, or leave empty for no auth.",
showLabel = true,
}: CredentialSelectorProps) {
const { getAccessToken } = useAuth();
useAuth();
const [credentials, setCredentials] = useState<CredentialResponse[]>([]);
const [loading, setLoading] = useState(false);
@ -45,10 +45,7 @@ export function CredentialSelector({
const fetchCredentials = useCallback(async () => {
setLoading(true);
try {
const accessToken = await getAccessToken();
const response = await listCredentialsApiV1CredentialsGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
const response = await listCredentialsApiV1CredentialsGet({});
if (response.error) {
console.error("Failed to fetch credentials:", response.error);
setCredentials([]);
@ -63,7 +60,7 @@ export function CredentialSelector({
} finally {
setLoading(false);
}
}, [getAccessToken]);
}, []);
useEffect(() => {
fetchCredentials();

View file

@ -28,44 +28,47 @@ const AppLayout: React.FC<AppLayoutProps> = ({
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
const isSuperadmin = pathname.startsWith("/superadmin");
// If no sidebar needed, just return children
if (!shouldShowSidebar) {
return <>{children}</>;
}
// Always render SidebarProvider to keep the component tree shape consistent
// across route changes (avoids React hooks ordering violations during navigation).
return (
<SidebarProvider defaultOpen={!isWorkflowEditor && !isSuperadmin}>
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">
{/* Optional header area for specific pages */}
{headerActions && (
<header className="sticky top-0 z-50 w-full border-b bg-background">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
{headerActions}
{shouldShowSidebar ? (
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">
{/* Optional header area for specific pages */}
{headerActions && (
<header className="sticky top-0 z-50 w-full border-b bg-background">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
{headerActions}
</div>
</div>
</header>
)}
{/* Optional sticky tabs */}
{stickyTabs && (
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
</header>
)}
)}
{/* Optional sticky tabs */}
{stickyTabs && (
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
)}
{/* Main content area */}
<main className="flex-1">
{children}
</main>
</SidebarInset>
</div>
{/* Main content area */}
<main className="flex-1">
{children}
</main>
</SidebarInset>
</div>
) : (
<div className="flex-1 w-full">
{children}
</div>
)}
</SidebarProvider>
);
};

View file

@ -11,9 +11,11 @@ import {
HelpCircle,
Home,
Key,
LogOut,
Megaphone,
MessageSquare,
Phone,
Settings,
Star,
TrendingUp,
Workflow,
@ -26,6 +28,14 @@ import React from "react";
import ThemeToggle from "@/components/ThemeSwitcher";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
@ -50,11 +60,6 @@ import { useAppConfig } from "@/context/AppConfigContext";
import { useAuth } from "@/lib/auth";
import { cn } from "@/lib/utils";
// Conditionally load Stack components only when using Stack auth
const StackUserButton = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({ default: mod.UserButton }))
);
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
@ -66,7 +71,7 @@ export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const { state } = useSidebar();
const { provider, getSelectedTeam } = useAuth();
const { provider, getSelectedTeam, logout, user } = useAuth();
const { config } = useAppConfig();
// Get selected team for Stack auth (cast to Team type from Stack)
@ -400,31 +405,53 @@ export function AppSidebar() {
</>
)}
{/* User Button for Stack Auth - at the bottom */}
{/* User Button - at the bottom */}
{provider === "stack" && (
<React.Suspense
fallback={
<div className={cn(
"animate-pulse bg-muted rounded",
state === "collapsed" ? "h-8 w-8" : "h-[34px] w-[34px]"
)} />
}
>
<div className={cn(
"flex",
state === "collapsed" ? "justify-center" : "justify-start"
)}>
<StackUserButton
extraItems={[
{
text: "Usage",
icon: <CircleDollarSign strokeWidth={2} size={16} />,
onClick: () => router.push("/usage"),
},
]}
/>
</div>
</React.Suspense>
<div className={cn(
"flex",
state === "collapsed" ? "justify-center" : "justify-start"
)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer">
<span className="text-xs font-medium">
{(user?.displayName || (user as { primaryEmail?: string })?.primaryEmail || "")
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0]?.toUpperCase())
.join("")
|| "U"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{user?.displayName && (
<p className="text-sm font-medium">{user.displayName}</p>
)}
{(user as { primaryEmail?: string })?.primaryEmail && (
<p className="text-xs text-muted-foreground">{(user as { primaryEmail?: string }).primaryEmail}</p>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/handler/account-settings")} className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
Account settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/usage")} className="cursor-pointer">
<CircleDollarSign className="mr-2 h-4 w-4" />
Usage
</DropdownMenuItem>
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Theme Toggle - at the very bottom */}

View file

@ -19,20 +19,16 @@ export function ConversationsList({ testSessionId }: ConversationsListProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, getAccessToken } = useAuth();
const { user } = useAuth();
useEffect(() => {
const fetchConversations = async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await getTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGet({
path: {
test_session_id: testSessionId
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// API returns { conversation: Conversation | null }
@ -56,7 +52,7 @@ export function ConversationsList({ testSessionId }: ConversationsListProps) {
// Poll for updates every 5 seconds
const interval = setInterval(fetchConversations, 5000);
return () => clearInterval(interval);
}, [testSessionId, user, getAccessToken]);
}, [testSessionId, user]);
if (loading && conversations.length === 0) {
return (

Some files were not shown because too many files have changed in this diff Show more