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

* feat: add deployment configuration options

* Simplify EmbedDialog

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

View file

@ -6,6 +6,7 @@ from api.routes.integration import router as integration_router
from api.routes.looptalk import router as looptalk_router
from api.routes.organization import router as organization_router
from api.routes.organization_usage import router as organization_usage_router
from api.routes.public_embed import router as public_embed_router
from api.routes.reports import router as reports_router
from api.routes.rtc_offer import router as rtc_offer_router
from api.routes.s3_signed_url import router as s3_router
@ -15,6 +16,7 @@ from api.routes.telephony import router as telephony_router
from api.routes.user import router as user_router
from api.routes.webrtc_signaling import router as webrtc_signaling_router
from api.routes.workflow import router as workflow_router
from api.routes.workflow_embed import router as workflow_embed_router
router = APIRouter(
tags=["main"],
@ -35,6 +37,8 @@ router.include_router(looptalk_router)
router.include_router(organization_usage_router)
router.include_router(reports_router)
router.include_router(webrtc_signaling_router)
router.include_router(public_embed_router)
router.include_router(workflow_embed_router)
@router.get("/health")

View file

@ -1,9 +1,10 @@
from typing import Union
from fastapi import APIRouter, Depends, HTTPException
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey
from typing import Union
from api.schemas.telephony_config import (
TelephonyConfigurationResponse,
TwilioConfigurationRequest,
@ -19,14 +20,13 @@ router = APIRouter(prefix="/organizations", tags=["organizations"])
# Provider configuration constants
PROVIDER_MASKED_FIELDS = {
"twilio": ["account_sid", "auth_token"],
"vonage": ["private_key", "api_key", "api_secret"]
"vonage": ["private_key", "api_key", "api_secret"],
}
# TODO: Make endpoints provider-agnostic
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
async def get_telephony_configuration(
user: UserModel = Depends(get_user)
):
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
"""Get telephony configuration for the user's organization with masked sensitive fields."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
@ -40,11 +40,13 @@ async def get_telephony_configuration(
return TelephonyConfigurationResponse()
stored_provider = config.value.get("provider", "twilio")
if stored_provider == "twilio":
account_sid = config.value.get("account_sid", "")
auth_token = config.value.get("auth_token", "")
from_numbers = config.value.get("from_numbers", []) if account_sid and auth_token else []
from_numbers = (
config.value.get("from_numbers", []) if account_sid and auth_token else []
)
return TelephonyConfigurationResponse(
twilio=TwilioConfigurationResponse(
@ -53,15 +55,19 @@ async def get_telephony_configuration(
auth_token=mask_key(auth_token) if auth_token else "",
from_numbers=from_numbers,
),
vonage=None
vonage=None,
)
elif stored_provider == "vonage":
application_id = config.value.get("application_id", "")
private_key = config.value.get("private_key", "")
api_key = config.value.get("api_key", "")
api_secret = config.value.get("api_secret", "")
from_numbers = config.value.get("from_numbers", []) if application_id and private_key else []
from_numbers = (
config.value.get("from_numbers", [])
if application_id and private_key
else []
)
return TelephonyConfigurationResponse(
twilio=None,
vonage=VonageConfigurationResponse(
@ -71,7 +77,7 @@ async def get_telephony_configuration(
api_key=mask_key(api_key) if api_key else None,
api_secret=mask_key(api_secret) if api_secret else None,
from_numbers=from_numbers,
)
),
)
else:
return TelephonyConfigurationResponse()
@ -79,8 +85,8 @@ async def get_telephony_configuration(
@router.post("/telephony-config")
async def save_telephony_configuration(
request: Union[TwilioConfigurationRequest, VonageConfigurationRequest],
user: UserModel = Depends(get_user)
request: Union[TwilioConfigurationRequest, VonageConfigurationRequest],
user: UserModel = Depends(get_user),
):
"""Save telephony configuration for the user's organization."""
if not user.selected_organization_id:
@ -105,12 +111,14 @@ async def save_telephony_configuration(
"provider": "vonage",
"application_id": request.application_id,
"private_key": request.private_key,
"api_key": getattr(request, 'api_key', None),
"api_secret": getattr(request, 'api_secret', None),
"api_key": getattr(request, "api_key", None),
"api_secret": getattr(request, "api_secret", None),
"from_numbers": request.from_numbers,
}
else:
raise HTTPException(status_code=400, detail=f"Unsupported provider: {request.provider}")
raise HTTPException(
status_code=400, detail=f"Unsupported provider: {request.provider}"
)
if existing_config and existing_config.value:
existing_provider = existing_config.value.get("provider")
@ -126,14 +134,16 @@ async def save_telephony_configuration(
return {"message": "Telephony configuration saved successfully"}
def preserve_masked_fields(request, existing_config, config_value):
def preserve_masked_fields(request, existing_config, config_value):
provider = request.provider
masked_fields = PROVIDER_MASKED_FIELDS.get(provider, [])
for field_name in masked_fields:
if hasattr(request, field_name):
field_value = getattr(request, field_name)
# Check if field has a value and is a masked version of the existing value
if field_value and is_mask_of(field_value, existing_config.value.get(field_name, "")):
if field_value and is_mask_of(
field_value, existing_config.value.get(field_name, "")
):
config_value[field_name] = existing_config.value[field_name]

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

@ -0,0 +1,265 @@
"""Public API endpoints for workflow embedding.
These endpoints are accessible without authentication but require valid embed tokens.
They handle CORS, domain validation, and session management for embedded workflows.
"""
import secrets
from datetime import UTC, datetime, timedelta
from typing import Optional
from fastapi import (
APIRouter,
HTTPException,
Request,
Response,
)
from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import WorkflowRunMode
router = APIRouter(prefix="/public/embed")
class InitEmbedRequest(BaseModel):
"""Request model for initializing an embed session"""
token: str
context_variables: Optional[dict] = None
class InitEmbedResponse(BaseModel):
"""Response model for embed initialization"""
session_token: str
workflow_run_id: int
config: dict
class EmbedConfigResponse(BaseModel):
"""Response model for embed configuration"""
workflow_id: int
settings: dict
theme: str
position: str
button_text: str
button_color: str
size: str
auto_start: bool
def validate_origin(origin: str, allowed_domains: list) -> bool:
"""Validate if the origin is in the allowed domains list.
Args:
origin: The origin header from the request
allowed_domains: List of allowed domain patterns
Returns:
True if origin is allowed, False otherwise
"""
if not allowed_domains:
# If no domains specified, allow all origins
return True
# Extract domain from origin (remove protocol)
if "://" in origin:
domain = origin.split("://")[1].split("/")[0].split(":")[0]
else:
domain = origin
for allowed in allowed_domains:
if allowed == "*":
return True
elif allowed.startswith("*."):
# Wildcard subdomain matching
base_domain = allowed[2:]
if domain == base_domain or domain.endswith("." + base_domain):
return True
elif domain == allowed:
return True
return False
def generate_session_token() -> str:
"""Generate a cryptographically secure session token"""
return f"emb_session_{secrets.token_urlsafe(32)}"
@router.post("/init", response_model=InitEmbedResponse)
async def initialize_embed_session(request: Request, init_request: InitEmbedRequest):
"""Initialize an embed session with token validation and domain checking.
This endpoint:
1. Validates the embed token
2. Checks domain whitelist
3. Creates a workflow run
4. Generates a temporary session token
5. Returns configuration for the widget
"""
# Get origin header for domain validation
origin = request.headers.get("origin", "")
if not origin:
origin = request.headers.get("referer", "")
# Validate embed token
embed_token = await db_client.get_embed_token_by_token(init_request.token)
if not embed_token:
raise HTTPException(status_code=404, detail="Invalid embed token")
# Check if token is active
if not embed_token.is_active:
raise HTTPException(status_code=403, detail="Embed token is inactive")
# Check expiration
if embed_token.expires_at and embed_token.expires_at < datetime.now(UTC):
raise HTTPException(status_code=403, detail="Embed token has expired")
# Check usage limit
if embed_token.usage_limit and embed_token.usage_count >= embed_token.usage_limit:
raise HTTPException(status_code=403, detail="Embed token usage limit exceeded")
# Validate domain
if not validate_origin(origin, embed_token.allowed_domains or []):
logger.warning(
f"Domain validation failed: {origin} not in {embed_token.allowed_domains}"
)
raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}")
# Create workflow run
try:
workflow_run = await db_client.create_workflow_run(
name=f"Embed Run - {datetime.now(UTC).isoformat()}",
workflow_id=embed_token.workflow_id,
mode=WorkflowRunMode.SMALLWEBRTC.value,
user_id=embed_token.created_by, # Use token creator as run owner
initial_context=init_request.context_variables,
)
except Exception as e:
logger.error(f"Failed to create workflow run: {e}")
raise HTTPException(status_code=500, detail="Failed to create workflow run")
# Generate session token
session_token = generate_session_token()
# Create embed session
try:
await db_client.create_embed_session(
session_token=session_token,
embed_token_id=embed_token.id,
workflow_run_id=workflow_run.id,
client_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:500],
origin=origin[:255],
expires_at=datetime.now(UTC) + timedelta(hours=1), # 1 hour expiry
)
except Exception as e:
logger.error(f"Failed to create embed session: {e}")
raise HTTPException(status_code=500, detail="Failed to create session")
# Increment usage count
await db_client.increment_embed_token_usage(embed_token.id)
# Prepare configuration
config = {
"workflow_id": embed_token.workflow_id,
"workflow_run_id": workflow_run.id,
**(embed_token.settings or {}),
}
return InitEmbedResponse(
session_token=session_token, workflow_run_id=workflow_run.id, config=config
)
@router.get("/config/{token}", response_model=EmbedConfigResponse)
async def get_embed_config(token: str, request: Request):
"""Get embed configuration without creating a session.
This endpoint is used to fetch widget configuration for display purposes
without actually starting a call session.
"""
# Get origin header for domain validation
origin = request.headers.get("origin", "")
if not origin:
origin = request.headers.get("referer", "")
# Validate embed token
embed_token = await db_client.get_embed_token_by_token(token)
if not embed_token:
raise HTTPException(status_code=404, detail="Invalid embed token")
# Check if token is active
if not embed_token.is_active:
raise HTTPException(status_code=403, detail="Embed token is inactive")
# Validate domain
if not validate_origin(origin, embed_token.allowed_domains or []):
raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}")
# Extract settings with defaults
settings = embed_token.settings or {}
return EmbedConfigResponse(
workflow_id=embed_token.workflow_id,
settings=settings,
theme=settings.get("theme", "light"),
position=settings.get("position", "bottom-right"),
button_text=settings.get("buttonText", "Start Voice Call"),
button_color=settings.get("buttonColor", "#3B82F6"),
size=settings.get("size", "medium"),
auto_start=settings.get("autoStart", False),
)
@router.options("/init")
async def options_init(request: Request):
"""Handle CORS preflight for init endpoint"""
# For init endpoint, we need to check the token in the request body
# But OPTIONS requests don't have body, so we'll be permissive
# The actual validation happens in the POST request
origin = request.headers.get("origin", "*")
return Response(
headers={
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Origin",
"Access-Control-Max-Age": "86400",
}
)
@router.options("/config/{token}")
async def options_config(request: Request, token: str):
"""Handle CORS preflight for config endpoint"""
# Get origin header
origin = request.headers.get("origin", "*")
# Try to validate the token and get allowed domains
allowed_origin = origin
try:
embed_token = await db_client.get_embed_token_by_token(token)
if embed_token and embed_token.is_active:
# Check if origin is in allowed domains
if validate_origin(origin, embed_token.allowed_domains or []):
allowed_origin = origin
else:
# If not allowed, don't include the origin
allowed_origin = ""
except Exception:
# On error, be permissive for OPTIONS
pass
return Response(
headers={
"Access-Control-Allow-Origin": allowed_origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
}
)

View file

@ -1,19 +1,19 @@
"""
Generic telephony routes that work with any telephony provider.
"""
import json
import random
from datetime import UTC, datetime
from typing import Annotated, Optional
from typing import Optional
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
from fastapi import APIRouter, Depends, Header, HTTPException, Request, WebSocket
from loguru import logger
from pydantic import BaseModel
from starlette.responses import HTMLResponse
from api.db import db_client
from api.db.models import UserModel
from api.enums import WorkflowRunMode
from api.services.auth.depends import get_user
from api.services.campaign.call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher
@ -32,6 +32,7 @@ class InitiateCallRequest(BaseModel):
class StatusCallbackRequest(BaseModel):
"""Generic status callback that can handle different providers"""
# Common fields
call_id: str
status: str
@ -39,10 +40,10 @@ class StatusCallbackRequest(BaseModel):
to_number: Optional[str] = None
direction: Optional[str] = None
duration: Optional[str] = None
# Provider-specific fields stored as extra
extra: dict = {}
@classmethod
def from_twilio(cls, data: dict):
"""Convert Twilio callback to generic format"""
@ -53,9 +54,9 @@ class StatusCallbackRequest(BaseModel):
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("CallDuration") or data.get("Duration"),
extra=data
extra=data,
)
@classmethod
def from_vonage(cls, data: dict):
"""Convert Vonage event to generic format"""
@ -63,14 +64,14 @@ class StatusCallbackRequest(BaseModel):
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy"
"rejected": "busy",
}
return cls(
call_id=data.get("uuid", ""),
status=status_map.get(data.get("status", ""), data.get("status", "")),
@ -78,7 +79,7 @@ class StatusCallbackRequest(BaseModel):
to_number=data.get("to"),
direction=data.get("direction"),
duration=data.get("duration"),
extra=data
extra=data,
)
@ -87,32 +88,32 @@ async def initiate_call(
request: InitiateCallRequest, user: UserModel = Depends(get_user)
):
"""Initiate a call using the configured telephony provider."""
# Get the telephony provider for the organization
provider = await get_telephony_provider(user.selected_organization_id)
# Validate provider is configured
if not provider.validate_config():
raise HTTPException(
status_code=400,
detail="telephony_not_configured",
)
# Determine the workflow run mode based on provider type
workflow_run_mode = provider.PROVIDER_NAME
user_configuration = await db_client.get_user_configurations(user.id)
phone_number = request.phone_number or user_configuration.test_phone_number
if not phone_number:
raise HTTPException(
status_code=400,
detail="Phone number must be provided in request or set in user configuration"
status_code=400,
detail="Phone number must be provided in request or set in user configuration",
)
workflow_run_id = request.workflow_run_id
if not workflow_run_id:
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
@ -130,12 +131,12 @@ async def initiate_call(
if not workflow_run:
raise HTTPException(status_code=400, detail="Workflow run not found")
workflow_run_name = workflow_run.name
# Construct webhook URL based on provider type
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
webhook_endpoint = provider.WEBHOOK_ENDPOINT
webhook_url = (
f"https://{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={request.workflow_id}"
@ -143,35 +144,29 @@ async def initiate_call(
f"&workflow_run_id={workflow_run_id}"
f"&organization_id={user.selected_organization_id}"
)
# Initiate call via provider
result = await provider.initiate_call(
to_number=phone_number,
webhook_url=webhook_url,
workflow_run_id=workflow_run_id,
)
# Store provider type and any provider-specific metadata in workflow run context
gathered_context = {
"provider": provider.PROVIDER_NAME,
**(result.provider_metadata or {})
**(result.provider_metadata or {}),
}
await db_client.update_workflow_run(
run_id=workflow_run_id,
gathered_context=gathered_context
run_id=workflow_run_id, gathered_context=gathered_context
)
return {
"message": f"Call initiated successfully with run name {workflow_run_name}"
}
return {"message": f"Call initiated successfully with run name {workflow_run_name}"}
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: int
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
):
"""
Handle initial webhook from telephony provider.
@ -179,32 +174,32 @@ async def handle_twiml_webhook(
"""
provider = await get_telephony_provider(organization_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return HTMLResponse(content=response_content, media_type="application/xml")
@router.get("/ncco", include_in_schema=False)
async def handle_ncco_webhook(
workflow_id: int,
user_id: int,
workflow_id: int,
user_id: int,
workflow_run_id: int,
organization_id: Optional[int] = None
organization_id: Optional[int] = None,
):
"""Handle NCCO (Nexmo Call Control Objects) webhook for Vonage.
Returns JSON response instead of XML like TwiML.
"""
provider = await get_telephony_provider(organization_id or user_id)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
return json.loads(response_content)
@ -218,36 +213,38 @@ async def websocket_endpoint(
try:
# Set the run context
set_current_run_id(workflow_run_id)
# Get workflow run to determine provider type
workflow_run = await db_client.get_workflow_run(workflow_run_id)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found")
await websocket.close(code=4404, reason="Workflow run not found")
return
# Get workflow for organization info
workflow = await db_client.get_workflow(workflow_id)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")
return
# Extract provider type from workflow run context
provider_type = None
if workflow_run.gathered_context:
provider_type = workflow_run.gathered_context.get("provider")
if not provider_type:
logger.error(f"No provider type found in workflow run {workflow_run_id}")
await websocket.close(code=4400, reason="Provider type not found")
return
logger.info(f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}")
logger.info(
f"WebSocket connected for {provider_type} provider, workflow_run {workflow_run_id}"
)
# Get the telephony provider instance
provider = await get_telephony_provider(workflow.organization_id)
# Verify the provider matches what was stored
if provider.PROVIDER_NAME != provider_type:
logger.error(
@ -255,10 +252,12 @@ async def websocket_endpoint(
)
await websocket.close(code=4400, reason="Provider mismatch")
return
# Delegate to provider-specific handler
await provider.handle_websocket(websocket, workflow_id, user_id, workflow_run_id)
await provider.handle_websocket(
websocket, workflow_id, user_id, workflow_run_id
)
except Exception as e:
logger.error(f"Error in WebSocket connection: {e}")
await websocket.close(1011, "Internal server error")
@ -271,44 +270,46 @@ async def handle_twilio_status_callback(
x_webhook_signature: Optional[str] = Header(None),
):
"""Handle Twilio-specific status callbacks."""
# Parse form data
form_data = await request.form()
callback_data = dict(form_data)
logger.info(
f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}"
)
# Get workflow run to find organization
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
return {"status": "ignored", "reason": "workflow_run_not_found"}
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
return {"status": "ignored", "reason": "workflow_not_found"}
provider = await get_telephony_provider(workflow.organization_id)
if x_webhook_signature:
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
full_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
is_valid = await provider.verify_webhook_signature(
full_url, callback_data, x_webhook_signature
)
if not is_valid:
logger.warning(f"Invalid webhook signature for workflow run {workflow_run_id}")
logger.warning(
f"Invalid webhook signature for workflow run {workflow_run_id}"
)
return {"status": "error", "reason": "invalid_signature"}
# Parse the callback data into generic format
parsed_data = provider.parse_status_callback(callback_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
@ -317,22 +318,20 @@ async def handle_twilio_status_callback(
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {})
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update, workflow_run)
return {"status": "success"}
async def _process_status_update(
workflow_run_id: int,
status: StatusCallbackRequest,
workflow_run: any
workflow_run_id: int, status: StatusCallbackRequest, workflow_run: any
):
"""Process status updates from telephony providers."""
# Log the status callback
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_log = {
@ -340,31 +339,29 @@ async def _process_status_update(
"timestamp": datetime.now(UTC).isoformat(),
"call_id": status.call_id,
"duration": status.duration,
**status.extra # Include provider-specific data
**status.extra, # Include provider-specific data
}
telephony_callback_logs.append(telephony_callback_log)
# Update workflow run logs
await db_client.update_workflow_run(
run_id=workflow_run_id,
logs={"telephony_status_callbacks": telephony_callback_logs},
)
# Handle call completion
if status.status == "completed":
logger.info(
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
)
# Release concurrent slot if this was a campaign call
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
# Mark workflow run as completed
await db_client.update_workflow_run(
run_id=workflow_run_id, is_completed=True
)
await db_client.update_workflow_run(run_id=workflow_run_id, is_completed=True)
# Publish campaign event if applicable
if workflow_run.campaign_id:
publisher = await get_campaign_event_publisher()
@ -374,32 +371,40 @@ async def _process_status_update(
queued_run_id=workflow_run.queued_run_id,
call_duration=int(status.duration) if status.duration else 0,
)
elif status.status in ["failed", "busy", "no-answer", "canceled"]:
logger.warning(f"[run {workflow_run_id}] Call failed with status: {status.status}")
logger.warning(
f"[run {workflow_run_id}] Call failed with status: {status.status}"
)
# Release concurrent slot for terminal statuses if this was a campaign call
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
# Check if retry is needed for campaign calls (busy/no-answer)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=status.status.replace("-", "_"), # Convert no-answer to no_answer
reason=status.status.replace(
"-", "_"
), # Convert no-answer to no_answer
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
# Mark workflow run as completed with failure tags
call_tags = workflow_run.gathered_context.get("call_tags", []) if workflow_run.gathered_context else []
call_tags = (
workflow_run.gathered_context.get("call_tags", [])
if workflow_run.gathered_context
else []
)
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
gathered_context={"call_tags": call_tags}
gathered_context={"call_tags": call_tags},
)
@ -409,20 +414,20 @@ async def handle_vonage_events(
workflow_run_id: int,
):
"""Handle Vonage-specific event webhooks.
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
"""
# Parse the event data
event_data = await request.json()
logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}")
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "error", "message": "Workflow run not found"}
# For a completed call that includes cost info, capture it immediately
if event_data.get("status") == "completed":
# Vonage sometimes includes price info in the webhook
@ -436,27 +441,32 @@ async def handle_vonage_events(
if "rate" in event_data:
cost_info["vonage_webhook_rate"] = float(event_data["rate"])
if "duration" in event_data:
cost_info["vonage_webhook_duration"] = int(event_data["duration"])
cost_info["vonage_webhook_duration"] = int(
event_data["duration"]
)
await db_client.update_workflow_run(
run_id=workflow_run_id,
cost_info=cost_info
run_id=workflow_run_id, cost_info=cost_info
)
logger.info(
f"[run {workflow_run_id}] Captured Vonage cost info from webhook"
)
logger.info(f"[run {workflow_run_id}] Captured Vonage cost info from webhook")
except Exception as e:
logger.error(f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}")
logger.error(
f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}"
)
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.error(f"[run {workflow_run_id}] Workflow not found")
return {"status": "error", "message": "Workflow not found"}
provider = await get_telephony_provider(workflow.organization_id)
# Parse the event data into generic format
parsed_data = provider.parse_status_callback(event_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
@ -465,11 +475,11 @@ async def handle_vonage_events(
to_number=parsed_data.get("to_number"),
direction=parsed_data.get("direction"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {})
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update, workflow_run)
# Return 204 No Content as expected by Vonage
return {"status": "ok"}
return {"status": "ok"}

View file

@ -10,12 +10,14 @@ Uses the SmallWebRTC API contract:
"""
import asyncio
from datetime import UTC, datetime
from typing import Dict
from aiortc.sdp import candidate_from_sdp
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from loguru import logger
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_user_ws
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
@ -124,7 +126,9 @@ class SignalingManager:
)
else:
# Create new connection using correct SmallWebRTC API
pc = SmallWebRTCConnection(ice_servers=ice_servers, connection_timeout_secs=60)
pc = SmallWebRTCConnection(
ice_servers=ice_servers, connection_timeout_secs=60
)
# Set the pc_id before initialization so it's available in get_answer()
pc._pc_id = pc_id
@ -244,3 +248,46 @@ async def signaling_websocket(
await signaling_manager.handle_websocket(
websocket, workflow_id, workflow_run_id, user
)
@router.websocket("/public/signaling/{session_token}")
async def public_signaling_websocket(
websocket: WebSocket,
session_token: str,
):
"""Public WebSocket endpoint for WebRTC signaling with embed tokens.
This endpoint:
1. Validates the session token from embed initialization
2. Retrieves the associated workflow run
3. Handles WebRTC signaling without requiring authentication
"""
# Validate session token
embed_session = await db_client.get_embed_session_by_token(session_token)
if not embed_session:
await websocket.close(code=1008, reason="Invalid session token")
return
# Check if session is expired
if embed_session.expires_at and embed_session.expires_at < datetime.now(UTC):
await websocket.close(code=1008, reason="Session expired")
return
# Get the embed token for user information
embed_token = await db_client.get_embed_token_by_id(embed_session.embed_token_id)
if not embed_token:
await websocket.close(code=1008, reason="Invalid embed token")
return
# Create a minimal user object for compatibility with signaling manager
# Use the embed token creator as the user
user = await db_client.get_user_by_id(embed_token.created_by)
if not user:
await websocket.close(code=1008, reason="Invalid user")
return
# Handle the WebSocket connection using the existing signaling manager
await signaling_manager.handle_websocket(
websocket, embed_token.workflow_id, embed_session.workflow_run_id, user
)

View file

@ -0,0 +1,203 @@
"""Embed token endpoints for workflows."""
from datetime import UTC, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from api.constants import BACKEND_API_ENDPOINT, ENVIRONMENT, UI_APP_URL
from api.db import db_client
from api.db.models import EmbedTokenModel, UserModel
from api.services.auth.depends import get_user
router = APIRouter(prefix="/workflow")
def generate_embed_script(token: EmbedTokenModel) -> str:
"""Generate the embed script for a given token."""
base_url = str(UI_APP_URL).rstrip("/")
return f"""<!-- Dograh Voice Widget -->
<script>
(function(d, s, id) {{
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src = '{base_url}/embed/dograh-widget.js?token={token.token}&environment={ENVIRONMENT}&apiEndpoint={BACKEND_API_ENDPOINT}';
js.async = true;
fjs.parentNode.insertBefore(js, fjs);
}}(document, 'script', 'dograh-widget'));
</script>"""
class EmbedTokenRequest(BaseModel):
allowed_domains: Optional[list[str]] = None
settings: Optional[dict] = None
usage_limit: Optional[int] = None
expires_in_days: Optional[int] = 30
class EmbedTokenResponse(BaseModel):
id: int
token: str
allowed_domains: Optional[list[str]]
settings: Optional[dict]
is_active: bool
usage_count: int
usage_limit: Optional[int]
expires_at: Optional[datetime]
created_at: datetime
embed_script: str
@router.post("/{workflow_id}/embed-token")
async def create_or_update_embed_token(
workflow_id: int,
request: Request,
embed_request: EmbedTokenRequest,
user: UserModel = Depends(get_user),
) -> EmbedTokenResponse:
"""
Create or update an embed token for a workflow.
Each workflow can have only one active embed token.
"""
# Verify workflow exists and user has access
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
# Check if an embed token already exists for this workflow
existing_tokens = await db_client.get_embed_tokens_by_workflow(
workflow_id, user.selected_organization_id, active_only=False
)
expires_at = None
if embed_request.expires_in_days:
expires_at = datetime.now(UTC) + timedelta(days=embed_request.expires_in_days)
if existing_tokens:
# Update the existing token (reactivate if needed)
token = await db_client.update_embed_token(
existing_tokens[0].id,
user.selected_organization_id,
allowed_domains=embed_request.allowed_domains,
settings=embed_request.settings,
usage_limit=embed_request.usage_limit,
expires_at=expires_at,
is_active=True,
)
else:
# Create new token
token = await db_client.create_embed_token(
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
created_by=user.id,
allowed_domains=embed_request.allowed_domains,
settings=embed_request.settings,
usage_limit=embed_request.usage_limit,
expires_at=expires_at,
)
# Generate embed script
embed_script = generate_embed_script(token)
return EmbedTokenResponse(
id=token.id,
token=token.token,
allowed_domains=token.allowed_domains,
settings=token.settings,
is_active=token.is_active,
usage_count=token.usage_count,
usage_limit=token.usage_limit,
expires_at=token.expires_at,
created_at=token.created_at,
embed_script=embed_script,
)
@router.get("/{workflow_id}/embed-token")
async def get_embed_token(
workflow_id: int,
request: Request,
user: UserModel = Depends(get_user),
) -> Optional[EmbedTokenResponse]:
"""
Get the embed token for a workflow if it exists.
"""
# Verify workflow exists and user has access
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
# Get active embed tokens for this workflow
tokens = await db_client.get_embed_tokens_by_workflow(
workflow_id, user.selected_organization_id, active_only=True
)
if not tokens:
return None
token = tokens[0] # There should be only one active token per workflow
# Generate embed script
embed_script = generate_embed_script(token)
return EmbedTokenResponse(
id=token.id,
token=token.token,
allowed_domains=token.allowed_domains,
settings=token.settings,
is_active=token.is_active,
usage_count=token.usage_count,
usage_limit=token.usage_limit,
expires_at=token.expires_at,
created_at=token.created_at,
embed_script=embed_script,
)
@router.delete("/{workflow_id}/embed-token")
async def deactivate_embed_token(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> dict:
"""
Deactivate the embed token for a workflow.
"""
# Verify workflow exists and user has access
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
# Get active embed tokens for this workflow
tokens = await db_client.get_embed_tokens_by_workflow(
workflow_id, user.selected_organization_id, active_only=True
)
if not tokens:
raise HTTPException(
status_code=404, detail="No active embed token found for this workflow"
)
# Deactivate the token
success = await db_client.deactivate_embed_token(
tokens[0].id, user.selected_organization_id
)
if success:
return {"message": "Embed token deactivated successfully"}
else:
raise HTTPException(status_code=500, detail="Failed to deactivate embed token")