mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
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:
parent
5e4aef346d
commit
99a768f291
40 changed files with 3551 additions and 645 deletions
29
.env.example
29
.env.example
|
|
@ -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: ""
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
160
api/alembic/versions/e02f387b7538_add_embed_token_model.py
Normal file
160
api/alembic/versions/e02f387b7538_add_embed_token_model.py
Normal 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 ###
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
329
api/db/embed_token_client.py
Normal file
329
api/db/embed_token_client.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
265
api/routes/public_embed.py
Normal 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",
|
||||
}
|
||||
)
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
203
api/routes/workflow_embed.py
Normal file
203
api/routes/workflow_embed.py
Normal 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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
# Telephony provider implementations
|
||||
# Telephony provider implementations
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
5
ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
951
ui/public/embed/dograh-widget.js
Normal file
951
ui/public/embed/dograh-widget.js
Normal 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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
507
ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx
Normal file
507
ui/src/app/workflow/[workflowId]/components/EmbedDialog.tsx
Normal 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 "{workflowName}" 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="dograh-inline-container" where you want the widget</li>
|
||||
<li>• The widget will render inside this container</li>
|
||||
<li>• You have full control over the container'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's HTML to enable the voice widget.
|
||||
Configuration changes will apply automatically without re-embedding.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
66
ui/src/components/ui/tabs.tsx
Normal file
66
ui/src/components/ui/tabs.tsx
Normal 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 }
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue