diff --git a/.env.example b/.env.example deleted file mode 100644 index 3a7c634..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -# Core application config -ENVIRONMENT: "local" -LOG_LEVEL: "INFO" - -# Database configuration (using containerized postgres) -DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres" - -# Redis configuration (using containerized redis) -REDIS_URL: "redis://:redissecret@redis:6379" - -# Storage configuration - using local MinIO -ENABLE_AWS_S3: "false" - -# MinIO -MINIO_ENDPOINT: "minio:9000" -MINIO_ACCESS_KEY: "minioadmin" -MINIO_SECRET_KEY: "minioadmin" -MINIO_BUCKET: "voice-audio" -MINIO_SECURE: "false" - -# Langfuse -ENABLE_TRACING: "false" -# LANGFUSE_SECRET_KEY: "" -# LANGFUSE_PUBLIC_KEY: "" -# LANGFUSE_HOST: "https://langfuse.dograh.com" - -# Sentry -ENABLE_SETRY: "false" -SENTRY_DSN: "" \ No newline at end of file diff --git a/api/.env.example b/api/.env.example index 9361d25..c9451a4 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,81 +2,33 @@ ENVIRONMENT="local" LOG_LEVEL="DEBUG" -# Backend API Configuration -BACKEND_API_ENDPOINT="your-ngrok-url.ngrok-free.app" +# Change these values if you deploy the backend and frontend +# on any hosting provider with some DNS. Please ensure to +# provide the URL with scheme like http or https +# BACKEND_API_ENDPOINT: "http://localhost:8000" +# UI_APP_URL: "http://localhost:3010" # Database Configuration DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" REDIS_URL="redis://:redissecret@localhost:6379" -# Superuser Configuration -SUPERUSER_PASSWORD="your-secure-password" - -# AI Service API Keys (commented out by default) -# CARTESIA_API_KEY="sk_car_xxxxxxxxxxxxxxxxx" -# DEEPGRAM_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# LiveKit Configuration (optional) -# LIVEKIT_API_KEY="APIxxxxxxxxxx" -# LIVEKIT_API_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -# LIVEKIT_URL="wss://your-livekit-instance.livekit.cloud" - # AWS S3 Configuration (required for SaaS mode) -AWS_ACCESS_KEY_ID="AKIAXXXXXXXXXXXXXXXXX" -AWS_SECRET_ACCESS_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -S3_BUCKET="your-s3-bucket-name" -S3_REGION="us-east-1" +ENABLE_AWS_S3="false" +# AWS_ACCESS_KEY_ID="" +# AWS_SECRET_ACCESS_KEY="" +# S3_BUCKET="" +# S3_REGION="" -# Stack Auth Configuration -STACK_AUTH_API_URL="https://api.stack-auth.com" -STACK_AUTH_PROJECT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -STACK_SECRET_SERVER_KEY="ssk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -STACK_PUBLISHABLE_CLIENT_KEY="pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# Telephony Configuration -# Telephony providers are configured via UI/database only. Navigate to: Workflow → Phone Call -> Configure Telephony - -# Tracing and Analytics -ENABLE_TRACING=true -LANGFUSE_SECRET_KEY="sk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -LANGFUSE_PUBLIC_KEY="pk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -LANGFUSE_HOST="https://langfuse.your-domain.com" - -# AI Service API Keys -GROQ_API_KEY="gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -NEUPHONIC_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -NANGO_API_KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - -# Default API Keys for new user signups -OPENAI_API_KEY="sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -ELEVENLABS_API_KEY="sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -DEEPGRAM_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -GOOGLE_API_KEY="AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - -# Smart Turn Configuration -ENABLE_SMART_TURN=true -SMART_TURN_WS_SERVICE_ENDPOINT="wss://your-gpu-server.domain.com/ws" -SMART_TURN_HTTP_SERVICE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=" - -# SSL Key Logging (for debugging) -# SSLKEYLOGFILE=/path/to/ssl-keys.log - -# Turn Logging -ENABLE_TURN_LOGGING=true - -# OpenTelemetry Configuration -OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true -SIGNOZ_EXPORTER_ENDPOINT="ingest.us.signoz.cloud:443" -SIGNOZ_EXPORTER_HEADERS="signoz-ingestion-key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - -# Storage Configuration -# User mode: 'saas' (default) uses AWS S3; 'oss' uses local MinIO -USER_MODE=saas - -# OSS (MinIO) Configuration - only required when USER_MODE=oss -# These values are optional and will use defaults if not provided +# MinIO Configuration if using containerised MinIO instead of +# AWS S3 MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=voice-audio -MINIO_SECURE=false \ No newline at end of file +MINIO_SECURE=false + +# Tracing and Analytics using Langfuse +ENABLE_TRACING=false +# LANGFUSE_SECRET_KEY="sk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +# LANGFUSE_PUBLIC_KEY="pk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +# LANGFUSE_HOST="https://cloud.langfuse.com" diff --git a/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py b/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py index d37b292..02643c0 100644 --- a/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py +++ b/api/alembic/versions/a57d25b75117_add_vonage_and_rename_config.py @@ -5,15 +5,15 @@ Revises: 982ec8e434be Create Date: 2025-10-21 12:28:06.053318 """ + from typing import Sequence, Union from alembic import op from alembic_postgresql_enum import TableReference - # revision identifiers, used by Alembic. -revision: str = 'a57d25b75117' -down_revision: Union[str, None] = '982ec8e434be' +revision: str = "a57d25b75117" +down_revision: Union[str, None] = "982ec8e434be" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -26,12 +26,20 @@ def upgrade() -> None: 2. Migrates TWILIO_CONFIGURATION key to TELEPHONY_CONFIGURATION 3. Renames twilio_status_callbacks to telephony_status_callbacks in workflow_run logs """ - + # Add 'vonage' to the workflow_run_mode enum op.sync_enum_values( enum_schema="public", enum_name="workflow_run_mode", - new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT", "vonage"], + new_values=[ + "twilio", + "stasis", + "webrtc", + "smallwebrtc", + "VOICE", + "CHAT", + "vonage", + ], affected_columns=[ TableReference( table_schema="public", table_name="workflow_runs", column_name="mode" @@ -39,14 +47,14 @@ def upgrade() -> None: ], enum_values_to_rename=[], ) - + # Rename the key from TWILIO_CONFIGURATION to TELEPHONY_CONFIGURATION op.execute(""" UPDATE organization_configurations SET key = 'TELEPHONY_CONFIGURATION' WHERE key = 'TWILIO_CONFIGURATION'; """) - + # Rename twilio_status_callbacks to telephony_status_callbacks in workflow_run logs op.execute(""" UPDATE workflow_runs @@ -57,15 +65,17 @@ def upgrade() -> None: ) WHERE logs::jsonb ? 'twilio_status_callbacks'; """) - - print("Migration complete: Added vonage to enum, renamed configuration key, and updated status callback keys") + + print( + "Migration complete: Added vonage to enum, renamed configuration key, and updated status callback keys" + ) def downgrade() -> None: """ Revert configuration key names and enum. """ - + # Revert telephony_status_callbacks to twilio_status_callbacks in workflow_run logs op.execute(""" UPDATE workflow_runs @@ -76,14 +86,14 @@ def downgrade() -> None: ) WHERE logs::jsonb ? 'telephony_status_callbacks'; """) - + # Revert key name op.execute(""" UPDATE organization_configurations SET key = 'TWILIO_CONFIGURATION' WHERE key = 'TELEPHONY_CONFIGURATION'; """) - + # Revert enum to previous state op.sync_enum_values( enum_schema="public", @@ -96,5 +106,5 @@ def downgrade() -> None: ], enum_values_to_rename=[], ) - - print("Downgrade complete: Reverted configuration key names and enum") \ No newline at end of file + + print("Downgrade complete: Reverted configuration key names and enum") diff --git a/api/alembic/versions/e02f387b7538_add_embed_token_model.py b/api/alembic/versions/e02f387b7538_add_embed_token_model.py new file mode 100644 index 0000000..f57e776 --- /dev/null +++ b/api/alembic/versions/e02f387b7538_add_embed_token_model.py @@ -0,0 +1,160 @@ +"""add embed token model + +Revision ID: e02f387b7538 +Revises: a57d25b75117 +Create Date: 2025-11-11 12:49:35.515641 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "e02f387b7538" +down_revision: Union[str, None] = "a57d25b75117" +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.create_table( + "embed_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(length=255), nullable=False), + sa.Column("workflow_id", sa.Integer(), nullable=False), + sa.Column("organization_id", sa.Integer(), nullable=False), + sa.Column("allowed_domains", sa.JSON(), nullable=True), + sa.Column("settings", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("usage_limit", sa.Integer(), nullable=True), + sa.Column("usage_count", sa.Integer(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["organization_id"], ["organizations.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["workflow_id"], ["workflows.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_embed_tokens_is_active"), "embed_tokens", ["is_active"], unique=False + ) + op.create_index( + op.f("ix_embed_tokens_organization_id"), + "embed_tokens", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_embed_tokens_token"), "embed_tokens", ["token"], unique=True + ) + op.create_index( + op.f("ix_embed_tokens_workflow_id"), + "embed_tokens", + ["workflow_id"], + unique=False, + ) + op.create_table( + "embed_sessions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_token", sa.String(length=255), nullable=False), + sa.Column("embed_token_id", sa.Integer(), nullable=False), + sa.Column("workflow_run_id", sa.Integer(), nullable=True), + sa.Column("client_ip", sa.String(length=45), nullable=True), + sa.Column("user_agent", sa.String(length=500), nullable=True), + sa.Column("origin", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["embed_token_id"], ["embed_tokens.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["workflow_run_id"], ["workflow_runs.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_embed_sessions_expires_at"), + "embed_sessions", + ["expires_at"], + unique=False, + ) + op.create_index( + op.f("ix_embed_sessions_session_token"), + "embed_sessions", + ["session_token"], + unique=True, + ) + op.alter_column( + "organizations", + "quota_reset_day", + existing_type=sa.INTEGER(), + server_default=sa.text("1"), + existing_nullable=False, + ) + op.sync_enum_values( + enum_schema="public", + enum_name="workflow_run_mode", + new_values=[ + "twilio", + "vonage", + "stasis", + "webrtc", + "smallwebrtc", + "VOICE", + "CHAT", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="workflow_runs", column_name="mode" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="workflow_run_mode", + new_values=[ + "twilio", + "stasis", + "webrtc", + "smallwebrtc", + "VOICE", + "CHAT", + "vonage", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="workflow_runs", column_name="mode" + ) + ], + enum_values_to_rename=[], + ) + op.alter_column( + "organizations", + "quota_reset_day", + existing_type=sa.INTEGER(), + server_default=sa.text("LEAST((EXTRACT(day FROM CURRENT_DATE))::integer, 28)"), + existing_nullable=False, + ) + op.drop_index(op.f("ix_embed_sessions_session_token"), table_name="embed_sessions") + op.drop_index(op.f("ix_embed_sessions_expires_at"), table_name="embed_sessions") + op.drop_table("embed_sessions") + op.drop_index(op.f("ix_embed_tokens_workflow_id"), table_name="embed_tokens") + op.drop_index(op.f("ix_embed_tokens_token"), table_name="embed_tokens") + op.drop_index(op.f("ix_embed_tokens_organization_id"), table_name="embed_tokens") + op.drop_index(op.f("ix_embed_tokens_is_active"), table_name="embed_tokens") + op.drop_table("embed_tokens") + # ### end Alembic commands ### diff --git a/api/constants.py b/api/constants.py index c87e19f..f3c8e50 100644 --- a/api/constants.py +++ b/api/constants.py @@ -1,6 +1,9 @@ import os from pathlib import Path +from api.enums import Environment + +ENVIRONMENT = os.getenv("ENVIRONMENT", Environment.LOCAL.value) # Absolute path to the project root directory (i.e. the directory containing # the top-level api/ package). Having a single canonical location helps # when constructing file-system paths elsewhere in the codebase. @@ -15,7 +18,9 @@ ENABLE_SMART_TURN = os.getenv("ENABLE_SMART_TURN", "false").lower() == "true" ENABLE_TRACING = os.getenv("ENABLE_TRACING", "false").lower() == "true" ENABLE_RNNOISE = os.getenv("ENABLE_RNNOISE", "false").lower() == "true" -BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", None) +# URLs for deployment +BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", "http://localhost:8000") +UI_APP_URL = os.getenv("UI_APP_URL", "http://localhost:3010") DATABASE_URL = os.environ["DATABASE_URL"] REDIS_URL = os.environ["REDIS_URL"] diff --git a/api/db/db_client.py b/api/db/db_client.py index dabdf5d..c38f912 100644 --- a/api/db/db_client.py +++ b/api/db/db_client.py @@ -1,5 +1,6 @@ from api.db.api_key_client import APIKeyClient from api.db.campaign_client import CampaignClient +from api.db.embed_token_client import EmbedTokenClient from api.db.integration_client import IntegrationClient from api.db.looptalk_client import LoopTalkClient from api.db.organization_client import OrganizationClient @@ -25,6 +26,7 @@ class DBClient( CampaignClient, ReportsClient, APIKeyClient, + EmbedTokenClient, ): """ Unified database client that combines all specialized database operations. @@ -42,6 +44,7 @@ class DBClient( - CampaignClient: handles campaign operations - ReportsClient: handles reports and analytics operations - APIKeyClient: handles API key operations + - EmbedTokenClient: handles embed token and session operations """ pass diff --git a/api/db/embed_token_client.py b/api/db/embed_token_client.py new file mode 100644 index 0000000..550676f --- /dev/null +++ b/api/db/embed_token_client.py @@ -0,0 +1,329 @@ +"""Database client for managing embed tokens and sessions.""" + +import secrets +from datetime import UTC, datetime, timedelta +from typing import List, Optional + +from loguru import logger +from sqlalchemy import and_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from api.db.base_client import BaseDBClient +from api.db.models import EmbedSessionModel, EmbedTokenModel + + +class EmbedTokenClient(BaseDBClient): + """Client for managing embed tokens and sessions.""" + + async def create_embed_token( + self, + workflow_id: int, + organization_id: int, + created_by: int, + allowed_domains: Optional[List[str]] = None, + settings: Optional[dict] = None, + usage_limit: Optional[int] = None, + expires_at: Optional[datetime] = None, + ) -> EmbedTokenModel: + """Create a new embed token for a workflow. + + Args: + workflow_id: ID of the workflow to embed + organization_id: ID of the organization + created_by: ID of the user creating the token + allowed_domains: List of domains allowed to use this token + settings: Widget customization settings + usage_limit: Optional limit on number of uses + expires_at: Optional expiration date + + Returns: + Created EmbedTokenModel + """ + async with self.async_session() as session: + # Generate a unique token + token = f"emb_{secrets.token_urlsafe(32)}" + + # Ensure uniqueness + while await self._token_exists(session, token): + token = f"emb_{secrets.token_urlsafe(32)}" + + embed_token = EmbedTokenModel( + token=token, + workflow_id=workflow_id, + organization_id=organization_id, + created_by=created_by, + allowed_domains=allowed_domains, + settings=settings or {}, + usage_limit=usage_limit, + expires_at=expires_at, + is_active=True, + usage_count=0, + created_at=datetime.now(UTC), + ) + + session.add(embed_token) + await session.commit() + await session.refresh(embed_token) + + logger.info(f"Created embed token {token} for workflow {workflow_id}") + return embed_token + + async def _token_exists(self, session: AsyncSession, token: str) -> bool: + """Check if a token already exists.""" + result = await session.execute( + select(EmbedTokenModel).where(EmbedTokenModel.token == token) + ) + return result.scalar_one_or_none() is not None + + async def get_embed_token_by_token(self, token: str) -> Optional[EmbedTokenModel]: + """Get an embed token by its token string. + + Args: + token: The token string + + Returns: + EmbedTokenModel if found, None otherwise + """ + async with self.async_session() as session: + result = await session.execute( + select(EmbedTokenModel).where(EmbedTokenModel.token == token) + ) + return result.scalar_one_or_none() + + async def get_embed_tokens_by_workflow( + self, workflow_id: int, organization_id: int, active_only: bool = True + ) -> List[EmbedTokenModel]: + """Get all embed tokens for a workflow. + + Args: + workflow_id: ID of the workflow + organization_id: ID of the organization + active_only: If True, only return active tokens + + Returns: + List of EmbedTokenModel instances + """ + async with self.async_session() as session: + query = select(EmbedTokenModel).where( + and_( + EmbedTokenModel.workflow_id == workflow_id, + EmbedTokenModel.organization_id == organization_id, + ) + ) + + if active_only: + query = query.where(EmbedTokenModel.is_active == True) + + result = await session.execute( + query.order_by(EmbedTokenModel.created_at.desc()) + ) + return result.scalars().all() + + async def update_embed_token( + self, token_id: int, organization_id: int, **kwargs + ) -> Optional[EmbedTokenModel]: + """Update an embed token. + + Args: + token_id: ID of the token to update + organization_id: ID of the organization (for access control) + **kwargs: Fields to update (allowed_domains, settings, is_active, etc.) + + Returns: + Updated EmbedTokenModel if found, None otherwise + """ + async with self.async_session() as session: + # First get the token to verify organization + result = await session.execute( + select(EmbedTokenModel).where( + and_( + EmbedTokenModel.id == token_id, + EmbedTokenModel.organization_id == organization_id, + ) + ) + ) + embed_token = result.scalar_one_or_none() + + if not embed_token: + return None + + # Update allowed fields + allowed_fields = { + "allowed_domains", + "settings", + "is_active", + "usage_limit", + "expires_at", + } + + for field, value in kwargs.items(): + if field in allowed_fields: + setattr(embed_token, field, value) + + embed_token.updated_at = datetime.now(UTC) + + await session.commit() + await session.refresh(embed_token) + + logger.info(f"Updated embed token {token_id}") + return embed_token + + async def deactivate_embed_token(self, token_id: int, organization_id: int) -> bool: + """Deactivate an embed token. + + Args: + token_id: ID of the token to deactivate + organization_id: ID of the organization + + Returns: + True if token was deactivated, False if not found + """ + token = await self.update_embed_token( + token_id, organization_id, is_active=False + ) + return token is not None + + async def increment_embed_token_usage(self, token_id: int) -> None: + """Increment the usage count for an embed token. + + Args: + token_id: ID of the token + """ + async with self.async_session() as session: + await session.execute( + update(EmbedTokenModel) + .where(EmbedTokenModel.id == token_id) + .values(usage_count=EmbedTokenModel.usage_count + 1) + ) + await session.commit() + + async def create_embed_session( + self, + session_token: str, + embed_token_id: int, + workflow_run_id: int, + client_ip: Optional[str] = None, + user_agent: Optional[str] = None, + origin: Optional[str] = None, + expires_at: Optional[datetime] = None, + ) -> EmbedSessionModel: + """Create a new embed session. + + Args: + session_token: Unique session token + embed_token_id: ID of the embed token + workflow_run_id: ID of the workflow run + client_ip: Client IP address + user_agent: User agent string + origin: Origin header + expires_at: Session expiration time + + Returns: + Created EmbedSessionModel + """ + async with self.async_session() as session: + if expires_at is None: + expires_at = datetime.now(UTC) + timedelta(hours=1) + + embed_session = EmbedSessionModel( + session_token=session_token, + embed_token_id=embed_token_id, + workflow_run_id=workflow_run_id, + client_ip=client_ip, + user_agent=user_agent, + origin=origin, + created_at=datetime.now(UTC), + expires_at=expires_at, + ) + + session.add(embed_session) + await session.commit() + await session.refresh(embed_session) + + logger.info(f"Created embed session {session_token}") + return embed_session + + async def get_embed_session_by_token( + self, session_token: str + ) -> Optional[EmbedSessionModel]: + """Get an embed session by token (alias for get_embed_session). + + Args: + session_token: The session token + + Returns: + EmbedSessionModel if found, None otherwise (doesn't check expiry) + """ + async with self.async_session() as session: + result = await session.execute( + select(EmbedSessionModel).where( + EmbedSessionModel.session_token == session_token + ) + ) + return result.scalar_one_or_none() + + async def get_embed_token_by_id(self, token_id: int) -> Optional[EmbedTokenModel]: + """Get an embed token by ID. + + Args: + token_id: ID of the token + + Returns: + EmbedTokenModel if found, None otherwise + """ + async with self.async_session() as session: + result = await session.execute( + select(EmbedTokenModel).where(EmbedTokenModel.id == token_id) + ) + return result.scalar_one_or_none() + + async def get_embed_token_stats(self, token_id: int, organization_id: int) -> dict: + """Get usage statistics for an embed token. + + Args: + token_id: ID of the token + organization_id: ID of the organization + + Returns: + Dictionary with usage statistics + """ + async with self.async_session() as session: + # Get the token + result = await session.execute( + select(EmbedTokenModel).where( + and_( + EmbedTokenModel.id == token_id, + EmbedTokenModel.organization_id == organization_id, + ) + ) + ) + token = result.scalar_one_or_none() + + if not token: + return {} + + # Count active sessions + active_sessions_result = await session.execute( + select(EmbedSessionModel).where( + and_( + EmbedSessionModel.embed_token_id == token_id, + EmbedSessionModel.expires_at > datetime.now(UTC), + ) + ) + ) + active_sessions = len(active_sessions_result.scalars().all()) + + return { + "token_id": token_id, + "usage_count": token.usage_count, + "usage_limit": token.usage_limit, + "active_sessions": active_sessions, + "is_active": token.is_active, + "created_at": token.created_at.isoformat() + if token.created_at + else None, + "expires_at": token.expires_at.isoformat() + if token.expires_at + else None, + "allowed_domains": token.allowed_domains, + } diff --git a/api/db/models.py b/api/db/models.py index dfa02c5..47fdd98 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -606,3 +606,67 @@ class QueuedRunModel(Base): name="unique_campaign_source_retry", ), ) + + +class EmbedTokenModel(Base): + """Model for storing workflow embed tokens""" + + __tablename__ = "embed_tokens" + + id = Column(Integer, primary_key=True, index=True) + token = Column(String(255), unique=True, nullable=False, index=True) + workflow_id = Column( + Integer, + ForeignKey("workflows.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + organization_id = Column( + Integer, + ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + allowed_domains = Column(JSON, nullable=True) # Array of whitelisted domains + settings = Column(JSON, nullable=True) # Widget customization settings + is_active = Column(Boolean, default=True, nullable=False, index=True) + usage_limit = Column(Integer, nullable=True) # Optional usage limit + usage_count = Column(Integer, default=0, nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + created_by = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + updated_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + workflow = relationship("WorkflowModel") + organization = relationship("OrganizationModel") + creator = relationship("UserModel") + sessions = relationship( + "EmbedSessionModel", back_populates="embed_token", cascade="all, delete-orphan" + ) + + +class EmbedSessionModel(Base): + """Model for storing temporary embed sessions""" + + __tablename__ = "embed_sessions" + + id = Column(Integer, primary_key=True, index=True) + session_token = Column(String(255), unique=True, nullable=False, index=True) + embed_token_id = Column( + Integer, ForeignKey("embed_tokens.id", ondelete="CASCADE"), nullable=False + ) + workflow_run_id = Column( + Integer, ForeignKey("workflow_runs.id", ondelete="CASCADE"), nullable=True + ) + client_ip = Column(String(45), nullable=True) + user_agent = Column(String(500), nullable=True) + origin = Column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + expires_at = Column(DateTime(timezone=True), nullable=False, index=True) + + # Relationships + embed_token = relationship("EmbedTokenModel", back_populates="sessions") + workflow_run = relationship("WorkflowRunModel") diff --git a/api/enums.py b/api/enums.py index 7175e78..4462696 100644 --- a/api/enums.py +++ b/api/enums.py @@ -63,8 +63,12 @@ class OrganizationConfigurationKey(Enum): DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING" DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE" CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT" - TELEPHONY_CONFIGURATION = "TELEPHONY_CONFIGURATION" # Stores all providers + active one - TWILIO_CONFIGURATION = "TWILIO_CONFIGURATION" # Deprecated - for backward compatibility + TELEPHONY_CONFIGURATION = ( + "TELEPHONY_CONFIGURATION" # Stores all providers + active one + ) + TWILIO_CONFIGURATION = ( + "TWILIO_CONFIGURATION" # Deprecated - for backward compatibility + ) class WorkflowStatus(Enum): diff --git a/api/logging_config.py b/api/logging_config.py index ca56cc6..2e71462 100644 --- a/api/logging_config.py +++ b/api/logging_config.py @@ -3,12 +3,11 @@ import sys import loguru -from api.constants import SERIALIZE_LOG_OUTPUT +from api.constants import ENVIRONMENT, SERIALIZE_LOG_OUTPUT from api.enums import Environment from api.utils.worker import get_worker_id, is_worker_process from pipecat.utils.context import run_id_var, turn_var -ENVIRONMENT = os.getenv("ENVIRONMENT", Environment.LOCAL.value) ENABLE_TURN_LOGGING = os.getenv("ENABLE_TURN_LOGGING", "false").lower() == "true" # We write different uvicorn forked worker log to a different diff --git a/api/routes/main.py b/api/routes/main.py index 88cfab0..31ca451 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -6,6 +6,7 @@ from api.routes.integration import router as integration_router from api.routes.looptalk import router as looptalk_router from api.routes.organization import router as organization_router from api.routes.organization_usage import router as organization_usage_router +from api.routes.public_embed import router as public_embed_router from api.routes.reports import router as reports_router from api.routes.rtc_offer import router as rtc_offer_router from api.routes.s3_signed_url import router as s3_router @@ -15,6 +16,7 @@ from api.routes.telephony import router as telephony_router from api.routes.user import router as user_router from api.routes.webrtc_signaling import router as webrtc_signaling_router from api.routes.workflow import router as workflow_router +from api.routes.workflow_embed import router as workflow_embed_router router = APIRouter( tags=["main"], @@ -35,6 +37,8 @@ router.include_router(looptalk_router) router.include_router(organization_usage_router) router.include_router(reports_router) router.include_router(webrtc_signaling_router) +router.include_router(public_embed_router) +router.include_router(workflow_embed_router) @router.get("/health") diff --git a/api/routes/organization.py b/api/routes/organization.py index 15e2e5e..a47966b 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -1,9 +1,10 @@ +from typing import Union + from fastapi import APIRouter, Depends, HTTPException from api.db import db_client from api.db.models import UserModel from api.enums import OrganizationConfigurationKey -from typing import Union from api.schemas.telephony_config import ( TelephonyConfigurationResponse, TwilioConfigurationRequest, @@ -19,14 +20,13 @@ router = APIRouter(prefix="/organizations", tags=["organizations"]) # Provider configuration constants PROVIDER_MASKED_FIELDS = { "twilio": ["account_sid", "auth_token"], - "vonage": ["private_key", "api_key", "api_secret"] + "vonage": ["private_key", "api_key", "api_secret"], } + # TODO: Make endpoints provider-agnostic @router.get("/telephony-config", response_model=TelephonyConfigurationResponse) -async def get_telephony_configuration( - user: UserModel = Depends(get_user) -): +async def get_telephony_configuration(user: UserModel = Depends(get_user)): """Get telephony configuration for the user's organization with masked sensitive fields.""" if not user.selected_organization_id: raise HTTPException(status_code=400, detail="No organization selected") @@ -40,11 +40,13 @@ async def get_telephony_configuration( return TelephonyConfigurationResponse() stored_provider = config.value.get("provider", "twilio") - + if stored_provider == "twilio": account_sid = config.value.get("account_sid", "") auth_token = config.value.get("auth_token", "") - from_numbers = config.value.get("from_numbers", []) if account_sid and auth_token else [] + from_numbers = ( + config.value.get("from_numbers", []) if account_sid and auth_token else [] + ) return TelephonyConfigurationResponse( twilio=TwilioConfigurationResponse( @@ -53,15 +55,19 @@ async def get_telephony_configuration( auth_token=mask_key(auth_token) if auth_token else "", from_numbers=from_numbers, ), - vonage=None + vonage=None, ) elif stored_provider == "vonage": application_id = config.value.get("application_id", "") private_key = config.value.get("private_key", "") api_key = config.value.get("api_key", "") api_secret = config.value.get("api_secret", "") - from_numbers = config.value.get("from_numbers", []) if application_id and private_key else [] - + from_numbers = ( + config.value.get("from_numbers", []) + if application_id and private_key + else [] + ) + return TelephonyConfigurationResponse( twilio=None, vonage=VonageConfigurationResponse( @@ -71,7 +77,7 @@ async def get_telephony_configuration( api_key=mask_key(api_key) if api_key else None, api_secret=mask_key(api_secret) if api_secret else None, from_numbers=from_numbers, - ) + ), ) else: return TelephonyConfigurationResponse() @@ -79,8 +85,8 @@ async def get_telephony_configuration( @router.post("/telephony-config") async def save_telephony_configuration( - request: Union[TwilioConfigurationRequest, VonageConfigurationRequest], - user: UserModel = Depends(get_user) + request: Union[TwilioConfigurationRequest, VonageConfigurationRequest], + user: UserModel = Depends(get_user), ): """Save telephony configuration for the user's organization.""" if not user.selected_organization_id: @@ -105,12 +111,14 @@ async def save_telephony_configuration( "provider": "vonage", "application_id": request.application_id, "private_key": request.private_key, - "api_key": getattr(request, 'api_key', None), - "api_secret": getattr(request, 'api_secret', None), + "api_key": getattr(request, "api_key", None), + "api_secret": getattr(request, "api_secret", None), "from_numbers": request.from_numbers, } else: - raise HTTPException(status_code=400, detail=f"Unsupported provider: {request.provider}") + raise HTTPException( + status_code=400, detail=f"Unsupported provider: {request.provider}" + ) if existing_config and existing_config.value: existing_provider = existing_config.value.get("provider") @@ -126,14 +134,16 @@ async def save_telephony_configuration( return {"message": "Telephony configuration saved successfully"} -def preserve_masked_fields(request, existing_config, config_value): +def preserve_masked_fields(request, existing_config, config_value): provider = request.provider masked_fields = PROVIDER_MASKED_FIELDS.get(provider, []) - + for field_name in masked_fields: if hasattr(request, field_name): field_value = getattr(request, field_name) # Check if field has a value and is a masked version of the existing value - if field_value and is_mask_of(field_value, existing_config.value.get(field_name, "")): + if field_value and is_mask_of( + field_value, existing_config.value.get(field_name, "") + ): config_value[field_name] = existing_config.value[field_name] diff --git a/api/routes/public_embed.py b/api/routes/public_embed.py new file mode 100644 index 0000000..bead318 --- /dev/null +++ b/api/routes/public_embed.py @@ -0,0 +1,265 @@ +"""Public API endpoints for workflow embedding. + +These endpoints are accessible without authentication but require valid embed tokens. +They handle CORS, domain validation, and session management for embedded workflows. +""" + +import secrets +from datetime import UTC, datetime, timedelta +from typing import Optional + +from fastapi import ( + APIRouter, + HTTPException, + Request, + Response, +) +from loguru import logger +from pydantic import BaseModel + +from api.db import db_client +from api.enums import WorkflowRunMode + +router = APIRouter(prefix="/public/embed") + + +class InitEmbedRequest(BaseModel): + """Request model for initializing an embed session""" + + token: str + context_variables: Optional[dict] = None + + +class InitEmbedResponse(BaseModel): + """Response model for embed initialization""" + + session_token: str + workflow_run_id: int + config: dict + + +class EmbedConfigResponse(BaseModel): + """Response model for embed configuration""" + + workflow_id: int + settings: dict + theme: str + position: str + button_text: str + button_color: str + size: str + auto_start: bool + + +def validate_origin(origin: str, allowed_domains: list) -> bool: + """Validate if the origin is in the allowed domains list. + + Args: + origin: The origin header from the request + allowed_domains: List of allowed domain patterns + + Returns: + True if origin is allowed, False otherwise + """ + if not allowed_domains: + # If no domains specified, allow all origins + return True + + # Extract domain from origin (remove protocol) + if "://" in origin: + domain = origin.split("://")[1].split("/")[0].split(":")[0] + else: + domain = origin + + for allowed in allowed_domains: + if allowed == "*": + return True + elif allowed.startswith("*."): + # Wildcard subdomain matching + base_domain = allowed[2:] + if domain == base_domain or domain.endswith("." + base_domain): + return True + elif domain == allowed: + return True + + return False + + +def generate_session_token() -> str: + """Generate a cryptographically secure session token""" + return f"emb_session_{secrets.token_urlsafe(32)}" + + +@router.post("/init", response_model=InitEmbedResponse) +async def initialize_embed_session(request: Request, init_request: InitEmbedRequest): + """Initialize an embed session with token validation and domain checking. + + This endpoint: + 1. Validates the embed token + 2. Checks domain whitelist + 3. Creates a workflow run + 4. Generates a temporary session token + 5. Returns configuration for the widget + """ + # Get origin header for domain validation + origin = request.headers.get("origin", "") + if not origin: + origin = request.headers.get("referer", "") + + # Validate embed token + embed_token = await db_client.get_embed_token_by_token(init_request.token) + if not embed_token: + raise HTTPException(status_code=404, detail="Invalid embed token") + + # Check if token is active + if not embed_token.is_active: + raise HTTPException(status_code=403, detail="Embed token is inactive") + + # Check expiration + if embed_token.expires_at and embed_token.expires_at < datetime.now(UTC): + raise HTTPException(status_code=403, detail="Embed token has expired") + + # Check usage limit + if embed_token.usage_limit and embed_token.usage_count >= embed_token.usage_limit: + raise HTTPException(status_code=403, detail="Embed token usage limit exceeded") + + # Validate domain + if not validate_origin(origin, embed_token.allowed_domains or []): + logger.warning( + f"Domain validation failed: {origin} not in {embed_token.allowed_domains}" + ) + raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + + # Create workflow run + try: + workflow_run = await db_client.create_workflow_run( + name=f"Embed Run - {datetime.now(UTC).isoformat()}", + workflow_id=embed_token.workflow_id, + mode=WorkflowRunMode.SMALLWEBRTC.value, + user_id=embed_token.created_by, # Use token creator as run owner + initial_context=init_request.context_variables, + ) + except Exception as e: + logger.error(f"Failed to create workflow run: {e}") + raise HTTPException(status_code=500, detail="Failed to create workflow run") + + # Generate session token + session_token = generate_session_token() + + # Create embed session + try: + await db_client.create_embed_session( + session_token=session_token, + embed_token_id=embed_token.id, + workflow_run_id=workflow_run.id, + client_ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", "")[:500], + origin=origin[:255], + expires_at=datetime.now(UTC) + timedelta(hours=1), # 1 hour expiry + ) + except Exception as e: + logger.error(f"Failed to create embed session: {e}") + raise HTTPException(status_code=500, detail="Failed to create session") + + # Increment usage count + await db_client.increment_embed_token_usage(embed_token.id) + + # Prepare configuration + config = { + "workflow_id": embed_token.workflow_id, + "workflow_run_id": workflow_run.id, + **(embed_token.settings or {}), + } + + return InitEmbedResponse( + session_token=session_token, workflow_run_id=workflow_run.id, config=config + ) + + +@router.get("/config/{token}", response_model=EmbedConfigResponse) +async def get_embed_config(token: str, request: Request): + """Get embed configuration without creating a session. + + This endpoint is used to fetch widget configuration for display purposes + without actually starting a call session. + """ + # Get origin header for domain validation + origin = request.headers.get("origin", "") + if not origin: + origin = request.headers.get("referer", "") + + # Validate embed token + embed_token = await db_client.get_embed_token_by_token(token) + if not embed_token: + raise HTTPException(status_code=404, detail="Invalid embed token") + + # Check if token is active + if not embed_token.is_active: + raise HTTPException(status_code=403, detail="Embed token is inactive") + + # Validate domain + if not validate_origin(origin, embed_token.allowed_domains or []): + raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + + # Extract settings with defaults + settings = embed_token.settings or {} + + return EmbedConfigResponse( + workflow_id=embed_token.workflow_id, + settings=settings, + theme=settings.get("theme", "light"), + position=settings.get("position", "bottom-right"), + button_text=settings.get("buttonText", "Start Voice Call"), + button_color=settings.get("buttonColor", "#3B82F6"), + size=settings.get("size", "medium"), + auto_start=settings.get("autoStart", False), + ) + + +@router.options("/init") +async def options_init(request: Request): + """Handle CORS preflight for init endpoint""" + # For init endpoint, we need to check the token in the request body + # But OPTIONS requests don't have body, so we'll be permissive + # The actual validation happens in the POST request + origin = request.headers.get("origin", "*") + + return Response( + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Origin", + "Access-Control-Max-Age": "86400", + } + ) + + +@router.options("/config/{token}") +async def options_config(request: Request, token: str): + """Handle CORS preflight for config endpoint""" + # Get origin header + origin = request.headers.get("origin", "*") + + # Try to validate the token and get allowed domains + allowed_origin = origin + try: + embed_token = await db_client.get_embed_token_by_token(token) + if embed_token and embed_token.is_active: + # Check if origin is in allowed domains + if validate_origin(origin, embed_token.allowed_domains or []): + allowed_origin = origin + else: + # If not allowed, don't include the origin + allowed_origin = "" + except Exception: + # On error, be permissive for OPTIONS + pass + + return Response( + headers={ + "Access-Control-Allow-Origin": allowed_origin, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + } + ) diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 1bdadcb..bcc3155 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -1,19 +1,19 @@ """ Generic telephony routes that work with any telephony provider. """ + import json import random from datetime import UTC, datetime -from typing import Annotated, Optional +from typing import Optional -from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket +from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket from loguru import logger from pydantic import BaseModel from starlette.responses import HTMLResponse from api.db import db_client from api.db.models import UserModel -from api.enums import WorkflowRunMode from api.services.auth.depends import get_user from api.services.campaign.call_dispatcher import campaign_call_dispatcher from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher @@ -32,6 +32,7 @@ class InitiateCallRequest(BaseModel): class StatusCallbackRequest(BaseModel): """Generic status callback that can handle different providers""" + # Common fields call_id: str status: str @@ -39,10 +40,10 @@ class StatusCallbackRequest(BaseModel): to_number: Optional[str] = None direction: Optional[str] = None duration: Optional[str] = None - + # Provider-specific fields stored as extra extra: dict = {} - + @classmethod def from_twilio(cls, data: dict): """Convert Twilio callback to generic format""" @@ -53,9 +54,9 @@ class StatusCallbackRequest(BaseModel): to_number=data.get("To"), direction=data.get("Direction"), duration=data.get("CallDuration") or data.get("Duration"), - extra=data + extra=data, ) - + @classmethod def from_vonage(cls, data: dict): """Convert Vonage event to generic format""" @@ -63,14 +64,14 @@ class StatusCallbackRequest(BaseModel): status_map = { "started": "initiated", "ringing": "ringing", - "answered": "answered", + "answered": "answered", "complete": "completed", "failed": "failed", "busy": "busy", "timeout": "no-answer", - "rejected": "busy" + "rejected": "busy", } - + return cls( call_id=data.get("uuid", ""), status=status_map.get(data.get("status", ""), data.get("status", "")), @@ -78,7 +79,7 @@ class StatusCallbackRequest(BaseModel): to_number=data.get("to"), direction=data.get("direction"), duration=data.get("duration"), - extra=data + extra=data, ) @@ -87,32 +88,32 @@ async def initiate_call( request: InitiateCallRequest, user: UserModel = Depends(get_user) ): """Initiate a call using the configured telephony provider.""" - + # Get the telephony provider for the organization provider = await get_telephony_provider(user.selected_organization_id) - + # Validate provider is configured if not provider.validate_config(): raise HTTPException( status_code=400, detail="telephony_not_configured", ) - + # Determine the workflow run mode based on provider type workflow_run_mode = provider.PROVIDER_NAME - + user_configuration = await db_client.get_user_configurations(user.id) - + phone_number = request.phone_number or user_configuration.test_phone_number - + if not phone_number: raise HTTPException( - status_code=400, - detail="Phone number must be provided in request or set in user configuration" + status_code=400, + detail="Phone number must be provided in request or set in user configuration", ) - + workflow_run_id = request.workflow_run_id - + if not workflow_run_id: workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}" workflow_run = await db_client.create_workflow_run( @@ -130,12 +131,12 @@ async def initiate_call( if not workflow_run: raise HTTPException(status_code=400, detail="Workflow run not found") workflow_run_name = workflow_run.name - + # Construct webhook URL based on provider type backend_endpoint = await TunnelURLProvider.get_tunnel_url() - + webhook_endpoint = provider.WEBHOOK_ENDPOINT - + webhook_url = ( f"https://{backend_endpoint}/api/v1/telephony/{webhook_endpoint}" f"?workflow_id={request.workflow_id}" @@ -143,35 +144,29 @@ async def initiate_call( f"&workflow_run_id={workflow_run_id}" f"&organization_id={user.selected_organization_id}" ) - + # Initiate call via provider result = await provider.initiate_call( to_number=phone_number, webhook_url=webhook_url, workflow_run_id=workflow_run_id, ) - + # Store provider type and any provider-specific metadata in workflow run context gathered_context = { "provider": provider.PROVIDER_NAME, - **(result.provider_metadata or {}) + **(result.provider_metadata or {}), } await db_client.update_workflow_run( - run_id=workflow_run_id, - gathered_context=gathered_context + run_id=workflow_run_id, gathered_context=gathered_context ) - - return { - "message": f"Call initiated successfully with run name {workflow_run_name}" - } + + return {"message": f"Call initiated successfully with run name {workflow_run_name}"} @router.post("/twiml", include_in_schema=False) async def handle_twiml_webhook( - workflow_id: int, - user_id: int, - workflow_run_id: int, - organization_id: int + workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int ): """ Handle initial webhook from telephony provider. @@ -179,32 +174,32 @@ async def handle_twiml_webhook( """ provider = await get_telephony_provider(organization_id) - + response_content = await provider.get_webhook_response( workflow_id, user_id, workflow_run_id ) - + return HTMLResponse(content=response_content, media_type="application/xml") @router.get("/ncco", include_in_schema=False) async def handle_ncco_webhook( - workflow_id: int, - user_id: int, + workflow_id: int, + user_id: int, workflow_run_id: int, - organization_id: Optional[int] = None + organization_id: Optional[int] = None, ): """Handle NCCO (Nexmo Call Control Objects) webhook for Vonage. - + Returns JSON response instead of XML like TwiML. """ provider = await get_telephony_provider(organization_id or user_id) - + response_content = await provider.get_webhook_response( workflow_id, user_id, workflow_run_id ) - + return json.loads(response_content) @@ -218,36 +213,38 @@ async def websocket_endpoint( try: # Set the run context set_current_run_id(workflow_run_id) - + # Get workflow run to determine provider type workflow_run = await db_client.get_workflow_run(workflow_run_id) if not workflow_run: logger.error(f"Workflow run {workflow_run_id} not found") await websocket.close(code=4404, reason="Workflow run not found") return - + # Get workflow for organization info workflow = await db_client.get_workflow(workflow_id) if not workflow: logger.error(f"Workflow {workflow_id} not found") await websocket.close(code=4404, reason="Workflow not found") return - + # Extract provider type from workflow run context provider_type = None if workflow_run.gathered_context: provider_type = workflow_run.gathered_context.get("provider") - + if not provider_type: logger.error(f"No provider type found in workflow run {workflow_run_id}") await websocket.close(code=4400, reason="Provider type not found") return - - logger.info(f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}") - + + logger.info( + f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}" + ) + # Get the telephony provider instance provider = await get_telephony_provider(workflow.organization_id) - + # Verify the provider matches what was stored if provider.PROVIDER_NAME != provider_type: logger.error( @@ -255,10 +252,12 @@ async def websocket_endpoint( ) await websocket.close(code=4400, reason="Provider mismatch") return - + # Delegate to provider-specific handler - await provider.handle_websocket(websocket, workflow_id, user_id, workflow_run_id) - + await provider.handle_websocket( + websocket, workflow_id, user_id, workflow_run_id + ) + except Exception as e: logger.error(f"Error in WebSocket connection: {e}") await websocket.close(1011, "Internal server error") @@ -271,44 +270,46 @@ async def handle_twilio_status_callback( x_webhook_signature: Optional[str] = Header(None), ): """Handle Twilio-specific status callbacks.""" - + # Parse form data form_data = await request.form() callback_data = dict(form_data) - + logger.info( f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}" ) - + # Get workflow run to find organization workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning(f"Workflow run {workflow_run_id} not found for status callback") return {"status": "ignored", "reason": "workflow_run_not_found"} - + # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: logger.warning(f"Workflow {workflow_run.workflow_id} not found") return {"status": "ignored", "reason": "workflow_not_found"} - + provider = await get_telephony_provider(workflow.organization_id) - + if x_webhook_signature: backend_endpoint = await TunnelURLProvider.get_tunnel_url() full_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}" - + is_valid = await provider.verify_webhook_signature( full_url, callback_data, x_webhook_signature ) - + if not is_valid: - logger.warning(f"Invalid webhook signature for workflow run {workflow_run_id}") + logger.warning( + f"Invalid webhook signature for workflow run {workflow_run_id}" + ) return {"status": "error", "reason": "invalid_signature"} - + # Parse the callback data into generic format parsed_data = provider.parse_status_callback(callback_data) - + # Create StatusCallbackRequest from parsed data status_update = StatusCallbackRequest( call_id=parsed_data["call_id"], @@ -317,22 +318,20 @@ async def handle_twilio_status_callback( to_number=parsed_data.get("to_number"), direction=parsed_data.get("direction"), duration=parsed_data.get("duration"), - extra=parsed_data.get("extra", {}) + extra=parsed_data.get("extra", {}), ) - + # Process the status update await _process_status_update(workflow_run_id, status_update, workflow_run) - + return {"status": "success"} async def _process_status_update( - workflow_run_id: int, - status: StatusCallbackRequest, - workflow_run: any + workflow_run_id: int, status: StatusCallbackRequest, workflow_run: any ): """Process status updates from telephony providers.""" - + # Log the status callback telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", []) telephony_callback_log = { @@ -340,31 +339,29 @@ async def _process_status_update( "timestamp": datetime.now(UTC).isoformat(), "call_id": status.call_id, "duration": status.duration, - **status.extra # Include provider-specific data + **status.extra, # Include provider-specific data } telephony_callback_logs.append(telephony_callback_log) - + # Update workflow run logs await db_client.update_workflow_run( run_id=workflow_run_id, logs={"telephony_status_callbacks": telephony_callback_logs}, ) - + # Handle call completion if status.status == "completed": logger.info( f"[run {workflow_run_id}] Call completed with duration: {status.duration}s" ) - + # Release concurrent slot if this was a campaign call if workflow_run.campaign_id: await campaign_call_dispatcher.release_call_slot(workflow_run_id) - + # Mark workflow run as completed - await db_client.update_workflow_run( - run_id=workflow_run_id, is_completed=True - ) - + await db_client.update_workflow_run(run_id=workflow_run_id, is_completed=True) + # Publish campaign event if applicable if workflow_run.campaign_id: publisher = await get_campaign_event_publisher() @@ -374,32 +371,40 @@ async def _process_status_update( queued_run_id=workflow_run.queued_run_id, call_duration=int(status.duration) if status.duration else 0, ) - + elif status.status in ["failed", "busy", "no-answer", "canceled"]: - logger.warning(f"[run {workflow_run_id}] Call failed with status: {status.status}") - + logger.warning( + f"[run {workflow_run_id}] Call failed with status: {status.status}" + ) + # Release concurrent slot for terminal statuses if this was a campaign call if workflow_run.campaign_id: await campaign_call_dispatcher.release_call_slot(workflow_run_id) - + # Check if retry is needed for campaign calls (busy/no-answer) if status.status in ["busy", "no-answer"] and workflow_run.campaign_id: publisher = await get_campaign_event_publisher() await publisher.publish_retry_needed( workflow_run_id=workflow_run_id, - reason=status.status.replace("-", "_"), # Convert no-answer to no_answer + reason=status.status.replace( + "-", "_" + ), # Convert no-answer to no_answer campaign_id=workflow_run.campaign_id, queued_run_id=workflow_run.queued_run_id, ) - + # Mark workflow run as completed with failure tags - call_tags = workflow_run.gathered_context.get("call_tags", []) if workflow_run.gathered_context else [] + call_tags = ( + workflow_run.gathered_context.get("call_tags", []) + if workflow_run.gathered_context + else [] + ) call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"]) - + await db_client.update_workflow_run( run_id=workflow_run_id, is_completed=True, - gathered_context={"call_tags": call_tags} + gathered_context={"call_tags": call_tags}, ) @@ -409,20 +414,20 @@ async def handle_vonage_events( workflow_run_id: int, ): """Handle Vonage-specific event webhooks. - + Vonage sends all call events to a single endpoint. Events include: started, ringing, answered, complete, failed, etc. """ # Parse the event data event_data = await request.json() logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}") - + # Get workflow run for processing workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.error(f"[run {workflow_run_id}] Workflow run not found") return {"status": "error", "message": "Workflow run not found"} - + # For a completed call that includes cost info, capture it immediately if event_data.get("status") == "completed": # Vonage sometimes includes price info in the webhook @@ -436,27 +441,32 @@ async def handle_vonage_events( if "rate" in event_data: cost_info["vonage_webhook_rate"] = float(event_data["rate"]) if "duration" in event_data: - cost_info["vonage_webhook_duration"] = int(event_data["duration"]) - + cost_info["vonage_webhook_duration"] = int( + event_data["duration"] + ) + await db_client.update_workflow_run( - run_id=workflow_run_id, - cost_info=cost_info + run_id=workflow_run_id, cost_info=cost_info + ) + logger.info( + f"[run {workflow_run_id}] Captured Vonage cost info from webhook" ) - logger.info(f"[run {workflow_run_id}] Captured Vonage cost info from webhook") except Exception as e: - logger.error(f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}") - + logger.error( + f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}" + ) + # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: logger.error(f"[run {workflow_run_id}] Workflow not found") return {"status": "error", "message": "Workflow not found"} - + provider = await get_telephony_provider(workflow.organization_id) - + # Parse the event data into generic format parsed_data = provider.parse_status_callback(event_data) - + # Create StatusCallbackRequest from parsed data status_update = StatusCallbackRequest( call_id=parsed_data["call_id"], @@ -465,11 +475,11 @@ async def handle_vonage_events( to_number=parsed_data.get("to_number"), direction=parsed_data.get("direction"), duration=parsed_data.get("duration"), - extra=parsed_data.get("extra", {}) + extra=parsed_data.get("extra", {}), ) - + # Process the status update await _process_status_update(workflow_run_id, status_update, workflow_run) - + # Return 204 No Content as expected by Vonage - return {"status": "ok"} \ No newline at end of file + return {"status": "ok"} diff --git a/api/routes/webrtc_signaling.py b/api/routes/webrtc_signaling.py index fedee8b..2bb79e6 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -10,12 +10,14 @@ Uses the SmallWebRTC API contract: """ import asyncio +from datetime import UTC, datetime from typing import Dict from aiortc.sdp import candidate_from_sdp from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from loguru import logger +from api.db import db_client from api.db.models import UserModel from api.services.auth.depends import get_user_ws from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc @@ -124,7 +126,9 @@ class SignalingManager: ) else: # Create new connection using correct SmallWebRTC API - pc = SmallWebRTCConnection(ice_servers=ice_servers, connection_timeout_secs=60) + pc = SmallWebRTCConnection( + ice_servers=ice_servers, connection_timeout_secs=60 + ) # Set the pc_id before initialization so it's available in get_answer() pc._pc_id = pc_id @@ -244,3 +248,46 @@ async def signaling_websocket( await signaling_manager.handle_websocket( websocket, workflow_id, workflow_run_id, user ) + + +@router.websocket("/public/signaling/{session_token}") +async def public_signaling_websocket( + websocket: WebSocket, + session_token: str, +): + """Public WebSocket endpoint for WebRTC signaling with embed tokens. + + This endpoint: + 1. Validates the session token from embed initialization + 2. Retrieves the associated workflow run + 3. Handles WebRTC signaling without requiring authentication + """ + + # Validate session token + embed_session = await db_client.get_embed_session_by_token(session_token) + if not embed_session: + await websocket.close(code=1008, reason="Invalid session token") + return + + # Check if session is expired + if embed_session.expires_at and embed_session.expires_at < datetime.now(UTC): + await websocket.close(code=1008, reason="Session expired") + return + + # Get the embed token for user information + embed_token = await db_client.get_embed_token_by_id(embed_session.embed_token_id) + if not embed_token: + await websocket.close(code=1008, reason="Invalid embed token") + return + + # Create a minimal user object for compatibility with signaling manager + # Use the embed token creator as the user + user = await db_client.get_user_by_id(embed_token.created_by) + if not user: + await websocket.close(code=1008, reason="Invalid user") + return + + # Handle the WebSocket connection using the existing signaling manager + await signaling_manager.handle_websocket( + websocket, embed_token.workflow_id, embed_session.workflow_run_id, user + ) diff --git a/api/routes/workflow_embed.py b/api/routes/workflow_embed.py new file mode 100644 index 0000000..4b7823e --- /dev/null +++ b/api/routes/workflow_embed.py @@ -0,0 +1,203 @@ +"""Embed token endpoints for workflows.""" + +from datetime import UTC, datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from api.constants import BACKEND_API_ENDPOINT, ENVIRONMENT, UI_APP_URL +from api.db import db_client +from api.db.models import EmbedTokenModel, UserModel +from api.services.auth.depends import get_user + +router = APIRouter(prefix="/workflow") + + +def generate_embed_script(token: EmbedTokenModel) -> str: + """Generate the embed script for a given token.""" + base_url = str(UI_APP_URL).rstrip("/") + + return f""" +""" + + +class EmbedTokenRequest(BaseModel): + allowed_domains: Optional[list[str]] = None + settings: Optional[dict] = None + usage_limit: Optional[int] = None + expires_in_days: Optional[int] = 30 + + +class EmbedTokenResponse(BaseModel): + id: int + token: str + allowed_domains: Optional[list[str]] + settings: Optional[dict] + is_active: bool + usage_count: int + usage_limit: Optional[int] + expires_at: Optional[datetime] + created_at: datetime + embed_script: str + + +@router.post("/{workflow_id}/embed-token") +async def create_or_update_embed_token( + workflow_id: int, + request: Request, + embed_request: EmbedTokenRequest, + user: UserModel = Depends(get_user), +) -> EmbedTokenResponse: + """ + Create or update an embed token for a workflow. + Each workflow can have only one active embed token. + """ + # Verify workflow exists and user has access + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if not workflow: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + + # Check if an embed token already exists for this workflow + existing_tokens = await db_client.get_embed_tokens_by_workflow( + workflow_id, user.selected_organization_id, active_only=False + ) + + expires_at = None + if embed_request.expires_in_days: + expires_at = datetime.now(UTC) + timedelta(days=embed_request.expires_in_days) + + if existing_tokens: + # Update the existing token (reactivate if needed) + token = await db_client.update_embed_token( + existing_tokens[0].id, + user.selected_organization_id, + allowed_domains=embed_request.allowed_domains, + settings=embed_request.settings, + usage_limit=embed_request.usage_limit, + expires_at=expires_at, + is_active=True, + ) + else: + # Create new token + token = await db_client.create_embed_token( + workflow_id=workflow_id, + organization_id=user.selected_organization_id, + created_by=user.id, + allowed_domains=embed_request.allowed_domains, + settings=embed_request.settings, + usage_limit=embed_request.usage_limit, + expires_at=expires_at, + ) + + # Generate embed script + embed_script = generate_embed_script(token) + + return EmbedTokenResponse( + id=token.id, + token=token.token, + allowed_domains=token.allowed_domains, + settings=token.settings, + is_active=token.is_active, + usage_count=token.usage_count, + usage_limit=token.usage_limit, + expires_at=token.expires_at, + created_at=token.created_at, + embed_script=embed_script, + ) + + +@router.get("/{workflow_id}/embed-token") +async def get_embed_token( + workflow_id: int, + request: Request, + user: UserModel = Depends(get_user), +) -> Optional[EmbedTokenResponse]: + """ + Get the embed token for a workflow if it exists. + """ + # Verify workflow exists and user has access + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if not workflow: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + + # Get active embed tokens for this workflow + tokens = await db_client.get_embed_tokens_by_workflow( + workflow_id, user.selected_organization_id, active_only=True + ) + + if not tokens: + return None + + token = tokens[0] # There should be only one active token per workflow + + # Generate embed script + embed_script = generate_embed_script(token) + + return EmbedTokenResponse( + id=token.id, + token=token.token, + allowed_domains=token.allowed_domains, + settings=token.settings, + is_active=token.is_active, + usage_count=token.usage_count, + usage_limit=token.usage_limit, + expires_at=token.expires_at, + created_at=token.created_at, + embed_script=embed_script, + ) + + +@router.delete("/{workflow_id}/embed-token") +async def deactivate_embed_token( + workflow_id: int, + user: UserModel = Depends(get_user), +) -> dict: + """ + Deactivate the embed token for a workflow. + """ + # Verify workflow exists and user has access + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if not workflow: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + + # Get active embed tokens for this workflow + tokens = await db_client.get_embed_tokens_by_workflow( + workflow_id, user.selected_organization_id, active_only=True + ) + + if not tokens: + raise HTTPException( + status_code=404, detail="No active embed token found for this workflow" + ) + + # Deactivate the token + success = await db_client.deactivate_embed_token( + tokens[0].id, user.selected_organization_id + ) + + if success: + return {"message": "Embed token deactivated successfully"} + else: + raise HTTPException(status_code=500, detail="Failed to deactivate embed token") diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 590f15c..6781d61 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -105,53 +105,6 @@ async def get_user( return user_model -async def _handle_oss_auth(authorization: str | None) -> UserModel: - """ - Handle authentication for OSS deployment mode. - Uses the authorization token as provider_id and creates user/org if needed. - """ - if not authorization: - raise HTTPException(status_code=401, detail="Authorization header required") - - # Remove "Bearer " prefix if present - token = ( - authorization.replace("Bearer ", "") - if authorization.startswith("Bearer ") - else authorization - ) - - if not token: - raise HTTPException(status_code=401, detail="Invalid authorization token") - - try: - # Use token as provider_id for OSS mode - user_model = await db_client.get_or_create_user_by_provider_id( - provider_id=token - ) - - # Create or get organization for OSS user - # Each OSS user gets their own organization using their token as org ID - organization = await db_client.get_or_create_organization_by_provider_id( - provider_id=f"org_{token}" - ) - - # Ensure user is mapped to their organization - if user_model.selected_organization_id != organization.id: - # add_user_to_organization now handles race conditions with ON CONFLICT DO NOTHING - await db_client.add_user_to_organization(user_model.id, organization.id) - await db_client.update_user_selected_organization( - user_model.id, organization.id - ) - user_model.selected_organization_id = organization.id - - return user_model - - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Error while handling OSS authentication: {e}" - ) - - async def get_user_optional( authorization: Annotated[str | None, Header()] = None, ) -> UserModel | None: diff --git a/api/services/campaign/call_dispatcher.py b/api/services/campaign/call_dispatcher.py index ba091e9..fd1d610 100644 --- a/api/services/campaign/call_dispatcher.py +++ b/api/services/campaign/call_dispatcher.py @@ -7,10 +7,10 @@ from loguru import logger from api.db import db_client from api.db.models import QueuedRunModel, WorkflowRunModel -from api.enums import OrganizationConfigurationKey, WorkflowRunMode +from api.enums import OrganizationConfigurationKey from api.services.campaign.rate_limiter import rate_limiter -from api.services.telephony.factory import get_telephony_provider from api.services.telephony.base import TelephonyProvider +from api.services.telephony.factory import get_telephony_provider from api.utils.tunnel import TunnelURLProvider @@ -238,7 +238,7 @@ class CampaignCallDispatcher: f"&campaign_id={campaign.id}" f"&organization_id={campaign.organization_id}" ) - + call_result = await provider.initiate_call( to_number=phone_number, webhook_url=webhook_url, @@ -255,7 +255,9 @@ class CampaignCallDispatcher: ) # Update workflow run as failed - telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", []) + telephony_callback_logs = workflow_run.logs.get( + "telephony_status_callbacks", [] + ) telephony_callback_log = { "status": "failed", "timestamp": datetime.now(UTC).isoformat(), diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 6a92969..98bc9b1 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -119,7 +119,7 @@ async def run_pipeline_vonage( user_id: int, ): """Run pipeline for Vonage WebSocket connections. - + Vonage uses raw PCM audio over WebSocket instead of base64-encoded μ-law. The audio is transmitted as binary frames at 16kHz by default. """ @@ -137,7 +137,9 @@ async def run_pipeline_vonage( if "vad_configuration" in workflow.workflow_configurations: vad_config = workflow.workflow_configurations["vad_configuration"] if "ambient_noise_configuration" in workflow.workflow_configurations: - ambient_noise_config = workflow.workflow_configurations["ambient_noise_configuration"] + ambient_noise_config = workflow.workflow_configurations[ + "ambient_noise_configuration" + ] try: # Setup audio config for Vonage using the centralized config diff --git a/api/services/pipecat/transport_setup.py b/api/services/pipecat/transport_setup.py index 480cf67..a674ce4 100644 --- a/api/services/pipecat/transport_setup.py +++ b/api/services/pipecat/transport_setup.py @@ -165,14 +165,15 @@ async def create_vonage_transport( # Use the factory to load config from database from api.services.telephony.factory import load_telephony_config + config = await load_telephony_config(organization_id) - + if config.get("provider") != "vonage": raise ValueError(f"Expected Vonage provider, got {config.get('provider')}") application_id = config.get("application_id") private_key = config.get("private_key") - + if not application_id or not private_key: raise ValueError( f"Incomplete Vonage configuration for organization {organization_id}" @@ -186,8 +187,8 @@ async def create_vonage_transport( private_key=private_key, params=VonageFrameSerializer.InputParams( vonage_sample_rate=audio_config.transport_in_sample_rate, - sample_rate=audio_config.pipeline_sample_rate - ) + sample_rate=audio_config.pipeline_sample_rate, + ), ) # Important: Vonage uses binary WebSocket mode, not text diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index 598081e..8668872 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -3,6 +3,7 @@ Base telephony provider interface for abstracting telephony services. This allows easy switching between different providers (Twilio, Vonage, etc.) while keeping business logic decoupled from specific implementations. """ + from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, List, Optional @@ -14,10 +15,15 @@ if TYPE_CHECKING: @dataclass class CallInitiationResult: """Standardized response from initiate_call across all providers.""" - call_id: str # Provider's call identifier (SID for Twilio, UUID for Vonage) - status: str # Initial status (e.g., "queued", "initiated", "started") - provider_metadata: Dict[str, Any] = field(default_factory=dict) # Data that needs to be persisted - raw_response: Dict[str, Any] = field(default_factory=dict) # Full provider response for debugging + + call_id: str # Provider's call identifier (SID for Twilio, UUID for Vonage) + status: str # Initial status (e.g., "queued", "initiated", "started") + provider_metadata: Dict[str, Any] = field( + default_factory=dict + ) # Data that needs to be persisted + raw_response: Dict[str, Any] = field( + default_factory=dict + ) # Full provider response for debugging class TelephonyProvider(ABC): @@ -25,6 +31,7 @@ class TelephonyProvider(ABC): Abstract base class for telephony providers. All telephony providers must implement these core methods. """ + PROVIDER_NAME = None WEBHOOK_ENDPOINT = None @@ -38,13 +45,13 @@ class TelephonyProvider(ABC): ) -> CallInitiationResult: """ Initiate an outbound call. - + Args: to_number: The destination phone number webhook_url: The URL to receive call events workflow_run_id: Optional workflow run ID for tracking **kwargs: Provider-specific additional parameters - + Returns: CallInitiationResult with standardized call details """ @@ -54,10 +61,10 @@ class TelephonyProvider(ABC): async def get_call_status(self, call_id: str) -> Dict[str, Any]: """ Get the current status of a call. - + Args: call_id: The provider-specific call identifier - + Returns: Dict containing call status information """ @@ -67,7 +74,7 @@ class TelephonyProvider(ABC): async def get_available_phone_numbers(self) -> List[str]: """ Get list of available phone numbers for this provider. - + Returns: List of phone numbers that can be used for outbound calls """ @@ -77,7 +84,7 @@ class TelephonyProvider(ABC): def validate_config(self) -> bool: """ Validate that the provider is properly configured. - + Returns: True if configuration is valid, False otherwise """ @@ -89,12 +96,12 @@ class TelephonyProvider(ABC): ) -> bool: """ Verify webhook signature for security. - + Args: url: The webhook URL params: The webhook parameters signature: The signature to verify - + Returns: True if signature is valid, False otherwise """ @@ -106,12 +113,12 @@ class TelephonyProvider(ABC): ) -> str: """ Generate the initial webhook response for starting a call session. - + Args: workflow_id: The workflow ID user_id: The user ID workflow_run_id: The workflow run ID - + Returns: Provider-specific response (e.g., TwiML for Twilio) """ @@ -121,10 +128,10 @@ class TelephonyProvider(ABC): async def get_call_cost(self, call_id: str) -> Dict[str, Any]: """ Get cost information for a completed call. - + Args: call_id: Provider-specific call identifier (SID for Twilio, UUID for Vonage) - + Returns: Dict containing: - cost_usd: The cost in USD as float @@ -138,10 +145,10 @@ class TelephonyProvider(ABC): def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]: """ Parse provider-specific status callback data into generic format. - + Args: data: Raw callback data from the provider - + Returns: Dict with standardized fields: - call_id: Provider's call identifier @@ -163,14 +170,14 @@ class TelephonyProvider(ABC): ) -> None: """ Handle provider-specific WebSocket connection for real-time call audio. - + This method encapsulates all provider-specific WebSocket handshake and message routing logic, keeping the main websocket endpoint clean. - + Args: websocket: The WebSocket connection workflow_id: The workflow ID user_id: The user ID workflow_run_id: The workflow run ID """ - pass \ No newline at end of file + pass diff --git a/api/services/telephony/factory.py b/api/services/telephony/factory.py index 4762572..f89f8b5 100644 --- a/api/services/telephony/factory.py +++ b/api/services/telephony/factory.py @@ -3,8 +3,8 @@ Factory for creating telephony providers. Handles configuration loading from environment (OSS) or database (SaaS). The providers themselves don't know or care where config comes from. """ -import os -from typing import Any, Dict, Optional + +from typing import Any, Dict from loguru import logger @@ -18,36 +18,36 @@ from api.services.telephony.providers.vonage_provider import VonageProvider async def load_telephony_config(organization_id: int) -> Dict[str, Any]: """ Load telephony configuration from database. - + Args: organization_id: Organization ID for database config - + Returns: Configuration dictionary with provider type and credentials - + Raises: ValueError: If no configuration found for the organization """ if not organization_id: raise ValueError("Organization ID is required to load telephony configuration") - + logger.debug(f"Loading telephony config from database for org {organization_id}") - + config = await db_client.get_configuration( organization_id, OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, ) - + if config and config.value: # Simple single-provider format provider = config.value.get("provider", "twilio") - + if provider == "twilio": return { "provider": "twilio", "account_sid": config.value.get("account_sid"), "auth_token": config.value.get("auth_token"), - "from_numbers": config.value.get("from_numbers", []) + "from_numbers": config.value.get("from_numbers", []), } elif provider == "vonage": return { @@ -56,41 +56,41 @@ async def load_telephony_config(organization_id: int) -> Dict[str, Any]: "private_key": config.value.get("private_key"), "api_key": config.value.get("api_key"), "api_secret": config.value.get("api_secret"), - "from_numbers": config.value.get("from_numbers", []) + "from_numbers": config.value.get("from_numbers", []), } else: raise ValueError(f"Unknown provider in config: {provider}") - - raise ValueError(f"No telephony configuration found for organization {organization_id}") + + raise ValueError( + f"No telephony configuration found for organization {organization_id}" + ) -async def get_telephony_provider( - organization_id: int -) -> TelephonyProvider: +async def get_telephony_provider(organization_id: int) -> TelephonyProvider: """ Factory function to create telephony providers. - + Args: organization_id: Organization ID (required) - + Returns: Configured telephony provider instance - + Raises: ValueError: If provider type is unknown or configuration is invalid """ # Load configuration config = await load_telephony_config(organization_id) - + provider_type = config.get("provider", "twilio") logger.info(f"Creating {provider_type} telephony provider") - + # Create provider instance with configuration if provider_type == "twilio": return TwilioProvider(config) - + elif provider_type == "vonage": return VonageProvider(config) - + else: raise ValueError(f"Unknown telephony provider: {provider_type}") diff --git a/api/services/telephony/providers/__init__.py b/api/services/telephony/providers/__init__.py index 5c8985e..16d28ea 100644 --- a/api/services/telephony/providers/__init__.py +++ b/api/services/telephony/providers/__init__.py @@ -1 +1 @@ -# Telephony provider implementations \ No newline at end of file +# Telephony provider implementations diff --git a/api/services/telephony/providers/twilio_provider.py b/api/services/telephony/providers/twilio_provider.py index cb901e2..5e42ecf 100644 --- a/api/services/telephony/providers/twilio_provider.py +++ b/api/services/telephony/providers/twilio_provider.py @@ -1,6 +1,7 @@ """ Twilio implementation of the TelephonyProvider interface. """ + import json import random from typing import TYPE_CHECKING, Any, Dict, List, Optional @@ -9,9 +10,9 @@ import aiohttp from loguru import logger from twilio.request_validator import RequestValidator +from api.enums import WorkflowRunMode from api.services.telephony.base import CallInitiationResult, TelephonyProvider from api.utils.tunnel import TunnelURLProvider -from api.enums import WorkflowRunMode if TYPE_CHECKING: from fastapi import WebSocket @@ -22,14 +23,14 @@ class TwilioProvider(TelephonyProvider): Twilio implementation of TelephonyProvider. Accepts configuration and works the same regardless of OSS/SaaS mode. """ - + PROVIDER_NAME = WorkflowRunMode.TWILIO.value WEBHOOK_ENDPOINT = "twiml" def __init__(self, config: Dict[str, Any]): """ Initialize TwilioProvider with configuration. - + Args: config: Dictionary containing: - account_sid: Twilio Account SID @@ -39,11 +40,11 @@ class TwilioProvider(TelephonyProvider): self.account_sid = config.get("account_sid") self.auth_token = config.get("auth_token") self.from_numbers = config.get("from_numbers", []) - + # Handle both single number (string) and multiple numbers (list) if isinstance(self.from_numbers, str): self.from_numbers = [self.from_numbers] - + self.base_url = f"https://api.twilio.com/2010-04-01/Accounts/{self.account_sid}" async def initiate_call( @@ -58,32 +59,35 @@ class TwilioProvider(TelephonyProvider): """ if not self.validate_config(): raise ValueError("Twilio provider not properly configured") - + endpoint = f"{self.base_url}/Calls.json" - + # Select a random phone number from_number = random.choice(self.from_numbers) logger.info(f"Selected phone number {from_number} for outbound call") - + # Prepare call data - data = { - "To": to_number, - "From": from_number, - "Url": webhook_url - } - + data = {"To": to_number, "From": from_number, "Url": webhook_url} + # Add status callback if workflow_run_id provided if workflow_run_id: backend_endpoint = await TunnelURLProvider.get_tunnel_url() callback_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}" - data.update({ - "StatusCallback": callback_url, - "StatusCallbackEvent": ["initiated", "ringing", "answered", "completed"], - "StatusCallbackMethod": "POST" - }) - + data.update( + { + "StatusCallback": callback_url, + "StatusCallbackEvent": [ + "initiated", + "ringing", + "answered", + "completed", + ], + "StatusCallbackMethod": "POST", + } + ) + data.update(kwargs) - + # Make the API request async with aiohttp.ClientSession() as session: auth = aiohttp.BasicAuth(self.account_sid, self.auth_token) @@ -91,14 +95,14 @@ class TwilioProvider(TelephonyProvider): if response.status != 201: error_data = await response.json() raise Exception(f"Failed to initiate call: {error_data}") - + response_data = await response.json() - + return CallInitiationResult( call_id=response_data["sid"], status=response_data.get("status", "queued"), provider_metadata={}, # Twilio doesn't need to persist extra data - raw_response=response_data + raw_response=response_data, ) async def get_call_status(self, call_id: str) -> Dict[str, Any]: @@ -107,16 +111,16 @@ class TwilioProvider(TelephonyProvider): """ if not self.validate_config(): raise ValueError("Twilio provider not properly configured") - + endpoint = f"{self.base_url}/Calls/{call_id}.json" - + async with aiohttp.ClientSession() as session: auth = aiohttp.BasicAuth(self.account_sid, self.auth_token) async with session.get(endpoint, auth=auth) as response: if response.status != 200: error_data = await response.json() raise Exception(f"Failed to get call status: {error_data}") - + return await response.json() async def get_available_phone_numbers(self) -> List[str]: @@ -129,11 +133,7 @@ class TwilioProvider(TelephonyProvider): """ Validate Twilio configuration. """ - return bool( - self.account_sid and - self.auth_token and - self.from_numbers - ) + return bool(self.account_sid and self.auth_token and self.from_numbers) async def verify_webhook_signature( self, url: str, params: Dict[str, Any], signature: str @@ -144,7 +144,7 @@ class TwilioProvider(TelephonyProvider): if not self.auth_token: logger.error("No auth token available for webhook signature verification") return False - + validator = RequestValidator(self.auth_token) return validator.validate(url, params, signature) @@ -155,7 +155,7 @@ class TwilioProvider(TelephonyProvider): Generate TwiML response for starting a call session. """ backend_endpoint = await TunnelURLProvider.get_tunnel_url() - + twiml_content = f""" @@ -168,15 +168,15 @@ class TwilioProvider(TelephonyProvider): async def get_call_cost(self, call_id: str) -> Dict[str, Any]: """ Get cost information for a completed Twilio call. - + Args: call_id: The Twilio Call SID - + Returns: Dict containing cost information """ endpoint = f"{self.base_url}/Calls/{call_id}.json" - + try: async with aiohttp.ClientSession() as session: auth = aiohttp.BasicAuth(self.account_sid, self.auth_token) @@ -188,34 +188,29 @@ class TwilioProvider(TelephonyProvider): "cost_usd": 0.0, "duration": 0, "status": "error", - "error": str(error_data) + "error": str(error_data), } - + call_data = await response.json() - + # Twilio returns price as a negative string (e.g., "-0.0085") price_str = call_data.get("price", "0") cost_usd = abs(float(price_str)) if price_str else 0.0 - + # Duration is in seconds as a string duration = int(call_data.get("duration", "0")) - + return { "cost_usd": cost_usd, "duration": duration, "status": call_data.get("status", "unknown"), "price_unit": call_data.get("price_unit", "USD"), - "raw_response": call_data + "raw_response": call_data, } - + except Exception as e: logger.error(f"Exception fetching Twilio call cost: {e}") - return { - "cost_usd": 0.0, - "duration": 0, - "status": "error", - "error": str(e) - } + return {"cost_usd": 0.0, "duration": 0, "status": "error", "error": str(e)} def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]: """ @@ -228,7 +223,7 @@ class TwilioProvider(TelephonyProvider): "to_number": data.get("To"), "direction": data.get("Direction"), "duration": data.get("CallDuration") or data.get("Duration"), - "extra": data # Include all original data + "extra": data, # Include all original data } async def handle_websocket( @@ -240,36 +235,38 @@ class TwilioProvider(TelephonyProvider): ) -> None: """ Handle Twilio-specific WebSocket connection. - + Twilio sends: 1. "connected" event first 2. "start" event with streamSid and callSid 3. Then audio messages """ from api.services.pipecat.run_pipeline import run_pipeline_twilio - + try: # Wait for "connected" event first_msg = await websocket.receive_text() msg = json.loads(first_msg) - + if msg.get("event") != "connected": logger.error(f"Expected 'connected' event, got: {msg.get('event')}") await websocket.close(code=4400, reason="Expected connected event") return - - logger.debug(f"Twilio WebSocket connected for workflow_run {workflow_run_id}") - + + logger.debug( + f"Twilio WebSocket connected for workflow_run {workflow_run_id}" + ) + # Wait for "start" event with stream details start_msg = await websocket.receive_text() logger.debug(f"Received start message: {start_msg}") - + start_msg = json.loads(start_msg) if start_msg.get("event") != "start": logger.error("Expected 'start' event second") await websocket.close(code=4400, reason="Expected start event") return - + # Extract Twilio-specific identifiers try: stream_sid = start_msg["start"]["streamSid"] @@ -278,12 +275,12 @@ class TwilioProvider(TelephonyProvider): logger.error("Missing streamSid or callSid in start message") await websocket.close(code=4400, reason="Missing stream identifiers") return - + # Run the Twilio pipeline await run_pipeline_twilio( websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id ) - + except Exception as e: logger.error(f"Error in Twilio WebSocket handler: {e}") - raise \ No newline at end of file + raise diff --git a/api/services/telephony/providers/vonage_provider.py b/api/services/telephony/providers/vonage_provider.py index 35e1e2d..a69e0a0 100644 --- a/api/services/telephony/providers/vonage_provider.py +++ b/api/services/telephony/providers/vonage_provider.py @@ -1,6 +1,7 @@ """ Vonage (Nexmo) implementation of the TelephonyProvider interface. """ + import json import random import time @@ -10,9 +11,9 @@ import aiohttp import jwt from loguru import logger +from api.enums import WorkflowRunMode from api.services.telephony.base import CallInitiationResult, TelephonyProvider from api.utils.tunnel import TunnelURLProvider -from api.enums import WorkflowRunMode if TYPE_CHECKING: from fastapi import WebSocket @@ -23,14 +24,14 @@ class VonageProvider(TelephonyProvider): Vonage implementation of TelephonyProvider. Uses JWT authentication and NCCO for call control. """ - + PROVIDER_NAME = WorkflowRunMode.VONAGE.value WEBHOOK_ENDPOINT = "ncco" - + def __init__(self, config: Dict[str, Any]): """ Initialize VonageProvider with configuration. - + Args: config: Dictionary containing: - api_key: Vonage API Key @@ -44,25 +45,27 @@ class VonageProvider(TelephonyProvider): self.application_id = config.get("application_id") self.private_key = config.get("private_key") self.from_numbers = config.get("from_numbers", []) - + # Handle both single number (string) and multiple numbers (list) if isinstance(self.from_numbers, str): self.from_numbers = [self.from_numbers] - + self.base_url = "https://api.nexmo.com" def _generate_jwt(self) -> str: """Generate JWT token for Vonage API authentication.""" if not self.application_id or not self.private_key: - raise ValueError("Application ID and private key required for JWT generation") - + raise ValueError( + "Application ID and private key required for JWT generation" + ) + claims = { "application_id": self.application_id, "iat": int(time.time()), "exp": int(time.time()) + 3600, - "jti": str(time.time()) + "jti": str(time.time()), } - + return jwt.encode(claims, self.private_key, algorithm="RS256") async def initiate_call( @@ -77,68 +80,57 @@ class VonageProvider(TelephonyProvider): """ if not self.validate_config(): raise ValueError("Vonage provider not properly configured") - + endpoint = f"{self.base_url}/v1/calls" - + # Select a random phone number from_number = random.choice(self.from_numbers) # Remove '+' prefix for Vonage from_number = from_number.replace("+", "") to_number = to_number.replace("+", "") - + logger.info(f"Selected phone number {from_number} for outbound call") - + # Prepare call data data = { - "to": [{ - "type": "phone", - "number": to_number - }], - "from": { - "type": "phone", - "number": from_number - }, + "to": [{"type": "phone", "number": to_number}], + "from": {"type": "phone", "number": from_number}, "answer_url": [webhook_url], - "answer_method": "GET" + "answer_method": "GET", } - + # Add event webhook if workflow_run_id provided if workflow_run_id: backend_endpoint = await TunnelURLProvider.get_tunnel_url() event_url = f"https://{backend_endpoint}/api/v1/telephony/vonage/events/{workflow_run_id}" - data.update({ - "event_url": [event_url], - "event_method": "POST" - }) - + data.update({"event_url": [event_url], "event_method": "POST"}) + data.update(kwargs) - + # Generate JWT token token = self._generate_jwt() headers = { "Authorization": f"Bearer {token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + # Make the API request async with aiohttp.ClientSession() as session: - async with session.post( - endpoint, - json=data, - headers=headers - ) as response: + async with session.post(endpoint, json=data, headers=headers) as response: response_data = await response.json() - + if response.status != 201: raise Exception(f"Failed to initiate call: {response_data}") - + return CallInitiationResult( call_id=response_data["uuid"], status=response_data.get("status", "started"), provider_metadata={ - "call_uuid": response_data["uuid"] # Vonage needs UUID persisted for WebSocket + "call_uuid": response_data[ + "uuid" + ] # Vonage needs UUID persisted for WebSocket }, - raw_response=response_data + raw_response=response_data, ) async def get_call_status(self, call_id: str) -> Dict[str, Any]: @@ -147,21 +139,19 @@ class VonageProvider(TelephonyProvider): """ if not self.validate_config(): raise ValueError("Vonage provider not properly configured") - + endpoint = f"{self.base_url}/v1/calls/{call_id}" - + # Generate JWT token token = self._generate_jwt() - headers = { - "Authorization": f"Bearer {token}" - } - + headers = {"Authorization": f"Bearer {token}"} + async with aiohttp.ClientSession() as session: async with session.get(endpoint, headers=headers) as response: if response.status != 200: error_data = await response.json() raise Exception(f"Failed to get call status: {error_data}") - + return await response.json() async def get_available_phone_numbers(self) -> List[str]: @@ -174,11 +164,7 @@ class VonageProvider(TelephonyProvider): """ Validate Vonage configuration. """ - return bool( - self.application_id and - self.private_key and - self.from_numbers - ) + return bool(self.application_id and self.private_key and self.from_numbers) async def verify_webhook_signature( self, url: str, params: Dict[str, Any], signature: str @@ -190,14 +176,14 @@ class VonageProvider(TelephonyProvider): if not self.api_secret: logger.error("No API secret available for webhook signature verification") return False - + try: # Vonage sends JWT in Authorization header. Verify the JWT signature decoded = jwt.decode( - signature, - self.api_secret, + signature, + self.api_secret, algorithms=["HS256"], - options={"verify_signature": True} + options={"verify_signature": True}, ) return True except jwt.InvalidTokenError: @@ -211,43 +197,42 @@ class VonageProvider(TelephonyProvider): NCCO (Nexmo Call Control Objects) is JSON-based, unlike TwiML which is XML. """ backend_endpoint = await TunnelURLProvider.get_tunnel_url() - + # NCCO for WebSocket connection ncco = [ { "action": "connect", - "endpoint": [{ - "type": "websocket", - "uri": f"wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}", - "content-type": "audio/l16;rate=16000", # 16kHz Linear PCM - "headers": {} - }] + "endpoint": [ + { + "type": "websocket", + "uri": f"wss://{backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}", + "content-type": "audio/l16;rate=16000", # 16kHz Linear PCM + "headers": {}, + } + ], } ] - + return json.dumps(ncco) def _get_auth_headers(self) -> Dict[str, str]: """Generate authorization headers for Vonage API.""" token = self._generate_jwt() - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async def get_call_cost(self, call_id: str) -> Dict[str, Any]: """ Get cost information for a completed Vonage call. - + Args: call_id: The Vonage Call UUID - + Returns: Dict containing cost information """ headers = self._get_auth_headers() endpoint = f"https://api.nexmo.com/v1/calls/{call_id}" - + try: async with aiohttp.ClientSession() as session: async with session.get(endpoint, headers=headers) as response: @@ -258,39 +243,34 @@ class VonageProvider(TelephonyProvider): "cost_usd": 0.0, "duration": 0, "status": "error", - "error": str(error_data) + "error": str(error_data), } - + call_data = await response.json() - + # Vonage returns price and rate # Price is the total cost, rate is the per-minute rate price = float(call_data.get("price", 0)) cost_usd = price # Vonage returns positive values - + # Duration is in seconds duration = int(call_data.get("duration", 0)) - + # Get the call status status = call_data.get("status", "unknown") - + return { "cost_usd": cost_usd, "duration": duration, "status": status, "price_unit": "USD", # Vonage uses USD by default "rate": call_data.get("rate", 0), # Per-minute rate - "raw_response": call_data + "raw_response": call_data, } - + except Exception as e: logger.error(f"Exception fetching Vonage call cost: {e}") - return { - "cost_usd": 0.0, - "duration": 0, - "status": "error", - "error": str(e) - } + return {"cost_usd": 0.0, "duration": 0, "status": "error", "error": str(e)} def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]: """ @@ -300,14 +280,14 @@ class VonageProvider(TelephonyProvider): status_map = { "started": "initiated", "ringing": "ringing", - "answered": "answered", + "answered": "answered", "complete": "completed", "failed": "failed", "busy": "busy", "timeout": "no-answer", - "rejected": "busy" + "rejected": "busy", } - + return { "call_id": data.get("uuid", ""), "status": status_map.get(data.get("status", ""), data.get("status", "")), @@ -315,7 +295,7 @@ class VonageProvider(TelephonyProvider): "to_number": data.get("to"), "direction": data.get("direction"), "duration": data.get("duration"), - "extra": data # Include all original data + "extra": data, # Include all original data } async def handle_websocket( @@ -327,14 +307,14 @@ class VonageProvider(TelephonyProvider): ) -> None: """ Handle Vonage-specific WebSocket connection. - + Vonage can send: 1. JSON metadata first (websocket:connected event) 2. Or directly start with binary audio """ from api.db import db_client from api.services.pipecat.run_pipeline import run_pipeline_vonage - + try: # Get workflow run to extract call UUID workflow_run = await db_client.get_workflow_run(workflow_run_id) @@ -342,38 +322,48 @@ class VonageProvider(TelephonyProvider): logger.error(f"Workflow run {workflow_run_id} not found") await websocket.close(code=4404, reason="Workflow run not found") return - + # Get workflow for organization info workflow = await db_client.get_workflow(workflow_id, user_id) if not workflow: logger.error(f"Workflow {workflow_id} not found") await websocket.close(code=4404, reason="Workflow not found") return - + # Extract call UUID from workflow run context - call_uuid = workflow_run.gathered_context.get("call_uuid") if workflow_run.gathered_context else None - + call_uuid = ( + workflow_run.gathered_context.get("call_uuid") + if workflow_run.gathered_context + else None + ) + if not call_uuid: - logger.error(f"No call UUID found for Vonage connection in workflow run {workflow_run_id}") + logger.error( + f"No call UUID found for Vonage connection in workflow run {workflow_run_id}" + ) await websocket.close(code=4400, reason="Missing call UUID") return - - logger.info(f"Vonage WebSocket connected for workflow_run {workflow_run_id}, call_uuid: {call_uuid}") - + + logger.info( + f"Vonage WebSocket connected for workflow_run {workflow_run_id}, call_uuid: {call_uuid}" + ) + # Peek at first message to see if it's metadata or audio first_msg = await websocket.receive() - + if "text" in first_msg: # JSON metadata - check if it's the connection event msg = json.loads(first_msg["text"]) if msg.get("event") == "websocket:connected": - logger.debug(f"Received Vonage connection confirmation for {workflow_run_id}") + logger.debug( + f"Received Vonage connection confirmation for {workflow_run_id}" + ) # Continue to pipeline regardless of message type elif "bytes" in first_msg: # Binary audio - Vonage started with audio immediately logger.debug(f"Vonage started with binary audio for {workflow_run_id}") # The pipeline will handle this first audio chunk - + # Run the Vonage pipeline await run_pipeline_vonage( websocket, @@ -382,9 +372,9 @@ class VonageProvider(TelephonyProvider): workflow.organization_id, workflow_id, workflow_run_id, - user_id + user_id, ) - + except Exception as e: logger.error(f"Error in Vonage WebSocket handler: {e}") - raise \ No newline at end of file + raise diff --git a/api/services/telephony/stasis_rtp_transport.py b/api/services/telephony/stasis_rtp_transport.py index cd3716d..20a6b64 100644 --- a/api/services/telephony/stasis_rtp_transport.py +++ b/api/services/telephony/stasis_rtp_transport.py @@ -22,9 +22,7 @@ from pipecat.frames.frames import ( ) from pipecat.serializers.base_serializer import FrameSerializer from pipecat.transports.base_input import BaseInputTransport -from pipecat.transports.base_output import ( - BaseOutputTransport -) +from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams diff --git a/api/tasks/workflow_run_cost.py b/api/tasks/workflow_run_cost.py index 4b3dc1b..25400fc 100644 --- a/api/tasks/workflow_run_cost.py +++ b/api/tasks/workflow_run_cost.py @@ -28,20 +28,24 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int): # Fetch telephony call cost for both Twilio and Vonage telephony_cost_usd = 0.0 - if workflow_run.mode in [WorkflowRunMode.TWILIO.value, WorkflowRunMode.VONAGE.value] and workflow_run.cost_info: + if ( + workflow_run.mode + in [WorkflowRunMode.TWILIO.value, WorkflowRunMode.VONAGE.value] + and workflow_run.cost_info + ): # Get the call ID (provider-agnostic approach with backward compatibility) call_id = workflow_run.cost_info.get("call_id") - + # Fallback to legacy provider-specific fields if needed if not call_id: if workflow_run.mode == WorkflowRunMode.TWILIO.value: call_id = workflow_run.cost_info.get("twilio_call_sid") elif workflow_run.mode == WorkflowRunMode.VONAGE.value: call_id = workflow_run.cost_info.get("vonage_call_uuid") - + # Provider name is derived from workflow run mode provider_name = workflow_run.mode.lower() if workflow_run.mode else "" - + if call_id: try: # Get workflow to access organization_id @@ -55,12 +59,14 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int): # Use telephony provider abstraction provider = await get_telephony_provider(workflow.organization_id) call_cost_info = await provider.get_call_cost(call_id) - + if call_cost_info.get("status") != "error": telephony_cost_usd = call_cost_info.get("cost_usd", 0.0) cost_breakdown["telephony_call"] = telephony_cost_usd - cost_breakdown[f"{provider_name}_call"] = telephony_cost_usd # Keep backward compatibility - + cost_breakdown[f"{provider_name}_call"] = ( + telephony_cost_usd # Keep backward compatibility + ) + # Add telephony cost to the total cost_breakdown["total"] = ( float(cost_breakdown["total"]) + telephony_cost_usd @@ -69,8 +75,10 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int): f"{provider_name.title()} call cost: ${telephony_cost_usd:.6f} USD for call {call_id}" ) else: - logger.error(f"Failed to fetch {provider_name} call cost: {call_cost_info.get('error')}") - + logger.error( + f"Failed to fetch {provider_name} call cost: {call_cost_info.get('error')}" + ) + except Exception as e: logger.error(f"Failed to fetch telephony call cost: {e}") # Don't fail the whole cost calculation if telephony API fails @@ -119,7 +127,9 @@ async def calculate_workflow_run_cost(ctx, workflow_run_id: int): elif "twilio_call_sid" in workflow_run.cost_info: cost_info["twilio_call_sid"] = workflow_run.cost_info["twilio_call_sid"] elif "vonage_call_uuid" in workflow_run.cost_info: - cost_info["vonage_call_uuid"] = workflow_run.cost_info["vonage_call_uuid"] + cost_info["vonage_call_uuid"] = workflow_run.cost_info[ + "vonage_call_uuid" + ] # Update workflow run with cost information await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info) diff --git a/api/tests/test_provider_switching.py b/api/tests/test_provider_switching.py index c2a5f01..9f11db7 100644 --- a/api/tests/test_provider_switching.py +++ b/api/tests/test_provider_switching.py @@ -5,46 +5,41 @@ handles provider switches correctly without losing billing data. """ import asyncio -import json -from datetime import datetime, timezone -from typing import Dict, Any # Test scenarios to validate + async def test_scenario_1_mid_call_provider_switch(): """ Test: What happens if provider is switched while a call is active? - + Expected behavior: - Active call continues with original provider - Call is billed to original provider - New calls use new provider """ print("Test 1: Mid-call provider switching") - + # Simulate workflow run with Twilio twilio_run = { "id": 1, "mode": "twilio", - "cost_info": { - "twilio_call_sid": "CA123456789", - "provider": "twilio" - }, - "is_completed": False + "cost_info": {"twilio_call_sid": "CA123456789", "provider": "twilio"}, + "is_completed": False, } - + # Provider switch happens here (in real scenario, user changes config) # But the call continues... - + # When cost calculation runs, it should: # 1. Use the provider stored in cost_info # 2. Fetch cost from Twilio using twilio_call_sid # 3. Store cost with provider attribution - + result = { "test": "mid_call_switch", "status": "PASS", - "reason": "Call continues with original provider, billing intact" + "reason": "Call continues with original provider, billing intact", } print(f" ✓ {result['reason']}") return result @@ -53,41 +48,41 @@ async def test_scenario_1_mid_call_provider_switch(): async def test_scenario_2_pending_cost_calculation(): """ Test: Calls that ended but cost not yet calculated when provider switches. - + Expected behavior: - Background job should use the provider info stored in cost_info - Cost should be fetched from correct provider """ print("\nTest 2: Pending cost calculation during switch") - + # Workflow runs that ended but cost job hasn't run yet pending_runs = [ { "id": 2, - "mode": "twilio", + "mode": "twilio", "cost_info": {"twilio_call_sid": "CA987654321", "provider": "twilio"}, - "is_completed": True + "is_completed": True, }, { "id": 3, "mode": "vonage", "cost_info": {"vonage_call_uuid": "uuid-123", "provider": "vonage"}, - "is_completed": True - } + "is_completed": True, + }, ] - + # Provider switch happens here # Cost calculation jobs run after switch - + # Each job should: # 1. Check the provider field in cost_info # 2. Use appropriate provider API to fetch cost # 3. Handle gracefully if credentials changed - + result = { "test": "pending_cost_calculation", "status": "PASS", - "reason": "Cost jobs use stored provider info correctly" + "reason": "Cost jobs use stored provider info correctly", } print(f" ✓ {result['reason']}") return result @@ -96,33 +91,37 @@ async def test_scenario_2_pending_cost_calculation(): async def test_scenario_3_mixed_provider_history(): """ Test: Organization has calls from both Twilio and Vonage. - + Expected behavior: - Historical costs remain intact - Reports show correct attribution - Total costs aggregate correctly """ print("\nTest 3: Mixed provider history") - + historical_runs = [ {"provider": "twilio", "cost_usd": 0.15, "date": "2024-01-01"}, {"provider": "vonage", "cost_usd": 0.12, "date": "2024-01-02"}, {"provider": "twilio", "cost_usd": 0.18, "date": "2024-01-03"}, {"provider": "vonage", "cost_usd": 0.14, "date": "2024-01-04"}, ] - + # Calculate totals total_cost = sum(run["cost_usd"] for run in historical_runs) - twilio_cost = sum(run["cost_usd"] for run in historical_runs if run["provider"] == "twilio") - vonage_cost = sum(run["cost_usd"] for run in historical_runs if run["provider"] == "vonage") - + twilio_cost = sum( + run["cost_usd"] for run in historical_runs if run["provider"] == "twilio" + ) + vonage_cost = sum( + run["cost_usd"] for run in historical_runs if run["provider"] == "vonage" + ) + result = { "test": "mixed_provider_history", - "status": "PASS", + "status": "PASS", "total_cost": total_cost, "twilio_cost": twilio_cost, "vonage_cost": vonage_cost, - "reason": f"Costs correctly aggregated: Total ${total_cost:.2f} (Twilio: ${twilio_cost:.2f}, Vonage: ${vonage_cost:.2f})" + "reason": f"Costs correctly aggregated: Total ${total_cost:.2f} (Twilio: ${twilio_cost:.2f}, Vonage: ${vonage_cost:.2f})", } print(f" ✓ {result['reason']}") return result @@ -131,41 +130,41 @@ async def test_scenario_3_mixed_provider_history(): async def test_scenario_4_cost_api_failure(): """ Test: Provider API fails when fetching cost. - + Expected behavior: - Error logged but system continues - Call record preserved - Cost marked as 0 or unknown """ print("\nTest 4: Cost API failure handling") - + # Simulate API failure scenarios failure_scenarios = [ { "provider": "twilio", "error": "401 Unauthorized - credentials changed", - "expected": "Cost set to 0, error logged" + "expected": "Cost set to 0, error logged", }, { "provider": "vonage", "error": "404 Not Found - call record deleted", - "expected": "Cost set to 0, error logged" + "expected": "Cost set to 0, error logged", }, { "provider": "twilio", "error": "500 Internal Server Error", - "expected": "Cost set to 0, retry possible" - } + "expected": "Cost set to 0, retry possible", + }, ] - + for scenario in failure_scenarios: print(f" - {scenario['provider']}: {scenario['error']}") print(f" Expected: {scenario['expected']}") - + result = { "test": "cost_api_failure", "status": "PASS", - "reason": "All failure scenarios handled gracefully" + "reason": "All failure scenarios handled gracefully", } print(f" ✓ {result['reason']}") return result @@ -174,22 +173,22 @@ async def test_scenario_4_cost_api_failure(): async def test_scenario_5_configuration_migration(): """ Test: Database migration from single to multi-provider format. - + Expected behavior: - Old TWILIO_CONFIGURATION migrated to TELEPHONY_CONFIGURATION - Single provider config wrapped in multi-provider structure - Existing cost_info gets provider field added """ print("\nTest 5: Configuration migration") - + # Old format old_config = { "account_sid": "AC123", - "auth_token": "token123", + "auth_token": "token123", "from_numbers": ["+1234567890"], - "provider": "twilio" + "provider": "twilio", } - + # New format after migration new_config = { "active_provider": "twilio", @@ -197,20 +196,20 @@ async def test_scenario_5_configuration_migration(): "twilio": { "account_sid": "AC123", "auth_token": "token123", - "from_numbers": ["+1234567890"] + "from_numbers": ["+1234567890"], } - } + }, } - + # Validate migration assert new_config["active_provider"] == "twilio" assert "providers" in new_config assert new_config["providers"]["twilio"]["account_sid"] == old_config["account_sid"] - + result = { "test": "configuration_migration", "status": "PASS", - "reason": "Configuration migrated to multi-provider format correctly" + "reason": "Configuration migrated to multi-provider format correctly", } print(f" ✓ {result['reason']}") return result @@ -219,39 +218,34 @@ async def test_scenario_5_configuration_migration(): async def test_scenario_6_provider_cost_discrepancy(): """ Test: Webhook cost vs API cost discrepancy. - + Expected behavior: - Webhook cost stored immediately if available - API cost fetched later for verification - Both costs stored for auditing """ print("\nTest 6: Provider cost discrepancy handling") - + # Vonage webhook provides immediate cost - webhook_cost = { - "vonage_webhook_price": 0.15, - "vonage_webhook_duration": 120 - } - + webhook_cost = {"vonage_webhook_price": 0.15, "vonage_webhook_duration": 120} + # API call provides authoritative cost api_cost = { "cost_usd": 0.14, # Slight difference - "duration": 120 + "duration": 120, } - + # Both should be stored final_cost_info = { **webhook_cost, - "cost_breakdown": { - "telephony_call": api_cost["cost_usd"] - }, - "provider": "vonage" + "cost_breakdown": {"telephony_call": api_cost["cost_usd"]}, + "provider": "vonage", } - + result = { "test": "cost_discrepancy", "status": "PASS", - "reason": "Both webhook and API costs stored for auditing" + "reason": "Both webhook and API costs stored for auditing", } print(f" ✓ {result['reason']}") return result @@ -262,40 +256,40 @@ async def run_all_tests(): print("=" * 60) print("PROVIDER SWITCHING TEST SUITE") print("=" * 60) - + tests = [ test_scenario_1_mid_call_provider_switch, test_scenario_2_pending_cost_calculation, test_scenario_3_mixed_provider_history, test_scenario_4_cost_api_failure, test_scenario_5_configuration_migration, - test_scenario_6_provider_cost_discrepancy + test_scenario_6_provider_cost_discrepancy, ] - + results = [] for test in tests: result = await test() results.append(result) - + print("\n" + "=" * 60) print("TEST SUMMARY") print("=" * 60) - + passed = sum(1 for r in results if r["status"] == "PASS") failed = sum(1 for r in results if r["status"] == "FAIL") - + print(f"Total Tests: {len(results)}") print(f"Passed: {passed}") print(f"Failed: {failed}") - + if failed == 0: print("\n✅ ALL TESTS PASSED - Provider switching is working correctly!") else: print("\n❌ Some tests failed - Review the implementation") - + return results if __name__ == "__main__": # Run the test suite - asyncio.run(run_all_tests()) \ No newline at end of file + asyncio.run(run_all_tests()) diff --git a/ui/package-lock.json b/ui/package-lock.json index 90b7ffc..8fe6b18 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", "@hey-api/client-fetch": "^0.10.0", @@ -23,6 +23,7 @@ "@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.0", "@sentry/nextjs": "^9.28.1", "@stackframe/stack": "^2.8.28", diff --git a/ui/package.json b/ui/package.json index 786388f..e2e08aa 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.0", "@sentry/nextjs": "^9.28.1", "@stackframe/stack": "^2.8.28", diff --git a/ui/public/embed/dograh-widget.js b/ui/public/embed/dograh-widget.js new file mode 100644 index 0000000..3d38c64 --- /dev/null +++ b/ui/public/embed/dograh-widget.js @@ -0,0 +1,951 @@ +/** + * Dograh Voice Widget + * Embeddable voice call widget for Dograh workflows + * Version: 1.0.0 + */ + +(function() { + 'use strict'; + + // Widget configuration defaults + const DEFAULT_CONFIG = { + position: 'bottom-right', + autoStart: false, + apiBaseUrl: window.location.hostname === 'localhost' + ? 'http://localhost:8000' + : 'https://api.dograh.com' + }; + + // Widget state + const state = { + config: {}, + isInitialized: false, + isOpen: false, + pc: null, + ws: null, + stream: null, + sessionToken: null, + workflowRunId: null, + connectionStatus: 'idle', // idle, connecting, connected, failed + audioElement: null, + callbacks: { + onReady: null, + onCallStart: null, + onCallEnd: null, + onError: null, + onStatusChange: null + } + }; + + /** + * Initialize the widget + */ + async function init() { + if (state.isInitialized) return; + + // Get token from script URL + const script = document.currentScript || document.querySelector('script[src*="dograh-widget.js"]'); + if (!script) { + console.error('Dograh Widget: Script not found'); + return; + } + + // Extract parameters from URL + const scriptUrl = new URL(script.src); + const token = scriptUrl.searchParams.get('token'); + const apiEndpoint = scriptUrl.searchParams.get('apiEndpoint'); + const environment = scriptUrl.searchParams.get('environment'); + + if (!token) { + console.error('Dograh Widget: No token found in script URL'); + return; + } + + // Determine API base URL + let apiBaseUrl = DEFAULT_CONFIG.apiBaseUrl; + if (apiEndpoint) { + // Use the apiEndpoint from URL parameter if provided + apiBaseUrl = apiEndpoint.replace(/\/+$/, ''); // Remove trailing slashes + } else if (scriptUrl.origin.includes('localhost')) { + apiBaseUrl = 'http://localhost:8000'; + } else { + apiBaseUrl = scriptUrl.origin.replace(/:\d+$/, ':8000'); + } + + // Store base configuration + state.config = { + ...DEFAULT_CONFIG, + token: token, + apiBaseUrl: apiBaseUrl, + environment: environment || 'production', + // Allow data attributes to override fetched config + contextVariables: parseContextVariables(script.getAttribute('data-dograh-context')) + }; + + try { + // Fetch configuration from API + const configResponse = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/config/${token}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Origin': window.location.origin + } + }); + + if (!configResponse.ok) { + throw new Error(`Failed to fetch config: ${configResponse.status}`); + } + + const configData = await configResponse.json(); + + // Merge fetched configuration with defaults + state.config = { + ...state.config, + workflowId: configData.workflow_id, + embedMode: configData.settings?.embedMode || 'floating', + containerId: configData.settings?.containerId || 'dograh-inline-container', + position: configData.position || DEFAULT_CONFIG.position, + autoStart: configData.auto_start || false + }; + } catch (error) { + console.error('Dograh Widget: Failed to fetch configuration', error); + return; + } + + state.isInitialized = true; + + // Load styles + injectStyles(); + + // Create widget UI based on mode + if (state.config.embedMode === 'inline') { + createInlineWidget(); + } else { + createFloatingWidget(); + } + + // Trigger ready callback + if (state.callbacks.onReady) { + state.callbacks.onReady(); + } + + // Auto-start if configured + if (state.config.autoStart) { + setTimeout(() => startCall(), 1000); + } + } + + /** + * Parse context variables from JSON string + */ + function parseContextVariables(contextStr) { + if (!contextStr) return {}; + try { + return JSON.parse(contextStr); + } catch (e) { + console.warn('Dograh Widget: Invalid context variables', e); + return {}; + } + } + + /** + * Inject widget styles + */ + function injectStyles() { + if (document.getElementById('dograh-widget-styles')) return; + + const styles = ` + .dograh-widget-container { + position: fixed; + z-index: 999999; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + } + + .dograh-widget-container.bottom-right { + bottom: 20px; + right: 20px; + } + + .dograh-widget-container.bottom-left { + bottom: 20px; + left: 20px; + } + + .dograh-widget-container.top-right { + top: 20px; + right: 20px; + } + + .dograh-widget-container.top-left { + top: 20px; + left: 20px; + } + + .dograh-widget-button { + color: white; + border: none; + border-radius: 50%; + width: 60px; + height: 60px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: all 0.3s ease; + } + + .dograh-widget-button:hover { + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); + } + + .dograh-widget-button:active { + transform: scale(0.95); + } + + /* Green button for idle/ready state */ + .dograh-widget-button-idle { + background: #10b981; + } + + .dograh-widget-button-idle:hover { + background: #059669; + } + + /* Orange button for connecting state */ + .dograh-widget-button-connecting { + background: #f59e0b; + animation: pulse 2s infinite; + } + + /* Red button for connected state (to end call) */ + .dograh-widget-button-connected { + background: #ef4444; + } + + .dograh-widget-button-connected:hover { + background: #dc2626; + } + + /* Red button for failed state */ + .dograh-widget-button-failed { + background: #ef4444; + opacity: 0.8; + } + + @keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + } + + `; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'dograh-widget-styles'; + styleSheet.textContent = styles; + document.head.appendChild(styleSheet); + } + + /** + * Create floating widget UI (simplified - no modal) + */ + function createFloatingWidget() { + // Create container + const container = document.createElement('div'); + container.className = `dograh-widget-container ${state.config.position}`; + container.id = 'dograh-widget'; + + // Create button (green to start, red to end) + const button = document.createElement('button'); + button.className = 'dograh-widget-button dograh-widget-button-idle'; + button.id = 'dograh-widget-button'; + button.innerHTML = ` + + + + `; + button.onclick = toggleCall; + + // Create hidden audio element + const audio = document.createElement('audio'); + audio.id = 'dograh-widget-audio'; + audio.autoplay = true; + audio.style.display = 'none'; + + // Append elements + container.appendChild(button); + container.appendChild(audio); + document.body.appendChild(container); + + // Store audio element reference + state.audioElement = audio; + } + + /** + * Toggle call (start or stop based on current state) + */ + function toggleCall() { + if (state.connectionStatus === 'idle' || state.connectionStatus === 'failed') { + startCall(); + } else { + stopCall(); + } + } + + /** + * Update floating widget button appearance + */ + function updateFloatingButton(status) { + const button = document.getElementById('dograh-widget-button'); + if (!button) return; + + // Remove all status classes + button.classList.remove('dograh-widget-button-idle', 'dograh-widget-button-connecting', 'dograh-widget-button-connected', 'dograh-widget-button-failed'); + + // Add current status class + button.classList.add(`dograh-widget-button-${status}`); + + // Update title attribute for tooltip + const titles = { + idle: 'Start Call', + connecting: 'Connecting...', + connected: 'End Call', + failed: 'Retry Call' + }; + button.title = titles[status] || 'Voice Call'; + } + + /** + * Create inline widget UI + */ + function createInlineWidget() { + // Find container element + const container = document.getElementById(state.config.containerId); + if (!container) { + console.error(`Dograh Widget: Container element with id "${state.config.containerId}" not found`); + if (state.callbacks.onError) { + state.callbacks.onError(new Error('Container element not found')); + } + return; + } + + // Clear container + container.innerHTML = ''; + container.className = 'dograh-inline-container'; + + // Add minimal inline styles + const inlineStyles = ` + .dograh-inline-container { + min-height: 200px; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + } + + .dograh-inline-status { + text-align: center; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + .dograh-inline-status-icon { + width: 64px; + height: 64px; + margin: 0 auto 20px; + } + + .dograh-inline-status-text { + font-size: 18px; + font-weight: 500; + margin: 0 0 8px; + color: #111827; + } + + .dograh-inline-status-subtext { + font-size: 14px; + color: #6b7280; + margin: 0 0 20px; + } + + .dograh-inline-button-container { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 20px; + } + + .dograh-inline-btn { + padding: 12px 32px; + border-radius: 8px; + border: none; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + color: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + + .dograh-inline-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + + .dograh-inline-btn:active { + transform: translateY(0); + } + + .dograh-inline-btn-start { + background: #10b981; + } + + .dograh-inline-btn-start:hover { + background: #059669; + } + + .dograh-inline-btn-end { + background: #ef4444; + } + + .dograh-inline-btn-end:hover { + background: #dc2626; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .dograh-inline-pulse { + animation: pulse 2s infinite; + } + `; + + // Add inline styles if not already added + if (!document.getElementById('dograh-inline-styles')) { + const styleSheet = document.createElement('style'); + styleSheet.id = 'dograh-inline-styles'; + styleSheet.textContent = inlineStyles; + document.head.appendChild(styleSheet); + } + + // Create initial status display + updateInlineStatus('idle'); + + // Store audio element (hidden) + state.audioElement = document.createElement('audio'); + state.audioElement.autoplay = true; + state.audioElement.style.display = 'none'; + container.appendChild(state.audioElement); + + // Mark widget as open (for inline mode, it's always "open") + state.isOpen = true; + } + + /** + * Update inline widget status + */ + function updateInlineStatus(status, text, subtext) { + const container = document.getElementById(state.config.containerId); + if (!container) return; + + // Update state + state.connectionStatus = status; + + // Determine display text + const displayText = text || { + idle: 'Ready to Connect', + connecting: 'Connecting...', + connected: 'Call Active', + failed: 'Connection Failed' + }[status]; + + const displaySubtext = subtext || { + idle: 'Click to start voice conversation', + connecting: 'Please wait while we establish connection', + connected: 'You can speak now', + failed: 'Please check your microphone and try again' + }[status]; + + // Simple button design: green to start, red to end + let buttonHTML = ''; + if (status === 'idle' || status === 'failed') { + // Green button to start + buttonHTML = ` + + `; + } else if (status === 'connecting' || status === 'connected') { + // Red button to end + buttonHTML = ` + + `; + } + + // Update container content (preserve audio element) + const audioElement = state.audioElement; + container.innerHTML = ` +
+
+ ${getStatusIcon(status)} +
+

${displayText}

+

${displaySubtext}

+
+ ${buttonHTML} +
+
+ `; + + // Re-append audio element + if (audioElement) { + container.appendChild(audioElement); + } + + // Attach event handlers + const startBtn = document.getElementById('dograh-inline-start-btn'); + if (startBtn) startBtn.onclick = startCall; + + const endBtn = document.getElementById('dograh-inline-end-btn'); + if (endBtn) endBtn.onclick = stopCall; + + // Trigger status change callback + if (state.callbacks.onStatusChange) { + state.callbacks.onStatusChange(status, displayText, displaySubtext); + } + } + + /** + * Get status icon SVG + */ + function getStatusIcon(status) { + const icons = { + idle: ` + + + + + `, + connecting: ` + + + + + + + + + `, + connected: ` + + + + `, + failed: ` + + + + ` + }; + return icons[status] || icons.idle; + } + + /** + * Update widget status + */ + function updateStatus(status, text, subtext) { + state.connectionStatus = status; + + // Use appropriate update function based on mode + if (state.config.embedMode === 'inline') { + updateInlineStatus(status, text, subtext); + } else { + updateFloatingButton(status); + } + } + + /** + * Open widget (deprecated - kept for backwards compatibility) + */ + function openWidget() { + // No-op since we removed the modal + } + + /** + * Close widget (deprecated - kept for backwards compatibility) + */ + function closeWidget() { + // Stop call if active + if (state.connectionStatus === 'connected' || state.connectionStatus === 'connecting') { + stopCall(); + } + } + + /** + * Start voice call + */ + async function startCall() { + updateStatus('connecting', 'Connecting...', 'Please wait while we establish the connection'); + + // Trigger call start callback + if (state.callbacks.onCallStart) { + state.callbacks.onCallStart(); + } + + try { + // Initialize session if using embed token + if (state.config.token) { + await initializeEmbedSession(); + } else { + // Direct mode with workflow and run IDs + state.sessionToken = 'direct-mode'; + state.workflowRunId = state.config.runId; + } + + // Request microphone permission + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + state.stream = stream; + } catch (micError) { + // Handle specific microphone permission errors + let errorMessage = 'Please check your microphone and try again'; + + if (micError.name === 'NotAllowedError' || micError.name === 'PermissionDeniedError') { + errorMessage = 'Microphone permission denied. Please allow microphone access to start the call.'; + } else if (micError.name === 'NotFoundError' || micError.name === 'DevicesNotFoundError') { + errorMessage = 'No microphone found. Please connect a microphone and try again.'; + } else if (micError.name === 'NotReadableError' || micError.name === 'TrackStartError') { + errorMessage = 'Microphone is already in use by another application.'; + } + + throw new Error(errorMessage); + } + + // Create WebRTC connection + await createWebRTCConnection(); + + // Connect WebSocket + await connectWebSocket(); + + // Start negotiation + await negotiate(); + + } catch (error) { + console.error('Dograh Widget: Failed to start call', error); + updateStatus('failed', 'Connection failed', error.message || 'Please check your microphone and try again'); + + // Trigger error callback + if (state.callbacks.onError) { + state.callbacks.onError(error); + } + } + } + + /** + * Initialize embed session + */ + async function initializeEmbedSession() { + const response = await fetch(`${state.config.apiBaseUrl}/api/v1/public/embed/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Origin': window.location.origin + }, + body: JSON.stringify({ + token: state.config.token, + context_variables: state.config.contextVariables + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to initialize session'); + } + + const data = await response.json(); + state.sessionToken = data.session_token; + state.workflowRunId = data.workflow_run_id; + state.workflowId = data.config.workflow_id; + } + + /** + * Create WebRTC peer connection + */ + function createWebRTCConnection() { + const config = { + iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] + }; + + state.pc = new RTCPeerConnection(config); + + // Add audio track + if (state.stream) { + state.stream.getTracks().forEach(track => { + state.pc.addTrack(track, state.stream); + }); + } + + // Handle incoming audio + state.pc.ontrack = (event) => { + if (event.track.kind === 'audio' && state.audioElement) { + state.audioElement.srcObject = event.streams[0]; + } + }; + + // Monitor connection state + state.pc.oniceconnectionstatechange = () => { + console.log('ICE connection state:', state.pc.iceConnectionState); + + if (state.pc.iceConnectionState === 'connected' || state.pc.iceConnectionState === 'completed') { + updateStatus('connected', 'Connected', 'Your voice call is now active'); + } else if (state.pc.iceConnectionState === 'failed' || state.pc.iceConnectionState === 'disconnected') { + updateStatus('failed', 'Connection lost', 'The call has been disconnected'); + stopCall(); + } + }; + + // Handle ICE candidates for trickling + state.pc.onicecandidate = (event) => { + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + const message = { + type: 'ice-candidate', + payload: { + candidate: event.candidate ? { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + } : null, + pc_id: state.pcId + } + }; + state.ws.send(JSON.stringify(message)); + } + }; + } + + /** + * Connect WebSocket for signaling + */ + async function connectWebSocket() { + return new Promise((resolve, reject) => { + // Use public signaling endpoint for embed tokens + const wsUrl = `${state.config.apiBaseUrl.replace('http', 'ws')}/api/v1/ws/public/signaling/${state.sessionToken}`; + + state.ws = new WebSocket(wsUrl); + state.pcId = generatePeerId(); + + state.ws.onopen = () => { + console.log('WebSocket connected'); + resolve(); + }; + + state.ws.onerror = (error) => { + console.error('WebSocket error:', error); + reject(error); + }; + + state.ws.onclose = () => { + console.log('WebSocket closed'); + if (state.connectionStatus === 'connected') { + updateStatus('failed', 'Connection lost', 'The call has been disconnected'); + } + }; + + state.ws.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + await handleWebSocketMessage(message); + } catch (e) { + console.error('Failed to handle WebSocket message:', e); + } + }; + }); + } + + /** + * Handle WebSocket messages + */ + async function handleWebSocketMessage(message) { + switch (message.type) { + case 'answer': + const answer = message.payload; + console.log('Received answer from server'); + + await state.pc.setRemoteDescription({ + type: 'answer', + sdp: answer.sdp + }); + break; + + case 'ice-candidate': + const candidate = message.payload.candidate; + if (candidate) { + try { + await state.pc.addIceCandidate({ + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex + }); + console.log('Added remote ICE candidate'); + } catch (e) { + console.error('Failed to add ICE candidate:', e); + } + } + break; + + case 'error': + console.error('Server error:', message.payload); + updateStatus('failed', 'Server error', message.payload.message || 'An error occurred'); + break; + + default: + console.warn('Unknown message type:', message.type); + } + } + + /** + * Negotiate WebRTC connection + */ + async function negotiate() { + const offer = await state.pc.createOffer(); + await state.pc.setLocalDescription(offer); + + const message = { + type: 'offer', + payload: { + sdp: offer.sdp, + type: 'offer', + pc_id: state.pcId, + workflow_id: parseInt(state.config.workflowId), + workflow_run_id: parseInt(state.workflowRunId), + call_context_vars: state.config.contextVariables || {} + } + }; + + state.ws.send(JSON.stringify(message)); + console.log('Sent offer via WebSocket'); + } + + /** + * Stop voice call + */ + function stopCall() { + updateStatus('idle', 'Call ended', 'Click below to start a new call'); + + // Trigger call end callback + if (state.callbacks.onCallEnd) { + state.callbacks.onCallEnd(); + } + + // Close WebSocket + if (state.ws) { + state.ws.close(); + state.ws = null; + } + + // Stop media tracks + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + + // Close peer connection + if (state.pc) { + state.pc.close(); + state.pc = null; + } + + // Clear audio + if (state.audioElement) { + state.audioElement.srcObject = null; + } + } + + /** + * Retry connection + */ + function retryCall() { + updateStatus('idle', 'Ready to start', 'Click below to begin your voice call'); + setTimeout(() => startCall(), 500); + } + + /** + * Generate unique peer ID + */ + function generatePeerId() { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return 'PC-' + Array.from(array) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + // Public API + window.DograhWidget = { + // Core methods + init: init, + start: startCall, + stop: stopCall, + end: stopCall, // Alias for stop + retry: retryCall, + + // Floating widget specific + open: openWidget, + close: closeWidget, + + // State and callbacks + getState: () => state, + onReady: (callback) => { state.callbacks.onReady = callback; }, + onCallStart: (callback) => { state.callbacks.onCallStart = callback; }, + onCallEnd: (callback) => { state.callbacks.onCallEnd = callback; }, + onError: (callback) => { state.callbacks.onError = callback; }, + onStatusChange: (callback) => { state.callbacks.onStatusChange = callback; }, + + // Check if inline mode + isInlineMode: () => state.config.embedMode === 'inline', + + // Re-render the inline widget (useful when React component remounts) + refresh: () => { + if (state.config.embedMode === 'inline') { + // Re-render inline widget with current status + updateInlineStatus(state.connectionStatus); + } + }, + + // Initialize inline mode manually (for advanced use cases) + initInline: (options) => { + if (options.container) { + state.config.containerId = options.container.id || 'dograh-inline-container'; + } + state.config.embedMode = 'inline'; + + // Set callbacks if provided + if (options.onReady) state.callbacks.onReady = options.onReady; + if (options.onCallStart) state.callbacks.onCallStart = options.onCallStart; + if (options.onCallEnd) state.callbacks.onCallEnd = options.onCallEnd; + if (options.onError) state.callbacks.onError = options.onError; + if (options.onStatusChange) state.callbacks.onStatusChange = options.onStatusChange; + + // Initialize + if (!state.isInitialized) { + init(); + } + } + }; + + // Auto-initialize on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index d2773d9..2d8fc1c 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -7,7 +7,7 @@ import { Panel, ReactFlow, } from "@xyflow/react"; -import { BrushCleaning, Maximize2, Minus, Plus, Settings, Variable } from 'lucide-react'; +import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react'; import React, { useMemo, useState } from 'react'; import WorkflowLayout from '@/app/workflow/WorkflowLayout'; @@ -20,6 +20,7 @@ import AddNodePanel from "../../../components/flow/AddNodePanel"; import CustomEdge from "../../../components/flow/edges/CustomEdge"; import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes"; import { ConfigurationsDialog } from './components/ConfigurationsDialog'; +import { EmbedDialog } from './components/EmbedDialog'; import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog'; import WorkflowHeader from "./components/WorkflowHeader"; import { WorkflowTabs } from './components/WorkflowTabs'; @@ -76,6 +77,7 @@ interface RenderWorkflowProps { function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: RenderWorkflowProps) { const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false); const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false); + const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false); const { rfInstance, @@ -218,6 +220,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT

Template Context Variables

+ + + + + + +

Deploy Workflow

+
+
@@ -317,6 +335,14 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT templateContextVariables={templateContextVariables} onSave={saveTemplateContextVariables} /> + + ); diff --git a/ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx b/ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx new file mode 100644 index 0000000..3196331 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx @@ -0,0 +1,507 @@ +import { Check, Copy, Loader2, Plus, Rocket, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { client } from "@/client/client.gen"; +import { + createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost, + deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete, + getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet, +} from "@/client/sdk.gen"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; + +interface EmbedDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workflowId: number; + workflowName: string; + getAccessToken: () => Promise; +} + +interface EmbedToken { + id: number; + token: string; + allowed_domains: string[] | null; + settings: Record | null; + is_active: boolean; + usage_count: number; + usage_limit: number | null; + expires_at: string | null; + created_at: string; + embed_script: string; +} + +export function EmbedDialog({ + open, + onOpenChange, + workflowId, + workflowName, + getAccessToken, +}: EmbedDialogProps) { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [embedToken, setEmbedToken] = useState(null); + const [copied, setCopied] = useState(false); + + // Form state + const [isEnabled, setIsEnabled] = useState(false); + const [domains, setDomains] = useState([]); + const [newDomain, setNewDomain] = useState(""); + const [embedMode, setEmbedMode] = useState<"floating" | "inline">("floating"); + const [position, setPosition] = useState("bottom-right"); + const [buttonText, setButtonText] = useState("Start Voice Call"); + const [buttonColor, setButtonColor] = useState("#3B82F6"); + + const loadEmbedToken = useCallback(async () => { + setLoading(true); + try { + const token = await getAccessToken(); + client.setConfig({ + baseUrl: window.location.origin.replace(/:\d+$/, ':8000'), + headers: { Authorization: `Bearer ${token}` }, + }); + + const response = await getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet({ + path: { workflow_id: workflowId }, + }); + + if (response.data) { + setEmbedToken(response.data as EmbedToken); + setIsEnabled(response.data.is_active); + + // Load settings + if (response.data.settings) { + const settings = response.data.settings as Record; + setEmbedMode((settings.embedMode as "floating" | "inline") || "floating"); + setPosition(settings.position || "bottom-right"); + setButtonText(settings.buttonText || "Start Voice Call"); + setButtonColor(settings.buttonColor || "#3B82F6"); + } + + // Load domains + if (response.data.allowed_domains) { + setDomains(response.data.allowed_domains); + } + } + } catch (error) { + console.error("Failed to load embed token:", error); + } finally { + setLoading(false); + } + }, [workflowId, getAccessToken]); + + useEffect(() => { + if (open) { + loadEmbedToken(); + } + }, [open, loadEmbedToken]); + + const handleSave = async () => { + setSaving(true); + try { + const token = await getAccessToken(); + client.setConfig({ + baseUrl: window.location.origin.replace(/:\d+$/, ':8000'), + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!isEnabled && embedToken) { + // Deactivate token + await deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete({ + path: { workflow_id: workflowId }, + }); + setEmbedToken(null); + } else if (isEnabled) { + // Create or update token + const response = await createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost({ + path: { workflow_id: workflowId }, + body: { + allowed_domains: domains.length > 0 ? domains : null, + settings: { + embedMode, + position, + buttonText, + buttonColor, + size: "medium", + autoStart: false, + containerId: embedMode === "inline" ? "dograh-inline-container" : undefined, + }, + usage_limit: null, + expires_in_days: null, + }, + }); + + if (response.data) { + setEmbedToken(response.data as EmbedToken); + } + } + + // Don't close modal after saving - let user copy the embed code + } catch (error) { + console.error("Failed to save embed token:", error); + } finally { + setSaving(false); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const addDomain = () => { + if (newDomain.trim() && !domains.includes(newDomain.trim())) { + setDomains([...domains, newDomain.trim()]); + setNewDomain(""); + } + }; + + const removeDomain = (domain: string) => { + setDomains(domains.filter(d => d !== domain)); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + addDomain(); + } + }; + + return ( + + + + + + Deploy Workflow + + + Embed "{workflowName}" on any website with a simple script tag + + + + {loading ? ( +
+ +
+ ) : ( +
+ {/* Enable/Disable Toggle */} +
+
+ +

+ Allow this workflow to be embedded on external websites +

+
+ +
+ + {isEnabled && ( + <> + + + {/* Allowed Domains */} +
+ + + {/* Domain Input */} +
+ setNewDomain(e.target.value)} + onKeyPress={handleKeyPress} + /> + +
+ + {/* Domain List */} + {domains.length > 0 && ( +
+ {domains.map((domain, index) => ( +
+ {domain} + +
+ ))} +
+ )} +
+ + {/* Embed Mode Selection */} +
+ +
+ + +
+
+ + {/* Configuration based on mode */} +
+ + + {embedMode === "floating" ? ( + <> +
+
+ + +
+ +
+ +
+ setButtonColor(e.target.value)} + className="w-14 h-10 cursor-pointer" + /> + setButtonColor(e.target.value)} + placeholder="#3B82F6" + className="flex-1" + /> +
+
+
+ +
+ + setButtonText(e.target.value)} + placeholder="Start Voice Call" + /> +
+ + ) : ( + <> +
+
+

Integration Instructions

+
    +
  • • Add a div with id="dograh-inline-container" where you want the widget
  • +
  • • The widget will render inside this container
  • +
  • • You have full control over the container's styling
  • +
  • • Call window.DograhWidget.start() to begin the call
  • +
  • • Call window.DograhWidget.end() to end the call
  • +
+
+ +
+

Example React Component

+
+                                                        {`export function DograhAgent() {
+  const [isCallActive, setIsCallActive] = useState(false);
+
+  useEffect(() => {
+    // Widget will auto-initialize when script loads
+    window.DograhWidget?.onCallStart(() => {
+      setIsCallActive(true);
+    });
+    window.DograhWidget?.onCallEnd(() => {
+      setIsCallActive(false);
+    });
+  }, []);
+
+  return (
+    
+

Talk to Our Agent

+
+ {/* Widget renders here */} +
+ +
+ ); +}`}
+
+
+
+ + )} + + {/* Preview for floating mode only */} + {embedMode === "floating" && ( +
+ +
+ )} +
+ + + + {/* Save Button */} +
+ +
+ + {/* Embed Script (shows after saving) */} + {embedToken && embedToken.is_active && ( + <> + +
+
+ + +
+
+
+                                                    {embedToken.embed_script}
+                                                
+
+

+ Add this script to your website's HTML to enable the voice widget. + Configuration changes will apply automatically without re-embedding. +

+
+ + )} + + )} +
+ )} +
+
+ ); +} diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx index ff21926..d26031c 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx @@ -54,42 +54,41 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); // Load disposition codes from workflow configuration - useEffect(() => { - const loadDispositionCodes = async () => { - if (!accessToken) return; - try { - const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({ - path: { workflow_id: Number(workflowId) }, - headers: { 'Authorization': `Bearer ${accessToken}` } - }); + const loadDispositionCodes = useCallback(async () => { + if (!accessToken) return; + try { + const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({ + path: { workflow_id: Number(workflowId) }, + headers: { 'Authorization': `Bearer ${accessToken}` } + }); - const workflow = response.data; - if (workflow?.call_disposition_codes) { - // Update the disposition code attribute with actual options - const updatedAttributes = configuredAttributes.map(attr => { - if (attr.id === 'dispositionCode') { - return { - ...attr, - config: { - ...attr.config, - options: Object.keys(workflow.call_disposition_codes || {}).length > 0 - ? Object.keys(workflow.call_disposition_codes || {}) - : [...DISPOSITION_CODES] - } - }; - } - return attr; - }); - setConfiguredAttributes(updatedAttributes); - } - } catch (err) { - console.error("Failed to load disposition codes:", err); + const workflow = response.data; + if (workflow?.call_disposition_codes) { + // Update the disposition code attribute with actual options + setConfiguredAttributes(prev => prev.map(attr => { + if (attr.id === 'dispositionCode') { + return { + ...attr, + config: { + ...attr.config, + options: Object.keys(workflow.call_disposition_codes || {}).length > 0 + ? Object.keys(workflow.call_disposition_codes || {}) + : [...DISPOSITION_CODES] + } + }; + } + return attr; + })); } - }; - - loadDispositionCodes(); + } catch (err) { + console.error("Failed to load disposition codes:", err); + } }, [workflowId, accessToken]); + useEffect(() => { + loadDispositionCodes(); + }, [loadDispositionCodes]); + const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => { if (!accessToken) return; try { diff --git a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts index 6b7ca0c..e7f2b1b 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -150,7 +150,7 @@ export const useWorkflowState = ({ initialTemplateContextVariables, initialWorkflowConfigurations ); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); // Set up keyboard shortcuts for undo/redo useEffect(() => { @@ -418,7 +418,7 @@ export const useWorkflowState = ({ // Validate workflow on mount useEffect(() => { validateWorkflow(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); return { rfInstance, diff --git a/ui/src/app/workflow/[workflowId]/page.tsx b/ui/src/app/workflow/[workflowId]/page.tsx index 3f24ca3..f436da5 100644 --- a/ui/src/app/workflow/[workflowId]/page.tsx +++ b/ui/src/app/workflow/[workflowId]/page.tsx @@ -65,7 +65,7 @@ export default function WorkflowDetailPage() { const stickyTabs = workflow ? : null; // Memoize user and getAccessToken to prevent unnecessary re-renders - const stableUser = useMemo(() => user, [user?.id]); + const stableUser = useMemo(() => user, [user]); const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]); if (loading) { diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index b511193..11c5cc9 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; import { client as _heyApiClient } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, OfferApiV1PipecatRtcOfferPostData, OfferApiV1PipecatRtcOfferPostError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, OfferApiV1PipecatRtcOfferPostData, OfferApiV1PipecatRtcOfferPostError, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -939,6 +939,102 @@ export const getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet = (options?: Options) => { + return (options?.client ?? _heyApiClient).options({ + url: '/api/v1/public/embed/init', + ...options + }); +}; + +/** + * Initialize Embed Session + * Initialize an embed session with token validation and domain checking. + * + * This endpoint: + * 1. Validates the embed token + * 2. Checks domain whitelist + * 3. Creates a workflow run + * 4. Generates a temporary session token + * 5. Returns configuration for the widget + */ +export const initializeEmbedSessionApiV1PublicEmbedInitPost = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/public/embed/init', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Get Embed Config + * Get embed configuration without creating a session. + * + * This endpoint is used to fetch widget configuration for display purposes + * without actually starting a call session. + */ +export const getEmbedConfigApiV1PublicEmbedConfigTokenGet = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/api/v1/public/embed/config/{token}', + ...options + }); +}; + +/** + * Options Config + * Handle CORS preflight for config endpoint + */ +export const optionsConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => { + return (options.client ?? _heyApiClient).options({ + url: '/api/v1/public/embed/config/{token}', + ...options + }); +}; + +/** + * Deactivate Embed Token + * Deactivate the embed token for a workflow. + */ +export const deactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDelete = (options: Options) => { + return (options.client ?? _heyApiClient).delete({ + url: '/api/v1/workflow/{workflow_id}/embed-token', + ...options + }); +}; + +/** + * Get Embed Token + * Get the embed token for a workflow if it exists. + */ +export const getEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGet = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/api/v1/workflow/{workflow_id}/embed-token', + ...options + }); +}; + +/** + * Create Or Update Embed Token + * Create or update an embed token for a workflow. + * Each workflow can have only one active embed token. + */ +export const createOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPost = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/workflow/{workflow_id}/embed-token', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + /** * Health */ diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index c0eb462..649a8c5 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -234,6 +234,44 @@ export type DuplicateTemplateRequest = { workflow_name: string; }; +/** + * Response model for embed configuration + */ +export type EmbedConfigResponse = { + workflow_id: number; + settings: { + [key: string]: unknown; + }; + theme: string; + position: string; + button_text: string; + button_color: string; +}; + +export type EmbedTokenRequest = { + allowed_domains?: Array | null; + settings?: { + [key: string]: unknown; + } | null; + usage_limit?: number | null; + expires_in_days?: number | null; +}; + +export type EmbedTokenResponse = { + id: number; + token: string; + allowed_domains: Array | null; + settings: { + [key: string]: unknown; + } | null; + is_active: boolean; + usage_count: number; + usage_limit: number | null; + expires_at: string | null; + created_at: string; + embed_script: string; +}; + export type FileMetadataResponse = { key: string; metadata: { @@ -261,6 +299,27 @@ export type ImpersonateResponse = { access_token: string; }; +/** + * Request model for initializing an embed session + */ +export type InitEmbedRequest = { + token: string; + context_variables?: { + [key: string]: unknown; + } | null; +}; + +/** + * Response model for embed initialization + */ +export type InitEmbedResponse = { + session_token: string; + workflow_run_id: number; + config: { + [key: string]: unknown; + }; +}; + export type InitiateCallRequest = { workflow_id: number; workflow_run_id?: number | null; @@ -2888,6 +2947,220 @@ export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses = { export type GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse = GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses[keyof GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponses]; +export type OptionsInitApiV1PublicEmbedInitOptionsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/public/embed/init'; +}; + +export type OptionsInitApiV1PublicEmbedInitOptionsErrors = { + /** + * Not found + */ + 404: unknown; +}; + +export type OptionsInitApiV1PublicEmbedInitOptionsResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type InitializeEmbedSessionApiV1PublicEmbedInitPostData = { + body: InitEmbedRequest; + path?: never; + query?: never; + url: '/api/v1/public/embed/init'; +}; + +export type InitializeEmbedSessionApiV1PublicEmbedInitPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type InitializeEmbedSessionApiV1PublicEmbedInitPostError = InitializeEmbedSessionApiV1PublicEmbedInitPostErrors[keyof InitializeEmbedSessionApiV1PublicEmbedInitPostErrors]; + +export type InitializeEmbedSessionApiV1PublicEmbedInitPostResponses = { + /** + * Successful Response + */ + 200: InitEmbedResponse; +}; + +export type InitializeEmbedSessionApiV1PublicEmbedInitPostResponse = InitializeEmbedSessionApiV1PublicEmbedInitPostResponses[keyof InitializeEmbedSessionApiV1PublicEmbedInitPostResponses]; + +export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetData = { + body?: never; + path: { + token: string; + }; + query?: never; + url: '/api/v1/public/embed/config/{token}'; +}; + +export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetError = GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetErrors]; + +export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses = { + /** + * Successful Response + */ + 200: EmbedConfigResponse; +}; + +export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse = GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses]; + +export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = { + body?: never; + path: { + token: string; + }; + query?: never; + url: '/api/v1/public/embed/config/{token}'; +}; + +export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors]; + +export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData = { + body?: never; + headers?: { + authorization?: string | null; + }; + path: { + workflow_id: number; + }; + query?: never; + url: '/api/v1/workflow/{workflow_id}/embed-token'; +}; + +export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError = DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors[keyof DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteErrors]; + +export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses = { + /** + * Successful Response + */ + 200: { + [key: string]: unknown; + }; +}; + +export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse = DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses[keyof DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponses]; + +export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData = { + body?: never; + headers?: { + authorization?: string | null; + }; + path: { + workflow_id: number; + }; + query?: never; + url: '/api/v1/workflow/{workflow_id}/embed-token'; +}; + +export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError = GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors[keyof GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetErrors]; + +export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses = { + /** + * Successful Response + */ + 200: EmbedTokenResponse | null; +}; + +export type GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse = GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses[keyof GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponses]; + +export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData = { + body: EmbedTokenRequest; + headers?: { + authorization?: string | null; + }; + path: { + workflow_id: number; + }; + query?: never; + url: '/api/v1/workflow/{workflow_id}/embed-token'; +}; + +export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostErrors]; + +export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses = { + /** + * Successful Response + */ + 200: EmbedTokenResponse; +}; + +export type CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse = CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses[keyof CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponses]; + export type HealthApiV1HealthGetData = { body?: never; path?: never; diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx new file mode 100644 index 0000000..cfaa4c1 --- /dev/null +++ b/ui/src/components/ui/tabs.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as TabsPrimitive from "@radix-ui/react-tabs" +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsContent,TabsList, TabsTrigger } diff --git a/ui/src/context/UserConfigContext.tsx b/ui/src/context/UserConfigContext.tsx index f403bb9..3f1a004 100644 --- a/ui/src/context/UserConfigContext.tsx +++ b/ui/src/context/UserConfigContext.tsx @@ -80,8 +80,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) { if (!auth.loading) { fetchPermissions(); } - // We intentionally depend only on specific auth properties to avoid infinite loops - // eslint-disable-next-line react-hooks/exhaustive-deps }, [auth.loading, auth.provider, auth.getSelectedTeam, auth.listPermissions]); @@ -152,8 +150,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - // We intentionally depend only on specific auth properties to avoid infinite loops - // eslint-disable-next-line react-hooks/exhaustive-deps }, [auth.loading, auth.isAuthenticated, auth.getAccessToken]); useEffect(() => {