mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +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
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue