feat: enable workflows to be embedded in websites as a script tag (#47)

* feat: add deployment configuration options

* Simplify EmbedDialog

* Add options for inline vs floating embedding of agent
This commit is contained in:
Abhishek 2025-11-15 17:32:37 +05:30 committed by GitHub
parent 5e4aef346d
commit 99a768f291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3551 additions and 645 deletions

View file

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

View file

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

View file

@ -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")
print("Downgrade complete: Reverted configuration key names and enum")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

265
api/routes/public_embed.py Normal file
View file

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

View file

@ -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"}
return {"status": "ok"}

View file

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

View file

@ -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"""<!-- Dograh Voice Widget -->
<script>
(function(d, s, id) {{
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src = '{base_url}/embed/dograh-widget.js?token={token.token}&environment={ENVIRONMENT}&apiEndpoint={BACKEND_API_ENDPOINT}';
js.async = true;
fjs.parentNode.insertBefore(js, fjs);
}}(document, 'script', 'dograh-widget'));
</script>"""
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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
# Telephony provider implementations
# Telephony provider implementations

View file

@ -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"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
@ -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
raise

View file

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

View file

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

View file

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

View file

@ -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())
asyncio.run(run_all_tests())

5
ui/package-lock.json generated
View file

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

View file

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

View file

@ -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 = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
`;
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 = `
<button class="dograh-inline-btn dograh-inline-btn-start" id="dograh-inline-start-btn">
${status === 'failed' ? 'Retry' : 'Start Call'}
</button>
`;
} else if (status === 'connecting' || status === 'connected') {
// Red button to end
buttonHTML = `
<button class="dograh-inline-btn dograh-inline-btn-end" id="dograh-inline-end-btn">
End Call
</button>
`;
}
// Update container content (preserve audio element)
const audioElement = state.audioElement;
container.innerHTML = `
<div class="dograh-inline-status">
<div class="dograh-inline-status-icon ${status === 'connecting' ? 'dograh-inline-pulse' : ''}">
${getStatusIcon(status)}
</div>
<p class="dograh-inline-status-text">${displayText}</p>
<p class="dograh-inline-status-subtext">${displaySubtext}</p>
<div class="dograh-inline-button-container">
${buttonHTML}
</div>
</div>
`;
// 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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>`,
connecting: `<svg class="dograh-widget-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4"/>
<path d="M12 18v4"/>
<path d="M4.93 4.93l2.83 2.83"/>
<path d="M16.24 16.24l2.83 2.83"/>
<path d="M2 12h4"/>
<path d="M18 12h4"/>
<path d="M4.93 19.07l2.83-2.83"/>
<path d="M16.24 7.76l2.83-2.83"/>
</svg>`,
connected: `<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72"/>
<path d="M15 7a2 2 0 0 1 2 2"/>
<path d="M15 3a6 6 0 0 1 6 6"/>
</svg>`,
failed: `<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>`
};
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();
}
})();

View file

@ -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
<p>Template Context Variables</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsEmbedDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Rocket className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Deploy Workflow</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
@ -317,6 +335,14 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}
workflowId={workflowId}
workflowName={workflowName}
getAccessToken={getAccessToken}
/>
</WorkflowLayout>
</WorkflowProvider>
);

View file

@ -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<string>;
}
interface EmbedToken {
id: number;
token: string;
allowed_domains: string[] | null;
settings: Record<string, unknown> | 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<EmbedToken | null>(null);
const [copied, setCopied] = useState(false);
// Form state
const [isEnabled, setIsEnabled] = useState(false);
const [domains, setDomains] = useState<string[]>([]);
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<string, string>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Deploy Workflow
</DialogTitle>
<DialogDescription>
Embed &quot;{workflowName}&quot; on any website with a simple script tag
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
</div>
) : (
<div className="space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="embed-enabled">Enable Embedding</Label>
<p className="text-sm text-muted-foreground">
Allow this workflow to be embedded on external websites
</p>
</div>
<Switch
id="embed-enabled"
checked={isEnabled}
onCheckedChange={setIsEnabled}
/>
</div>
{isEnabled && (
<>
<Separator />
{/* Allowed Domains */}
<div className="space-y-3">
<Label>
Allowed Domains
<span className="text-xs text-muted-foreground ml-2">
(leave empty to allow all domains)
</span>
</Label>
{/* Domain Input */}
<div className="flex gap-2">
<Input
placeholder="example.com or *.example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyPress={handleKeyPress}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={addDomain}
disabled={!newDomain.trim()}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Domain List */}
{domains.length > 0 && (
<div className="space-y-2">
{domains.map((domain, index) => (
<div
key={index}
className="flex items-center justify-between bg-muted/50 rounded-lg px-3 py-2"
>
<span className="text-sm font-mono">{domain}</span>
<Button
type="button"
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={() => removeDomain(domain)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Embed Mode Selection */}
<div className="space-y-4">
<Label>Embed Mode</Label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setEmbedMode("floating")}
className={`p-4 rounded-lg border-2 transition-all ${
embedMode === "floating"
? "border-primary bg-primary/5"
: "border-muted hover:border-muted-foreground/20"
}`}
>
<div className="space-y-2">
<div className="font-medium">Floating Widget</div>
<div className="text-xs text-muted-foreground">
Shows as a button in corner of the page
</div>
</div>
</button>
<button
type="button"
onClick={() => setEmbedMode("inline")}
className={`p-4 rounded-lg border-2 transition-all ${
embedMode === "inline"
? "border-primary bg-primary/5"
: "border-muted hover:border-muted-foreground/20"
}`}
>
<div className="space-y-2">
<div className="font-medium">Inline Component</div>
<div className="text-xs text-muted-foreground">
Embeds directly in your page content
</div>
</div>
</button>
</div>
</div>
{/* Configuration based on mode */}
<div className="space-y-4">
<Label>Configuration</Label>
{embedMode === "floating" ? (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="position" className="text-sm">Position</Label>
<Select value={position} onValueChange={setPosition}>
<SelectTrigger id="position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom-right">Bottom Right</SelectItem>
<SelectItem value="bottom-left">Bottom Left</SelectItem>
<SelectItem value="top-right">Top Right</SelectItem>
<SelectItem value="top-left">Top Left</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="button-color" className="text-sm">Button Color</Label>
<div className="flex gap-2">
<Input
id="button-color-picker"
type="color"
value={buttonColor}
onChange={(e) => setButtonColor(e.target.value)}
className="w-14 h-10 cursor-pointer"
/>
<Input
id="button-color"
value={buttonColor}
onChange={(e) => setButtonColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="button-text" className="text-sm">Button Text</Label>
<Input
id="button-text"
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder="Start Voice Call"
/>
</div>
</>
) : (
<>
<div className="space-y-3">
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="font-medium mb-2">Integration Instructions</h4>
<ul className="text-sm space-y-2 text-muted-foreground">
<li> Add a div with id=&quot;dograh-inline-container&quot; where you want the widget</li>
<li> The widget will render inside this container</li>
<li> You have full control over the container&apos;s styling</li>
<li> Call window.DograhWidget.start() to begin the call</li>
<li> Call window.DograhWidget.end() to end the call</li>
</ul>
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/20 p-4 border border-blue-200 dark:border-blue-800">
<h4 className="font-medium mb-2 text-blue-900 dark:text-blue-100">Example React Component</h4>
<pre className="text-xs overflow-x-auto">
<code className="text-blue-800 dark:text-blue-200">{`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 (
<div className="my-8">
<h2>Talk to Our Agent</h2>
<div id="dograh-inline-container" className="min-h-[400px]">
{/* Widget renders here */}
</div>
<button
onClick={() => window.DograhWidget?.start()}
disabled={isCallActive}
>
Start Call
</button>
</div>
);
}`}</code>
</pre>
</div>
</div>
</>
)}
{/* Preview for floating mode only */}
{embedMode === "floating" && (
<div className="rounded-lg border bg-background p-4 flex items-center justify-center">
<button
className="px-5 py-2.5 rounded-full font-medium shadow-lg hover:shadow-xl transition-all flex items-center gap-2"
style={{
backgroundColor: buttonColor,
color: "white",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
{buttonText}
</button>
</div>
)}
</div>
<Separator />
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save Configurations"
)}
</Button>
</div>
{/* Embed Script (shows after saving) */}
{embedToken && embedToken.is_active && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Embed Code</Label>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(embedToken.embed_script)}
>
{copied ? (
<>
<Check className="h-4 w-4 mr-1" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy Code
</>
)}
</Button>
</div>
<div className="relative">
<pre className="bg-muted/50 rounded-lg p-4 text-xs overflow-x-auto whitespace-pre-wrap break-all">
<code>{embedToken.embed_script}</code>
</pre>
</div>
<p className="text-xs text-muted-foreground">
Add this script to your website&apos;s HTML to enable the voice widget.
Configuration changes will apply automatically without re-embedding.
</p>
</div>
</>
)}
</>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

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

View file

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

View file

@ -65,7 +65,7 @@ export default function WorkflowDetailPage() {
const stickyTabs = workflow ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : 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) {

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsContent,TabsList, TabsTrigger }

View file

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