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 0000000..5cab021 Binary files /dev/null and b/api/assets/transfer_hold_ring_16000.wav differ diff --git a/api/assets/transfer_hold_ring_8000.wav b/api/assets/transfer_hold_ring_8000.wav new file mode 100644 index 0000000..b1133b3 Binary files /dev/null and b/api/assets/transfer_hold_ring_8000.wav differ diff --git a/api/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" + /> +
+ +
+ + +