From c71192016561333a109393186cf0d3b70bbd894d Mon Sep 17 00:00:00 2001 From: Sabiha Khan <87858386+chewwbaka@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:33:33 +0530 Subject: [PATCH 01/11] feat: telephony call transfer (#155) * transfer call * fix: ignore completed call status * chore: refactor telephony * chore: refactor pipecat engine custom tools and other telephony services * chore: code refactor * chore: put back office ambient sound files * chore: remove transport from engine * fix: fix alembic revision * chore: remove set_transferring_call from engine * fix: send OutputAudio frame and let transport chunk it * fix: reinstate docker compose * chore: remove unused transfer-twmil route for caller * chore: update pipecat submodule --------- Co-authored-by: Abhishek Kumar --- .claude/settings.json | 6 + ...4d54e8f_add_transfer_call_tool_category.py | 50 +++ api/assets/transfer_hold_ring_16000.wav | Bin 0 -> 32044 bytes api/assets/transfer_hold_ring_8000.wav | Bin 0 -> 16044 bytes api/enums.py | 3 +- api/routes/telephony.py | 226 ++++++++++- api/routes/tool.py | 60 ++- api/services/configuration/registry.py | 10 +- api/services/pipecat/event_handlers.py | 2 +- api/services/pipecat/run_pipeline.py | 5 +- api/services/smart_turn/app.py | 3 +- api/services/telephony/base.py | 43 +++ .../telephony/call_transfer_manager.py | 200 ++++++++++ .../telephony/providers/cloudonix_provider.py | 27 ++ .../telephony/providers/twilio_provider.py | 128 +++++++ .../telephony/providers/vobiz_provider.py | 27 ++ .../telephony/providers/vonage_provider.py | 27 ++ .../telephony/transfer_event_protocol.py | 102 +++++ api/services/workflow/pipecat_engine.py | 45 ++- .../workflow/pipecat_engine_custom_tools.py | 361 +++++++++++++++++- api/utils/hold_audio.py | 94 +++++ docker-compose.yaml | 2 +- pipecat | 2 +- ui/package-lock.json | 179 ++++++--- .../components/TransferCallToolConfig.tsx | 170 +++++++++ .../app/tools/[toolUuid]/components/index.ts | 1 + ui/src/app/tools/[toolUuid]/page.tsx | 120 ++++-- ui/src/app/tools/config.tsx | 51 ++- ui/src/client/sdk.gen.ts | 34 +- ui/src/client/types.gen.ts | 115 +++++- 30 files changed, 1965 insertions(+), 128 deletions(-) create mode 100644 .claude/settings.json create mode 100644 api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py create mode 100644 api/assets/transfer_hold_ring_16000.wav create mode 100644 api/assets/transfer_hold_ring_8000.wav create mode 100644 api/services/telephony/call_transfer_manager.py create mode 100644 api/services/telephony/transfer_event_protocol.py create mode 100644 api/utils/hold_audio.py create mode 100644 ui/src/app/tools/[toolUuid]/components/TransferCallToolConfig.tsx diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3fdd478 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "effortLevel": "high", + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + } +} diff --git a/api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py b/api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py new file mode 100644 index 0000000..2fd6285 --- /dev/null +++ b/api/alembic/versions/1a7d74d54e8f_add_transfer_call_tool_category.py @@ -0,0 +1,50 @@ +"""add transfer_call tool category + +Revision ID: 1a7d74d54e8f +Revises: 34c8537dfde5 +Create Date: 2026-02-03 11:18:11.417837 + +""" + +from typing import Sequence, Union + +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "1a7d74d54e8f" +down_revision: Union[str, None] = "34c8537dfde5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=["http_api", "end_call", "transfer_call", "native", "integration"], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=["http_api", "end_call", "native", "integration"], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### diff --git a/api/assets/transfer_hold_ring_16000.wav b/api/assets/transfer_hold_ring_16000.wav new file mode 100644 index 0000000000000000000000000000000000000000..5cab0216660fcaca1d522b6dd085bb746fc1c9b0 GIT binary patch literal 32044 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 literal 0 HcmV?d00001 diff --git a/api/assets/transfer_hold_ring_8000.wav b/api/assets/transfer_hold_ring_8000.wav new file mode 100644 index 0000000000000000000000000000000000000000..b1133b3a214072a2839cac750545f85480fdc019 GIT binary patch literal 16044 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 Idh1vG2Tr=tNB{r; literal 0 HcmV?d00001 diff --git a/api/enums.py b/api/enums.py index 8b19bcc..053a06e 100644 --- a/api/enums.py +++ b/api/enums.py @@ -122,7 +122,8 @@ class ToolCategory(Enum): HTTP_API = "http_api" # Custom HTTP API calls (implemented) END_CALL = "end_call" # End call tool - NATIVE = "native" # Built-in integrations (future: call_transfer, dtmf_input) + TRANSFER_CALL = "transfer_call" # Transfer call to phone number (Twilio only) + NATIVE = "native" # Built-in integrations (future: dtmf_input) INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.) diff --git a/api/routes/telephony.py b/api/routes/telephony.py index e93dd60..2a69cb0 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -8,9 +8,16 @@ import uuid from datetime import UTC, datetime from typing import Optional -from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Request, + WebSocket, +) from loguru import logger -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from sqlalchemy import text from sqlalchemy.future import select from starlette.responses import HTMLResponse @@ -26,10 +33,15 @@ 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.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 +169,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 @@ -1480,3 +1493,210 @@ async def handle_cloudonix_cdr(request: Request): ) return {"status": "success"} + + +class TransferCallRequest(BaseModel): + """Request model for initiating a call transfer.""" + + destination: str # E.164 format phone number (required) + organization_id: int # Organization ID for provider configuration + transfer_id: str # Unique identifier for tracking this transfer + conference_name: str # Conference name for the transfer + timeout: Optional[int] = 20 # seconds to wait for answer + + @field_validator("destination") + @classmethod + def validate_destination(cls, destination: str) -> str: + """Validate destination is in E.164 format.""" + import re + + if not destination or not destination.strip(): + raise ValueError("Destination phone number is required") + + E164_PHONE_REGEX = r"^\+[1-9]\d{1,14}$" + if not re.match(E164_PHONE_REGEX, destination.strip()): + raise ValueError( + f"Invalid phone number format: {destination}. Must be E.164 format (e.g., +1234567890)" + ) + + return destination.strip() + + +@router.post("/call-transfer") +async def initiate_call_transfer(request: TransferCallRequest): + """Initiate a call transfer via the telephony provider. + + This endpoint only initiates the outbound call. Transfer context + (original_call_sid, etc.) is stored by the caller + before invoking this endpoint. + """ + logger.info( + f"Starting call transfer to {request.destination} with transfer_id: {request.transfer_id}" + ) + + try: + try: + provider = await get_telephony_provider(request.organization_id) + except ValueError as e: + logger.error(f"Transfer provider validation failed: {e}") + raise HTTPException( + status_code=400, detail=f"Call transfer not supported: {str(e)}" + ) + + if not provider.supports_transfers(): + raise HTTPException( + status_code=400, + detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers", + ) + + if not provider.validate_config(): + logger.error(f"Provider {provider.PROVIDER_NAME} configuration is invalid") + raise HTTPException( + status_code=400, + detail=f"Telephony provider '{provider.PROVIDER_NAME}' is not properly configured for transfers", + ) + + logger.info(f"Initiating transfer call via {provider.PROVIDER_NAME} provider") + try: + transfer_result = await provider.transfer_call( + destination=request.destination, + transfer_id=request.transfer_id, + conference_name=request.conference_name, + timeout=request.timeout, + ) + except NotImplementedError as e: + logger.error( + f"Provider {provider.PROVIDER_NAME} doesn't support transfers: {e}" + ) + raise HTTPException( + status_code=400, + detail=f"Provider '{provider.PROVIDER_NAME}' does not support call transfers", + ) + except Exception as e: + logger.error(f"Provider transfer call failed: {e}") + raise HTTPException( + status_code=500, detail=f"Transfer call failed: {str(e)}" + ) + + call_sid = transfer_result.get("call_sid") + logger.info(f"Transfer call initiated successfully: {call_sid}") + logger.debug(f"Transfer result: {transfer_result}") + + return { + "status": "transfer_initiated", + "call_id": call_sid, + "message": f"Calling {request.destination}...", + "transfer_id": request.transfer_id, + "provider": provider.PROVIDER_NAME, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Unexpected error during transfer call: {e}") + raise HTTPException( + status_code=500, detail=f"Internal error during transfer: {str(e)}" + ) + + +@router.post("/transfer-result/{transfer_id}") +async def complete_transfer_function_call(transfer_id: str, request: Request): + """Webhook endpoint to complete the function call with transfer result. + + Called by Twilio's StatusCallback when the transfer call status changes. + """ + form_data = await request.form() + data = dict(form_data) + + call_status = data.get("CallStatus", "") + call_sid = data.get("CallSid", "") + + logger.info( + f"Transfer result(call status) webhook: {transfer_id} status={call_status}" + ) + + # Get transfer context from Redis for additional information + call_transfer_manager = await get_call_transfer_manager() + transfer_context = await call_transfer_manager.get_transfer_context(transfer_id) + + original_call_sid = transfer_context.original_call_sid if transfer_context else None + conference_name = transfer_context.conference_name if transfer_context else None + + # Determine the result based on call status with user-friendly messaging + if call_status in ("in-progress", "answered"): + result = { + "status": "success", + "message": "Great! The destination number answered. Let me transfer you now.", + "action": "transfer_success", + "conference_id": conference_name, + "transfer_call_sid": call_sid, # The outbound transfer call SID + "original_call_sid": original_call_sid, # The original caller's SID + "end_call": False, # Continue with transfer + } + elif call_status == "no-answer": + result = { + "status": "transfer_failed", + "reason": "no_answer", + "message": "The transfer call was not answered. The person may be busy or unavailable right now.", + "action": "transfer_failed", + "call_sid": call_sid, + "end_call": True, + } + elif call_status == "busy": + result = { + "status": "transfer_failed", + "reason": "busy", + "message": "The transfer call encountered a busy signal. The person is likely on another call.", + "action": "transfer_failed", + "call_sid": call_sid, + "end_call": True, + } + elif call_status == "failed": + result = { + "status": "transfer_failed", + "reason": "call_failed", + "message": "The transfer call failed to connect. There may be a network issue or the number is unavailable.", + "action": "transfer_failed", + "call_sid": call_sid, + "end_call": True, + } + else: + # Intermediate status (ringing, in-progress, etc.), don't complete yet + logger.info( + f"Received intermediate status {call_status}, waiting for final status" + ) + return {"status": "pending"} + + # Complete the function call with Redis event publishing + try: + # Determine event type based on result status + if result["status"] == "success": + event_type = TransferEventType.TRANSFER_COMPLETED + elif result.get("reason") == "timeout": + event_type = TransferEventType.TRANSFER_TIMEOUT + else: + event_type = TransferEventType.TRANSFER_FAILED + + transfer_event = TransferEvent( + type=event_type, + transfer_id=transfer_id, + original_call_sid=original_call_sid or "", + transfer_call_sid=call_sid, + conference_name=conference_name, + message=result.get("message", ""), + status=result["status"], + action=result.get("action", ""), + reason=result.get("reason"), + end_call=result.get("end_call", False), + ) + + # Publish the event via Redis + await call_transfer_manager.publish_transfer_event(transfer_event) + logger.info( + f"Published {event_type} event for {transfer_id} with result: {result['status']}" + ) + + except Exception as e: + logger.error(f"Error completing transfer {transfer_id}: {e}") + + return {"status": "completed", "result": result} diff --git a/api/routes/tool.py b/api/routes/tool.py index f6ee635..6430b1a 100644 --- a/api/routes/tool.py +++ b/api/routes/tool.py @@ -1,10 +1,11 @@ """API routes for managing tools.""" +import re from datetime import datetime from typing import Annotated, Any, Dict, List, Literal, Optional, Union from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from api.db import db_client from api.db.models import UserModel @@ -56,6 +57,42 @@ class EndCallConfig(BaseModel): ) +class TransferCallConfig(BaseModel): + """Configuration for Transfer Call tools.""" + + destination: str = Field( + description="Phone number to transfer the call to (E.164 format, e.g., +1234567890)" + ) + messageType: Literal["none", "custom"] = Field( + default="none", description="Type of message to play before transfer" + ) + customMessage: Optional[str] = Field( + default=None, description="Custom message to play before transferring the call" + ) + timeout: int = Field( + default=30, + ge=5, + le=120, + description="Maximum time in seconds to wait for destination to answer (5-120 seconds)", + ) + + @field_validator("destination") + @classmethod + def validate_destination(cls, v: str) -> str: + """Validate that destination is a valid E.164 phone number.""" + # Allow empty string for initial creation (like HTTP API tools with empty URL) + if not v.strip(): + return v + + # E.164 format: +[1-9]\d{1,14} + e164_pattern = r"^\+[1-9]\d{1,14}$" + if not re.match(e164_pattern, v): + raise ValueError( + "Destination must be a valid E.164 phone number (e.g., +1234567890)" + ) + return v + + class HttpApiToolDefinition(BaseModel): """Tool definition for HTTP API tools.""" @@ -72,9 +109,17 @@ class EndCallToolDefinition(BaseModel): config: EndCallConfig = Field(description="End Call configuration") +class TransferCallToolDefinition(BaseModel): + """Tool definition for Transfer Call tools.""" + + schema_version: int = Field(default=1, description="Schema version") + type: Literal["transfer_call"] = Field(description="Tool type") + config: TransferCallConfig = Field(description="Transfer Call configuration") + + # Union type for tool definitions - Pydantic will discriminate based on 'type' field ToolDefinition = Annotated[ - Union[HttpApiToolDefinition, EndCallToolDefinition], + Union[HttpApiToolDefinition, EndCallToolDefinition, TransferCallToolDefinition], Field(discriminator="type"), ] @@ -89,6 +134,17 @@ class CreateToolRequest(BaseModel): icon_color: Optional[str] = Field(default="#3B82F6", max_length=7) definition: ToolDefinition + @field_validator("category") + @classmethod + def validate_category(cls, v: str) -> str: + """Validate that category is a valid ToolCategory value.""" + valid_categories = [c.value for c in ToolCategory] + if v not in valid_categories: + raise ValueError( + f"Invalid category '{v}'. Must be one of: {', '.join(valid_categories)}" + ) + return v + class UpdateToolRequest(BaseModel): """Request schema for updating a tool.""" diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index c9e3675..ab28a0d 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -278,7 +278,15 @@ class DograhTTSService(BaseTTSConfiguration): SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"] -SARVAM_V2_VOICES = ["anushka", "manisha", "vidya", "arya", "abhilash", "karun", "hitesh"] +SARVAM_V2_VOICES = [ + "anushka", + "manisha", + "vidya", + "arya", + "abhilash", + "karun", + "hitesh", +] SARVAM_V3_VOICES = [ "shubh", "aditya", diff --git a/api/services/pipecat/event_handlers.py b/api/services/pipecat/event_handlers.py index fea4a6f..b8d4111 100644 --- a/api/services/pipecat/event_handlers.py +++ b/api/services/pipecat/event_handlers.py @@ -80,6 +80,7 @@ def register_event_handlers( @transport.event_handler("on_client_disconnected") async def on_client_disconnected(_transport, _participant): call_disposed = engine.is_call_disposed() + logger.debug( f"In on_client_disconnected callback handler. Call disposed: {call_disposed}" ) @@ -87,7 +88,6 @@ def register_event_handlers( # Stop recordings await audio_buffer.stop_recording() - # End the call await engine.end_call_with_reason( EndTaskReason.USER_HANGUP.value, abort_immediately=True ) diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index f0107e9..191e6cc 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -552,11 +552,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) + engine.set_audio_config(audio_config) # Set Stasis connection for immediate transfers (if available) if stasis_connection: diff --git a/api/services/smart_turn/app.py b/api/services/smart_turn/app.py index 6bbd0ab..66ccf5a 100644 --- a/api/services/smart_turn/app.py +++ b/api/services/smart_turn/app.py @@ -21,9 +21,10 @@ from fastapi import ( status, ) from fastapi.websockets import WebSocketState -from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2 from scipy.io import wavfile +from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2 + LOG_LEVEL = ( logging.DEBUG if os.environ.get("LOG_LEVEL", "DEBUG").lower() == "debug" diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index ee1d05f..a154946 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -309,3 +309,46 @@ class TelephonyProvider(ABC): Tuple of (Response, media_type) - Response object and content type """ pass + + # ======== CALL TRANSFER METHODS ======== + + @abstractmethod + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Initiate a call transfer to a destination number. + + Args: + destination: The destination phone number (E.164 format) + transfer_id: Unique identifier for tracking this transfer + conference_name: Name of the conference to join the destination into + timeout: Transfer timeout in seconds + **kwargs: Provider-specific additional parameters + + Returns: + Dict containing: + - call_sid: Provider's call identifier + - status: Transfer initiation status + - provider: Provider name + + Raises: + NotImplementedError: If provider doesn't support transfers + ValueError: If provider configuration is invalid + """ + pass + + @abstractmethod + def supports_transfers(self) -> bool: + """ + Check if this provider supports call transfers. + + Returns: + True if provider supports call transfers, False otherwise + """ + pass diff --git a/api/services/telephony/call_transfer_manager.py b/api/services/telephony/call_transfer_manager.py new file mode 100644 index 0000000..f843850 --- /dev/null +++ b/api/services/telephony/call_transfer_manager.py @@ -0,0 +1,200 @@ +"""Redis-based transfer event coordination service + +Handles transfer event publishing, subscription, and context storage +""" + +import asyncio +import time +from typing import Dict, Optional + +import redis.asyncio as aioredis +from loguru import logger + +from api.constants import REDIS_URL +from api.services.telephony.transfer_event_protocol import ( + TransferContext, + TransferEvent, + TransferEventType, + TransferRedisChannels, +) + + +class CallTransferManager: + """Manages call transfer events and context storage using Redis.""" + + def __init__(self, redis_client: Optional[aioredis.Redis] = None): + self._redis_client = redis_client + self._pubsub_connections: Dict[str, aioredis.client.PubSub] = {} + + async def _get_redis(self) -> aioredis.Redis: + """Get Redis client instance.""" + if not self._redis_client: + self._redis_client = await aioredis.from_url( + REDIS_URL, decode_responses=True + ) + return self._redis_client + + async def store_transfer_context( + self, context: TransferContext, ttl: int = 300 + ) -> None: + """Store transfer context in Redis with TTL. + + Args: + context: Transfer context data + ttl: Time to live in seconds (default 5 minutes) + """ + try: + redis = await self._get_redis() + key = TransferRedisChannels.transfer_context_key(context.transfer_id) + await redis.setex(key, ttl, context.to_json()) + logger.debug(f"Stored transfer context for {context.transfer_id}") + except Exception as e: + logger.error(f"Failed to store transfer context: {e}") + + async def get_transfer_context(self, transfer_id: str) -> Optional[TransferContext]: + """Retrieve transfer context from Redis. + + Args: + transfer_id: Transfer identifier + + Returns: + Transfer context if found, None otherwise + """ + try: + redis = await self._get_redis() + key = TransferRedisChannels.transfer_context_key(transfer_id) + data = await redis.get(key) + if data: + return TransferContext.from_json(data) + return None + except Exception as e: + logger.error(f"Failed to get transfer context: {e}") + return None + + async def remove_transfer_context(self, transfer_id: str) -> None: + """Remove transfer context from Redis. + + Args: + transfer_id: Transfer identifier + """ + try: + redis = await self._get_redis() + key = TransferRedisChannels.transfer_context_key(transfer_id) + await redis.delete(key) + logger.debug(f"Removed transfer context for {transfer_id}") + except Exception as e: + logger.error(f"Failed to remove transfer context: {e}") + + async def publish_transfer_event(self, event: TransferEvent) -> None: + """Publish transfer event to Redis channel. + + Args: + event: Transfer event to publish + """ + try: + # Add timestamp if not present + if event.timestamp is None: + event.timestamp = time.time() + + redis = await self._get_redis() + channel = TransferRedisChannels.transfer_events(event.transfer_id) + await redis.publish(channel, event.to_json()) + logger.info(f"Published {event.type} event for {event.transfer_id}") + except Exception as e: + logger.error(f"Failed to publish transfer event: {e}") + + async def wait_for_transfer_completion( + self, transfer_id: str, timeout_seconds: float = 30.0 + ) -> Optional[TransferEvent]: + """Wait for transfer completion event using Redis pub/sub. + + Args: + transfer_id: Transfer identifier to wait for + timeout_seconds: Maximum time to wait + + Returns: + Transfer completion event if received, None on timeout + """ + channel = TransferRedisChannels.transfer_events(transfer_id) + redis = await self._get_redis() + pubsub = redis.pubsub() + + try: + await pubsub.subscribe(channel) + logger.info( + f"Waiting for transfer completion on {channel} (timeout: {timeout_seconds}s)" + ) + + # Wait for completion event with timeout + async def wait_for_message(): + async for message in pubsub.listen(): + if message["type"] == "message": + try: + event = TransferEvent.from_json(message["data"]) + logger.info( + f"Received {event.type} event for {transfer_id}" + ) + + # Check if this is a completion event + if ( + event.type + in [ + TransferEventType.TRANSFER_ANSWERED, # Call answered = transfer successful + TransferEventType.TRANSFER_COMPLETED, + TransferEventType.TRANSFER_FAILED, + TransferEventType.TRANSFER_CANCELLED, + TransferEventType.TRANSFER_TIMEOUT, + ] + ): + return event + except Exception as e: + logger.error(f"Failed to parse transfer event: {e}") + continue + return None + + # Wait with timeout + result = await asyncio.wait_for(wait_for_message(), timeout=timeout_seconds) + return result + + except asyncio.TimeoutError: + logger.debug(f"Transfer completion wait timed out for {transfer_id}") + return None + except Exception as e: + logger.error(f"Error waiting for transfer completion: {e}") + return None + finally: + try: + await pubsub.unsubscribe(channel) + await pubsub.close() + except Exception as e: + logger.error(f"Error closing pubsub connection: {e}") + + async def cleanup(self): + """Clean up Redis connections.""" + try: + # Close pubsub connections + for pubsub in self._pubsub_connections.values(): + try: + await pubsub.close() + except: + pass + self._pubsub_connections.clear() + + # Close main Redis connection + if self._redis_client: + await self._redis_client.close() + self._redis_client = None + except Exception as e: + logger.error(f"Error during transfer coordinator cleanup: {e}") + + +# Global call transfer manager instance +_call_transfer_manager: Optional[CallTransferManager] = None + + +async def get_call_transfer_manager() -> CallTransferManager: + """Get or create the global call transfer manager instance.""" + global _call_transfer_manager + if not _call_transfer_manager: + _call_transfer_manager = CallTransferManager() + return _call_transfer_manager diff --git a/api/services/telephony/providers/cloudonix_provider.py b/api/services/telephony/providers/cloudonix_provider.py index c6b2a55..d26849b 100644 --- a/api/services/telephony/providers/cloudonix_provider.py +++ b/api/services/telephony/providers/cloudonix_provider.py @@ -680,3 +680,30 @@ class CloudonixProvider(TelephonyProvider): """ return Response(content=twiml, media_type="application/xml"), "application/xml" + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Cloudonix provider does not support call transfers. + + Raises: + NotImplementedError: Cloudonix call transfers are yet to be implemented + """ + raise NotImplementedError("Cloudonix provider does not support call transfers") + + def supports_transfers(self) -> bool: + """ + Cloudonix does not support call transfers. + + Returns: + False - Cloudonix provider does not support call transfers + """ + return False diff --git a/api/services/telephony/providers/twilio_provider.py b/api/services/telephony/providers/twilio_provider.py index 3c020b0..764227e 100644 --- a/api/services/telephony/providers/twilio_provider.py +++ b/api/services/telephony/providers/twilio_provider.py @@ -72,6 +72,7 @@ class TwilioProvider(TelephonyProvider): if from_number is None: from_number = random.choice(self.from_numbers) logger.info(f"Selected phone number {from_number} for outbound call") + logger.info(f"Webhook url received - {webhook_url}") # Prepare call data data = {"To": to_number, "From": from_number, "Url": webhook_url} @@ -172,6 +173,7 @@ class TwilioProvider(TelephonyProvider): """ + logger.info(f"Twiml content generated - {twiml_content}") return twiml_content async def get_call_cost(self, call_id: str) -> Dict[str, Any]: @@ -459,3 +461,129 @@ class TwilioProvider(TelephonyProvider): """ return Response(content=twiml_content, media_type="application/xml") + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Initiate a call transfer via Twilio. + + Uses inline TwiML to put the destination into a conference when they answer, + and a status callback to track the transfer outcome. + + Args: + destination: The destination phone number (E.164 format) + transfer_id: Unique identifier for tracking this transfer + conference_name: Name of the conference to join the destination into + timeout: Transfer timeout in seconds + **kwargs: Additional Twilio-specific parameters + + Returns: + Dict containing transfer result information + + Raises: + ValueError: If provider configuration is invalid + Exception: If Twilio API call fails + """ + if not self.validate_config(): + raise ValueError("Twilio provider not properly configured") + + # Select a random phone number for the transfer + from_number = random.choice(self.from_numbers) + logger.info(f"Selected phone number {from_number} for transfer call") + + backend_endpoint, _ = await get_backend_endpoints() + + status_callback_url = ( + f"{backend_endpoint}/api/v1/telephony/transfer-result/{transfer_id}" + ) + + # Inline TwiML: when the destination answers, put them into the conference + twiml = f""" + + You have answered a transfer call. Connecting you now. + + {conference_name} + +""" + + # Prepare Twilio API call data + endpoint = f"{self.base_url}/Calls.json" + data = { + "To": destination, + "From": from_number, + "Timeout": timeout, + "Twiml": twiml, + "StatusCallback": status_callback_url, + "StatusCallbackEvent": [ + "answered", + "no-answer", + "busy", + "failed", + "completed", + ], + "StatusCallbackMethod": "POST", + } + + # Add any additional kwargs + data.update(kwargs) + + try: + logger.debug(f"Transfer call data: {data}") + + async with aiohttp.ClientSession() as session: + auth = aiohttp.BasicAuth(self.account_sid, self.auth_token) + async with session.post(endpoint, data=data, auth=auth) as response: + response_status = response.status + response_text = await response.text() + + logger.info( + f"Twilio transfer API response status: {response_status}" + ) + logger.debug(f"Twilio transfer API response body: {response_text}") + + if response_status in [200, 201]: + try: + response_data = await response.json() + call_sid = response_data.get("sid") + logger.info( + f"Transfer call initiated successfully: {call_sid}" + ) + + return { + "call_sid": call_sid, + "status": response_data.get("status", "queued"), + "provider": self.PROVIDER_NAME, + "from_number": from_number, + "to_number": destination, + "raw_response": response_data, + } + except Exception as e: + logger.error( + f"Failed to parse Twilio transfer response JSON: {e}" + ) + raise Exception(f"Failed to parse transfer response: {e}") + else: + error_msg = f"Twilio API call failed with status {response_status}: {response_text}" + logger.error(error_msg) + raise Exception(error_msg) + + except Exception as e: + logger.error(f"Exception during Twilio transfer call: {e}") + raise + + def supports_transfers(self) -> bool: + """ + Twilio supports call transfers. + + Returns: + True - Twilio provider supports call transfers + """ + return True diff --git a/api/services/telephony/providers/vobiz_provider.py b/api/services/telephony/providers/vobiz_provider.py index 0666d85..7e91bed 100644 --- a/api/services/telephony/providers/vobiz_provider.py +++ b/api/services/telephony/providers/vobiz_provider.py @@ -533,3 +533,30 @@ class VobizProvider(TelephonyProvider): """ return Response(content=vobiz_xml_content, media_type="application/xml") + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Vobiz provider does not support call transfers. + + Raises: + NotImplementedError: Vobiz call transfers are yet to be implemented + """ + raise NotImplementedError("Vobiz provider does not support call transfers") + + def supports_transfers(self) -> bool: + """ + Vobiz does not support call transfers. + + Returns: + False - Vobiz provider does not support call transfers + """ + return False diff --git a/api/services/telephony/providers/vonage_provider.py b/api/services/telephony/providers/vonage_provider.py index ee25d78..357d5b4 100644 --- a/api/services/telephony/providers/vonage_provider.py +++ b/api/services/telephony/providers/vonage_provider.py @@ -484,3 +484,30 @@ class VonageProvider(TelephonyProvider): ] return Response(content=json.dumps(error_ncco), media_type="application/json") + + # ======== CALL TRANSFER METHODS ======== + + async def transfer_call( + self, + destination: str, + transfer_id: str, + conference_name: str, + timeout: int = 30, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Vonage provider does not support call transfers. + + Raises: + NotImplementedError: call transfers are yet to be implemented + """ + raise NotImplementedError("Vonage provider does not support call transfers") + + def supports_transfers(self) -> bool: + """ + Vonage does not support call transfers. + + Returns: + False - Vonage provider does not support call transfers + """ + return False diff --git a/api/services/telephony/transfer_event_protocol.py b/api/services/telephony/transfer_event_protocol.py new file mode 100644 index 0000000..6260676 --- /dev/null +++ b/api/services/telephony/transfer_event_protocol.py @@ -0,0 +1,102 @@ +"""Redis communication protocol for call transfer coordination. + +Defines event formats and Redis channels for coordinating call transfers +across multiple API server instances. +""" + +import json +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Any, Dict, Optional + + +class TransferEventType(str, Enum): + """Types of transfer events sent between instances.""" + + TRANSFER_INITIATED = "transfer_initiated" + TRANSFER_ANSWERED = "transfer_answered" + TRANSFER_COMPLETED = "transfer_completed" + TRANSFER_FAILED = "transfer_failed" + TRANSFER_CANCELLED = "transfer_cancelled" + TRANSFER_TIMEOUT = "transfer_timeout" + + +@dataclass +class TransferEvent: + """Event data structure for transfer coordination.""" + + type: TransferEventType + transfer_id: str + original_call_sid: str + transfer_call_sid: Optional[str] = None + target_number: Optional[str] = None + conference_name: Optional[str] = None + message: Optional[str] = None + status: Optional[str] = None + action: Optional[str] = None + reason: Optional[str] = None + end_call: bool = False + timestamp: Optional[float] = None + + def to_json(self) -> str: + """Convert event to JSON string.""" + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, data: str) -> "TransferEvent": + """Create event from JSON string.""" + return cls(**json.loads(data)) + + def to_result_dict(self) -> Dict[str, Any]: + """Convert to function call result format.""" + result = { + "status": self.status or "success", + "message": self.message or "", + "action": self.action or self.type, + "conference_id": self.conference_name, + "transfer_call_sid": self.transfer_call_sid, + "original_call_sid": self.original_call_sid, + "end_call": self.end_call, + "reason": self.reason, + } + return result + + +@dataclass +class TransferContext: + """Transfer context data stored in Redis.""" + + transfer_id: str + call_sid: Optional[str] + target_number: str + tool_uuid: str + original_call_sid: str + conference_name: str + initiated_at: float + + def to_json(self) -> str: + """Convert context to JSON string.""" + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, data: str) -> "TransferContext": + """Create context from JSON string.""" + return cls(**json.loads(data)) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return asdict(self) + + +class TransferRedisChannels: + """Redis channel naming conventions for transfer events.""" + + @staticmethod + def transfer_events(transfer_id: str) -> str: + """Channel for transfer events for a specific transfer.""" + return f"transfer:events:{transfer_id}" + + @staticmethod + def transfer_context_key(transfer_id: str) -> str: + """Redis key for transfer context storage.""" + return f"transfer:context:{transfer_id}" diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index 8e6c74c..56d24d2 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -15,7 +15,6 @@ 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: @@ -61,7 +60,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 +73,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 @@ -113,6 +110,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,14 +207,14 @@ class PipecatEngine: ) logger.info(f"Arguments: {function_call_params.arguments}") - # 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) 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: """ @@ -245,6 +245,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)} @@ -277,6 +278,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) @@ -292,6 +294,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) @@ -302,6 +305,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"), @@ -332,6 +336,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() @@ -532,7 +537,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 @@ -705,6 +712,22 @@ class PipecatEngine: f"Stasis connection set for immediate transfers: {connection.channel_id}" ) + def set_audio_config(self, audio_config) -> None: + """Set the audio configuration for the pipeline.""" + self._audio_config = audio_config + + 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: + mute: True to mute user input, False to allow input + """ + 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.""" self._current_llm_generation_reference_text += text diff --git a/api/services/workflow/pipecat_engine_custom_tools.py b/api/services/workflow/pipecat_engine_custom_tools.py index b60ea79..e07fb6b 100644 --- a/api/services/workflow/pipecat_engine_custom_tools.py +++ b/api/services/workflow/pipecat_engine_custom_tools.py @@ -6,12 +6,20 @@ during workflow execution. from __future__ import annotations +import asyncio +import re +import time +import uuid from typing import TYPE_CHECKING, Any, Optional from loguru import logger +from api.constants import APP_ROOT_DIR from api.db import db_client from api.enums import ToolCategory +from api.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,321 @@ 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 + + # 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 + + # Get original callSID from gathered_context + workflow_run = await db_client.get_workflow_run_by_id( + self._engine._workflow_run_id + ) + original_call_sid = workflow_run.gathered_context.get("call_id") + + # Generate a unique transfer ID for tracking this transfer + transfer_id = str(uuid.uuid4()) + + # Compute conference name from original call SID + conference_name = f"transfer-{original_call_sid}" + + # Mute the pipeline + self._engine.set_mute_pipeline(True) + + # Initiate transfer via provider with inline TwiML + transfer_result = await provider.transfer_call( + destination=destination, + transfer_id=transfer_id, + conference_name=conference_name, + timeout=timeout_seconds, + ) + + call_sid = transfer_result.get("call_sid") + logger.info(f"Transfer call initiated successfully: {call_sid}") + + # TODO: Possible race here between saving the transfer context + # and getting a callback response from Twilio? Should we store_transfer_context + # before sending request to Twilio and update the transfer context afterwards? + + # Store transfer context in Redis + call_transfer_manager = await get_call_transfer_manager() + transfer_context = TransferContext( + transfer_id=transfer_id, + call_sid=call_sid, + target_number=destination, + tool_uuid=tool.tool_uuid, + original_call_sid=original_call_sid, + conference_name=conference_name, + initiated_at=time.time(), + ) + await call_transfer_manager.store_transfer_context(transfer_context) + + # Wait for status callback completion using Redis pub/sub + logger.info( + f"Transfer call initiated for {destination} (transfer_id={transfer_id}), waiting for completion..." + ) + + # Start hold music during transfer waiting period + hold_music_stop_event = asyncio.Event() + hold_music_task = None + + try: + # Use audio config for sample rate (set during pipeline setup) + sample_rate = ( + self._engine._audio_config.transport_out_sample_rate + if self._engine._audio_config + else 8000 + ) + + logger.info( + f"Starting hold music at {sample_rate}Hz while waiting for transfer" + ) + + # Start hold music as background task + hold_music_task = asyncio.create_task( + self.play_hold_music_loop(hold_music_stop_event, sample_rate) + ) + + # Wait for transfer completion using Redis pub/sub + logger.info("Waiting for transfer completion via Redis pub/sub...") + transfer_event = ( + await call_transfer_manager.wait_for_transfer_completion( + transfer_id, timeout_seconds + ) + ) + + except Exception as e: + logger.error(f"Error during transfer wait: {e}") + transfer_event = None + + finally: + # Single cleanup point: stop hold music, unmute pipeline, remove context + logger.info( + "Transfer wait ended, cleaning up hold music, pipeline state, and transfer context" + ) + hold_music_stop_event.set() + if hold_music_task: + await hold_music_task + self._engine.set_mute_pipeline(False) + await call_transfer_manager.remove_transfer_context(transfer_id) + + # Handle result (after cleanup) + if transfer_event: + final_result = transfer_event.to_result_dict() + await self._handle_transfer_result( + final_result, function_call_params, properties + ) + else: + logger.error( + f"Transfer call timed out or failed after {timeout_seconds} seconds" + ) + timeout_result = { + "status": "failed", + "message": "I'm sorry, but the call is taking longer than expected to connect. The person might not be available right now. Please try calling back later.", + "action": "transfer_failed", + "reason": "timeout", + "end_call": True, + } + await self._handle_transfer_result( + timeout_result, function_call_params, properties + ) + + except Exception as e: + logger.error( + f"Transfer call tool '{function_name}' execution failed: {e}" + ) + self._engine.set_mute_pipeline(False) + + # Handle generic exception with user-friendly message + exception_result = { + "status": "failed", + "message": "I'm sorry, but something went wrong while trying to transfer your call. Please try again later or contact support if the problem persists.", + "action": "transfer_failed", + "reason": "execution_error", + "end_call": True, + } + + await self._handle_transfer_result( + exception_result, function_call_params, properties + ) + + return transfer_call_handler + + async def _handle_transfer_result( + self, result: dict, function_call_params, properties + ): + """Handle different transfer call outcomes and take appropriate action.""" + action = result.get("action", "") + status = result.get("status", "") + + logger.info(f"Handling transfer result: action={action}, status={status}") + + if action == "transfer_success": + # Successful transfer - add original caller to conference and end pipeline + conference_id = result.get("conference_id") + original_call_sid = result.get("original_call_sid") + transfer_call_sid = result.get("transfer_call_sid") + + logger.info( + f"Transfer successful! Conference: {conference_id}, Original: {original_call_sid}, Transfer: {transfer_call_sid}" + ) + + # Inform LLM of success and end the call with Transfer call reason + response_properties = FunctionCallResultProperties(run_llm=False) + await function_call_params.result_callback( + { + "status": "transfer_success", + "message": "Transfer successful - connecting to conference", + "conference_id": conference_id, + }, + properties=response_properties, + ) + + await self._engine.end_call_with_reason( + EndTaskReason.TRANSFER_CALL.value, abort_immediately=False + ) + + elif action == "transfer_failed": + # Transfer failed - inform user via LLM and then end the call + reason = result.get("reason", "unknown") + logger.info(f"Transfer failed ({reason}), informing user") + + await function_call_params.result_callback( + { + "status": "transfer_failed", + "reason": reason, + "message": "Transfer failed", + } + ) + else: + # Unknown action, treat as generic success + logger.warning(f"Unknown transfer action: {action}, treating as success") + await function_call_params.result_callback(result) + + async def play_hold_music_loop( + self, stop_event: asyncio.Event, sample_rate: int = 8000 + ): + """Play hold music in a loop until stop event is triggered. + + Args: + stop_event: Event to stop the hold music loop + sample_rate: Sample rate for the hold music (default 8000Hz for Twilio) + """ + try: + # Path to hold music file based on sample rate + hold_music_file = ( + APP_ROOT_DIR / "assets" / f"transfer_hold_ring_{sample_rate}.wav" + ) + hold_audio_data = load_hold_audio(hold_music_file, sample_rate) + num_samples = len(hold_audio_data) // 2 + duration = int(num_samples / sample_rate) + + logger.info(f"Starting hold music loop with file: {hold_music_file}") + + while not stop_event.is_set(): + # Queue the hold audio frame + frame = OutputAudioRawFrame( + audio=hold_audio_data, + sample_rate=sample_rate, + num_channels=1, + ) + await self._engine.task.queue_frame(frame) + + # Wait for the audio to play or until stopped + try: + await asyncio.wait_for(stop_event.wait(), timeout=duration + 1.5) + break # Stop event was set + except asyncio.TimeoutError: + pass # Continue looping + + logger.info("Hold music loop stopped") + + except Exception as e: + logger.error(f"Error in hold music loop: {e}") diff --git a/api/utils/hold_audio.py b/api/utils/hold_audio.py new file mode 100644 index 0000000..e77f24f --- /dev/null +++ b/api/utils/hold_audio.py @@ -0,0 +1,94 @@ +""" +Hold audio utility for loading and caching hold music files. + +This module provides functionality to load hold music audio files at specific sample rates +with caching to improve performance during multiple calls. +""" + +from typing import Dict, Optional, Tuple + +import numpy as np +from loguru import logger + +try: + import soundfile as sf +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error("In order to use hold audio, you need to `pip install soundfile`.") + raise Exception(f"Missing module: {e}") + + +# Global cache for loaded hold music data +_hold_audio_cache: Dict[Tuple[str, int], np.ndarray] = {} + + +def load_hold_audio(file_path: str, sample_rate: int) -> Optional[bytes]: + """Load hold music audio file at the specified sample rate with caching. + + Args: + file_path: Path to the hold music audio file + sample_rate: Target sample rate (8000 or 16000 Hz supported) + + Returns: + Audio data as bytes (PCM16) or None if loading failed + """ + cache_key = (file_path, sample_rate) + + # Check cache first + if cache_key in _hold_audio_cache: + logger.debug(f"Using cached hold audio for {file_path} at {sample_rate}Hz") + audio_data = _hold_audio_cache[cache_key] + return audio_data.tobytes() + + try: + logger.info(f"Loading hold audio from {file_path} at {sample_rate}Hz") + + # Load audio file + sound, file_sample_rate = sf.read(file_path, dtype="int16") + logger.info( + f"Audio file loaded - file sample_rate: {file_sample_rate}, target: {sample_rate}" + ) + + # Ensure mono audio (take first channel if stereo) + if len(sound.shape) > 1: + sound = sound[:, 0] + + # Resample if needed + if file_sample_rate != sample_rate: + logger.warning( + f"Hold music file has sample rate {file_sample_rate}, expected {sample_rate}" + ) + # For now, we'll use the audio as-is and let the transport handle resampling + # In a production system, you might want to use librosa or scipy for proper resampling + + # Convert to int16 and cache + audio_data = sound.astype(np.int16) + _hold_audio_cache[cache_key] = audio_data + + logger.info( + f"Hold audio loaded successfully: {len(audio_data)} samples at {sample_rate}Hz" + ) + return audio_data.tobytes() + + except Exception as e: + logger.error(f"Failed to load hold audio file {file_path}: {e}") + return None + + +def clear_hold_audio_cache(): + """Clear the hold audio cache to free memory.""" + global _hold_audio_cache + _hold_audio_cache.clear() + logger.info("Hold audio cache cleared") + + +def get_cache_info() -> Dict[str, int]: + """Get information about the current cache state. + + Returns: + Dictionary with cache statistics + """ + return { + "cached_files": len(_hold_audio_cache), + "total_cache_size": sum(len(data) for data in _hold_audio_cache.values()), + } diff --git a/docker-compose.yaml b/docker-compose.yaml index 5fb3c53..21402f7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -214,4 +214,4 @@ volumes: networks: app-network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/pipecat b/pipecat index e180bd3..e5390c0 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit e180bd3c2abc3cebbdf5e2d7955d9928cca5d219 +Subproject commit e5390c06c158d7051640e5e295c51f879ad143c3 diff --git a/ui/package-lock.json b/ui/package-lock.json index 866d461..2862dc7 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "1.10.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.10.0", + "version": "1.13.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", "@hey-api/client-fetch": "^0.10.0", @@ -777,7 +777,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -970,6 +969,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", + "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1093,6 +1093,7 @@ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -1111,13 +1112,15 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/babel-plugin/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1127,6 +1130,7 @@ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1139,19 +1143,22 @@ "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/react": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1176,6 +1183,7 @@ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -1188,19 +1196,22 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/unitless": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -1209,13 +1220,15 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", @@ -1833,7 +1846,6 @@ "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.66.2.tgz", "integrity": "sha512-77nofk/zacBNDwVb86kjS2sMIrwbwoBgUNw10crhPPrhV7HUs6A4SzZxePLEGRyHbM54v0g+XL6P8DSr98BM+A==", "license": "MIT", - "peer": true, "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.4", "c12": "2.0.1", @@ -2492,6 +2504,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2749,7 +2762,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2759,6 +2771,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz", "integrity": "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, @@ -4140,6 +4153,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4174,7 +4188,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -8924,7 +8937,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11069,6 +11081,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -11079,6 +11092,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -11131,7 +11145,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/pg": { "version": "8.6.1", @@ -11158,7 +11173,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -11169,7 +11183,6 @@ "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -11179,6 +11192,7 @@ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*" } @@ -11701,6 +11715,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -11710,25 +11725,29 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -11739,13 +11758,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11758,6 +11779,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -11767,6 +11789,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -11775,13 +11798,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11798,6 +11823,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -11811,6 +11837,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11823,6 +11850,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -11837,6 +11865,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -11846,13 +11875,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.9.2", @@ -11919,7 +11950,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11980,6 +12010,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11997,6 +12028,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12012,7 +12044,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ansi-regex": { "version": "5.0.1", @@ -12330,6 +12363,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -12436,7 +12470,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -12617,6 +12650,7 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0" } @@ -12837,6 +12871,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", + "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -12988,7 +13023,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -13295,6 +13329,7 @@ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" @@ -13386,6 +13421,7 @@ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", + "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -13394,7 +13430,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-abstract": { "version": "1.23.9", @@ -13512,7 +13549,8 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -13661,7 +13699,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13835,7 +13872,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -14123,6 +14159,7 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -14221,7 +14258,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fast-xml-parser": { "version": "5.2.5", @@ -14286,7 +14324,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/find-up": { "version": "5.0.0", @@ -14650,7 +14689,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", @@ -14915,7 +14955,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -15482,6 +15521,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -15496,6 +15536,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15583,7 +15624,8 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema": { "version": "0.4.0", @@ -15921,13 +15963,15 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.11.5" } @@ -16003,13 +16047,15 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/merge2": { "version": "1.4.1", @@ -16202,7 +16248,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", @@ -16614,6 +16659,7 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -16672,6 +16718,7 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -17084,6 +17131,7 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -17103,7 +17151,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17134,7 +17181,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -17161,7 +17207,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17186,15 +17231,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -17334,6 +17377,7 @@ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -17398,8 +17442,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17437,7 +17480,8 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -17474,6 +17518,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17573,7 +17618,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.35.0.tgz", "integrity": "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -17675,7 +17719,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/safe-push-apply": { "version": "1.0.0", @@ -17732,6 +17777,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -17768,6 +17814,7 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -17779,7 +17826,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/secure-json-parse": { "version": "4.0.0", @@ -17814,6 +17862,7 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -18385,7 +18434,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/supports-color": { "version": "7.2.0", @@ -18425,8 +18475,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz", "integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -18468,6 +18517,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.42.0.tgz", "integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -18486,6 +18536,7 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -18519,7 +18570,8 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/thread-stream": { "version": "3.1.0", @@ -18592,7 +18644,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18654,8 +18705,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.19.3", @@ -18793,7 +18843,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18999,6 +19048,7 @@ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -19085,6 +19135,7 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "license": "MIT", + "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -19110,6 +19161,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -19172,6 +19224,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -19185,6 +19238,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -19448,6 +19502,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "license": "ISC", + "peer": true, "engines": { "node": ">= 6" } @@ -19556,7 +19611,6 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", - "peer": true, "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", @@ -19599,7 +19653,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/ui/src/app/tools/[toolUuid]/components/TransferCallToolConfig.tsx b/ui/src/app/tools/[toolUuid]/components/TransferCallToolConfig.tsx new file mode 100644 index 0000000..067fad7 --- /dev/null +++ b/ui/src/app/tools/[toolUuid]/components/TransferCallToolConfig.tsx @@ -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 ( + + + Transfer Call Configuration + + Configure call transfer settings (Twilio only) + + + +
+ + + onNameChange(e.target.value)} + placeholder="e.g., Transfer Call" + /> +
+ +
+ + +