Feat/Add API Trigger and Webhooks in Agent Builder (#83)

* feat: add api trigger node for agent runs

* feat: add webhook node

* Execute webhook nodes post workflow run

* Add hint to go to API keys
This commit is contained in:
Abhishek 2025-12-22 14:08:30 +05:30 committed by GitHub
parent 4ddb144dd0
commit 55b727a872
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3667 additions and 494 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
__pycache__
.DS_Store
.env
.env.test
# logs and run directory on production
/logs/

View file

@ -0,0 +1,109 @@
"""add external credentials model
Revision ID: 36b5dbf670e4
Revises: c7c56dd36b21
Create Date: 2025-12-22 05:29:31.061141
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "36b5dbf670e4"
down_revision: Union[str, None] = "c7c56dd36b21"
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! ###
sa.Enum(
"none",
"api_key",
"bearer_token",
"basic_auth",
"custom_header",
name="webhook_credential_type",
).create(op.get_bind())
op.create_table(
"external_credentials",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("credential_uuid", sa.String(length=36), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column(
"credential_type",
postgresql.ENUM(
"none",
"api_key",
"bearer_token",
"basic_auth",
"custom_header",
name="webhook_credential_type",
create_type=False,
),
nullable=False,
),
sa.Column("credential_data", sa.JSON(), nullable=False),
sa.Column("created_by", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["created_by"],
["users.id"],
),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id", "name", name="unique_org_credential_name"
),
)
op.create_index(
op.f("ix_external_credentials_credential_uuid"),
"external_credentials",
["credential_uuid"],
unique=True,
)
op.create_index(
"ix_webhook_credentials_organization_id",
"external_credentials",
["organization_id"],
unique=False,
)
op.create_index(
"ix_webhook_credentials_uuid",
"external_credentials",
["credential_uuid"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_webhook_credentials_uuid", table_name="external_credentials")
op.drop_index(
"ix_webhook_credentials_organization_id", table_name="external_credentials"
)
op.drop_index(
op.f("ix_external_credentials_credential_uuid"),
table_name="external_credentials",
)
op.drop_table("external_credentials")
sa.Enum(
"none",
"api_key",
"bearer_token",
"basic_auth",
"custom_header",
name="webhook_credential_type",
).drop(op.get_bind())
# ### end Alembic commands ###

View file

@ -5,13 +5,14 @@ Revises: a188ff90e76f
Create Date: 2025-12-10 17:34:31.232048
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
revision: str = '49a8fe6841e6'
down_revision: Union[str, None] = 'a188ff90e76f'
revision: str = "49a8fe6841e6"
down_revision: Union[str, None] = "a188ff90e76f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@ -19,21 +20,20 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create the workflow_run_state enum type
workflow_run_state_enum = sa.Enum(
'initialized', 'running', 'completed',
name='workflow_run_state'
"initialized", "running", "completed", name="workflow_run_state"
)
workflow_run_state_enum.create(op.get_bind())
# Add the state column to workflow_runs table (nullable first)
op.add_column(
'workflow_runs',
"workflow_runs",
sa.Column(
'state',
sa.Enum('initialized', 'running', 'completed', name='workflow_run_state'),
nullable=True
)
"state",
sa.Enum("initialized", "running", "completed", name="workflow_run_state"),
nullable=True,
),
)
# Set appropriate state values for existing records
# Completed workflows should be marked as 'completed'
# Non-completed workflows should be marked as 'initialized'
@ -44,19 +44,16 @@ def upgrade() -> None:
ELSE 'initialized'::workflow_run_state
END
""")
# Now make the column non-nullable with 'initialized' as default for new records
op.alter_column(
'workflow_runs',
'state',
nullable=False,
server_default='initialized'
"workflow_runs", "state", nullable=False, server_default="initialized"
)
def downgrade() -> None:
# Drop the state column
op.drop_column('workflow_runs', 'state')
op.drop_column("workflow_runs", "state")
# Drop the enum type
sa.Enum(name='workflow_run_state').drop(op.get_bind())
sa.Enum(name="workflow_run_state").drop(op.get_bind())

View file

@ -0,0 +1,68 @@
"""add agent trigger
Revision ID: c7c56dd36b21
Revises: 49a8fe6841e6
Create Date: 2025-12-21 08:21:06.692772
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "c7c56dd36b21"
down_revision: Union[str, None] = "49a8fe6841e6"
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! ###
sa.Enum("active", "archived", name="trigger_state").create(op.get_bind())
op.create_table(
"agent_triggers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("trigger_path", sa.String(length=36), nullable=False),
sa.Column("workflow_id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column(
"state",
postgresql.ENUM(
"active", "archived", name="trigger_state", create_type=False
),
server_default=sa.text("'active'::trigger_state"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["workflow_id"], ["workflows.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_agent_triggers_state", "agent_triggers", ["state"], unique=False
)
op.create_index(
op.f("ix_agent_triggers_trigger_path"),
"agent_triggers",
["trigger_path"],
unique=True,
)
op.create_index(
"ix_agent_triggers_workflow_id", "agent_triggers", ["workflow_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_agent_triggers_workflow_id", table_name="agent_triggers")
op.drop_index(op.f("ix_agent_triggers_trigger_path"), table_name="agent_triggers")
op.drop_index("ix_agent_triggers_state", table_name="agent_triggers")
op.drop_table("agent_triggers")
sa.Enum("active", "archived", name="trigger_state").drop(op.get_bind())
# ### end Alembic commands ###

View file

@ -0,0 +1,118 @@
"""Database client for managing agent triggers."""
from typing import List, Optional
from loguru import logger
from sqlalchemy import and_, select, update
from sqlalchemy.dialects.postgresql import insert
from api.db.base_client import BaseDBClient
from api.db.models import AgentTriggerModel
from api.enums import TriggerState
class AgentTriggerClient(BaseDBClient):
"""Client for managing agent triggers (UUID -> workflow_id mappings)."""
async def get_agent_trigger_by_path(
self, trigger_path: str, active_only: bool = True
) -> Optional[AgentTriggerModel]:
"""Get an agent trigger by its unique path (UUID).
Args:
trigger_path: The unique trigger UUID
active_only: If True, only return active triggers
Returns:
AgentTriggerModel if found, None otherwise
"""
async with self.async_session() as session:
query = select(AgentTriggerModel).where(
AgentTriggerModel.trigger_path == trigger_path
)
if active_only:
query = query.where(
AgentTriggerModel.state == TriggerState.ACTIVE.value
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def sync_triggers_for_workflow(
self, workflow_id: int, organization_id: int, trigger_paths: List[str]
) -> None:
"""Sync triggers for a workflow based on the trigger nodes in the workflow definition.
This creates/reactivates triggers that are in the workflow definition
and archives triggers that are no longer in the workflow.
Args:
workflow_id: ID of the workflow
organization_id: ID of the organization
trigger_paths: List of trigger UUIDs from the workflow definition
"""
async with self.async_session() as session:
# Get all existing triggers for this workflow (including archived)
result = await session.execute(
select(AgentTriggerModel).where(
AgentTriggerModel.workflow_id == workflow_id
)
)
existing_triggers = {t.trigger_path: t for t in result.scalars().all()}
existing_paths = set(existing_triggers.keys())
new_paths = set(trigger_paths)
# Archive triggers that are no longer in the workflow definition
paths_to_archive = existing_paths - new_paths
if paths_to_archive:
await session.execute(
update(AgentTriggerModel)
.where(AgentTriggerModel.trigger_path.in_(paths_to_archive))
.values(state=TriggerState.ARCHIVED.value)
)
logger.info(
f"Archived {len(paths_to_archive)} triggers for workflow {workflow_id}"
)
# Reactivate existing triggers that are back in the workflow
paths_to_reactivate = new_paths & existing_paths
if paths_to_reactivate:
await session.execute(
update(AgentTriggerModel)
.where(
and_(
AgentTriggerModel.trigger_path.in_(paths_to_reactivate),
AgentTriggerModel.state == TriggerState.ARCHIVED.value,
)
)
.values(state=TriggerState.ACTIVE.value)
)
# Add new triggers
paths_to_add = new_paths - existing_paths
for trigger_path in paths_to_add:
stmt = insert(AgentTriggerModel).values(
trigger_path=trigger_path,
workflow_id=workflow_id,
organization_id=organization_id,
state=TriggerState.ACTIVE.value,
)
# Handle race condition where trigger might already exist for another workflow
stmt = stmt.on_conflict_do_update(
index_elements=["trigger_path"],
set_={
"workflow_id": workflow_id,
"organization_id": organization_id,
"state": TriggerState.ACTIVE.value,
},
)
await session.execute(stmt)
if paths_to_add:
logger.info(
f"Added {len(paths_to_add)} triggers for workflow {workflow_id}"
)
await session.commit()

View file

@ -1,3 +1,4 @@
from api.db.agent_trigger_client import AgentTriggerClient
from api.db.api_key_client import APIKeyClient
from api.db.campaign_client import CampaignClient
from api.db.embed_token_client import EmbedTokenClient
@ -8,6 +9,7 @@ from api.db.organization_configuration_client import OrganizationConfigurationCl
from api.db.organization_usage_client import OrganizationUsageClient
from api.db.reports_client import ReportsClient
from api.db.user_client import UserClient
from api.db.webhook_credential_client import WebhookCredentialClient
from api.db.workflow_client import WorkflowClient
from api.db.workflow_run_client import WorkflowRunClient
from api.db.workflow_template_client import WorkflowTemplateClient
@ -27,6 +29,8 @@ class DBClient(
ReportsClient,
APIKeyClient,
EmbedTokenClient,
AgentTriggerClient,
WebhookCredentialClient,
):
"""
Unified database client that combines all specialized database operations.
@ -45,6 +49,8 @@ class DBClient(
- ReportsClient: handles reports and analytics operations
- APIKeyClient: handles API key operations
- EmbedTokenClient: handles embed token and session operations
- AgentTriggerClient: handles agent trigger operations for API-based call triggering
- WebhookCredentialClient: handles webhook credential operations
"""
pass

View file

@ -1,3 +1,4 @@
import uuid
from datetime import UTC, datetime
from loguru import logger
@ -19,7 +20,14 @@ from sqlalchemy import (
)
from sqlalchemy.orm import declarative_base, relationship
from ..enums import IntegrationAction, WorkflowRunMode, WorkflowRunState, WorkflowStatus
from ..enums import (
IntegrationAction,
TriggerState,
WebhookCredentialType,
WorkflowRunMode,
WorkflowRunState,
WorkflowStatus,
)
Base = declarative_base()
@ -676,3 +684,119 @@ class EmbedSessionModel(Base):
# Relationships
embed_token = relationship("EmbedTokenModel", back_populates="sessions")
workflow_run = relationship("WorkflowRunModel")
class AgentTriggerModel(Base):
"""Model for storing agent trigger mappings (UUID -> workflow_id).
This is a minimal lookup table that maps trigger UUIDs to workflows.
The trigger node in the workflow definition is the source of truth.
"""
__tablename__ = "agent_triggers"
id = Column(Integer, primary_key=True, index=True)
# Unique trigger path (UUID format) - generated by UI when trigger node is created
trigger_path = Column(String(36), unique=True, nullable=False, index=True)
# Link to workflow
workflow_id = Column(
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False
)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
# State management (active/archived)
state = Column(
Enum(*[state.value for state in TriggerState], name="trigger_state"),
nullable=False,
default=TriggerState.ACTIVE.value,
server_default=text("'active'::trigger_state"),
)
# Audit
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
# Relationships
workflow = relationship("WorkflowModel")
organization = relationship("OrganizationModel")
# Indexes for performance
__table_args__ = (
Index("ix_agent_triggers_workflow_id", "workflow_id"),
Index("ix_agent_triggers_state", "state"),
)
class ExternalCredentialModel(Base):
"""Model for storing external authentication credentials.
Credentials are stored separately from webhook configurations to allow
reuse across multiple workflows and secure storage of sensitive data.
"""
__tablename__ = "external_credentials"
id = Column(Integer, primary_key=True, index=True)
# Public UUID reference (used in APIs and workflow definitions)
# This prevents enumeration attacks and hides internal IDs
credential_uuid = Column(
String(36),
unique=True,
nullable=False,
index=True,
default=lambda: str(uuid.uuid4()),
)
# Organization scoping
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
# Credential metadata
name = Column(String, nullable=False) # Display name, e.g., "Salesforce API"
description = Column(String, nullable=True) # Optional description
# Credential type - uses enum from api/enums.py
credential_type = Column(
Enum(
*[t.value for t in WebhookCredentialType],
name="webhook_credential_type",
),
nullable=False,
default=WebhookCredentialType.NONE.value,
)
# Encrypted credential data (JSON)
# Structure depends on credential_type:
# - api_key: {"header_name": "X-API-Key", "api_key": "value"}
# - bearer_token: {"token": "value"}
# - basic_auth: {"username": "user", "password": "value"}
# - custom_header: {"header_name": "X-Custom", "header_value": "value"}
credential_data = Column(JSON, nullable=False, default=dict)
# Audit fields
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Soft delete for safety
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
organization = relationship("OrganizationModel")
created_by_user = relationship("UserModel")
# Indexes and constraints
__table_args__ = (
Index("ix_webhook_credentials_organization_id", "organization_id"),
Index("ix_webhook_credentials_uuid", "credential_uuid"),
UniqueConstraint("organization_id", "name", name="unique_org_credential_name"),
)

View file

@ -0,0 +1,220 @@
"""Database client for managing webhook credentials."""
from datetime import UTC, datetime
from typing import List, Optional
from loguru import logger
from sqlalchemy import select, update
from api.db.base_client import BaseDBClient
from api.db.models import ExternalCredentialModel
class WebhookCredentialClient(BaseDBClient):
"""Client for managing webhook credentials (organization-scoped, UUID-referenced)."""
async def create_credential(
self,
organization_id: int,
user_id: int,
name: str,
credential_type: str,
credential_data: dict,
description: Optional[str] = None,
) -> ExternalCredentialModel:
"""Create a new webhook credential.
Args:
organization_id: ID of the organization
user_id: ID of the user creating the credential
name: Display name for the credential
credential_type: Type of credential (none, api_key, bearer_token, basic_auth, custom_header)
credential_data: JSON data containing the credential details
description: Optional description
Returns:
The created ExternalCredentialModel with auto-generated UUID
"""
async with self.async_session() as session:
credential = ExternalCredentialModel(
organization_id=organization_id,
created_by=user_id,
name=name,
description=description,
credential_type=credential_type,
credential_data=credential_data,
)
session.add(credential)
await session.commit()
await session.refresh(credential)
logger.info(
f"Created webhook credential '{name}' ({credential.credential_uuid}) "
f"for organization {organization_id}"
)
return credential
async def get_credentials_for_organization(
self, organization_id: int, active_only: bool = True
) -> List[ExternalCredentialModel]:
"""Get all credentials for an organization.
Args:
organization_id: ID of the organization
active_only: If True, only return active (non-deleted) credentials
Returns:
List of ExternalCredentialModel instances
"""
async with self.async_session() as session:
query = select(ExternalCredentialModel).where(
ExternalCredentialModel.organization_id == organization_id
)
if active_only:
query = query.where(ExternalCredentialModel.is_active.is_(True))
query = query.order_by(ExternalCredentialModel.name)
result = await session.execute(query)
return list(result.scalars().all())
async def get_credential_by_uuid(
self, credential_uuid: str, organization_id: int, active_only: bool = True
) -> Optional[ExternalCredentialModel]:
"""Get a credential by its UUID, scoped to organization.
Args:
credential_uuid: The unique credential UUID
organization_id: ID of the organization (for authorization)
active_only: If True, only return if active
Returns:
ExternalCredentialModel if found and authorized, None otherwise
"""
async with self.async_session() as session:
query = select(ExternalCredentialModel).where(
ExternalCredentialModel.credential_uuid == credential_uuid,
ExternalCredentialModel.organization_id == organization_id,
)
if active_only:
query = query.where(ExternalCredentialModel.is_active.is_(True))
result = await session.execute(query)
return result.scalar_one_or_none()
async def update_credential(
self,
credential_uuid: str,
organization_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
credential_type: Optional[str] = None,
credential_data: Optional[dict] = None,
) -> Optional[ExternalCredentialModel]:
"""Update a credential by UUID.
Args:
credential_uuid: The unique credential UUID
organization_id: ID of the organization (for authorization)
name: New name (if provided)
description: New description (if provided)
credential_type: New credential type (if provided)
credential_data: New credential data (if provided)
Returns:
Updated ExternalCredentialModel if found, None otherwise
"""
async with self.async_session() as session:
# First check if credential exists and belongs to organization
credential = await self.get_credential_by_uuid(
credential_uuid, organization_id
)
if not credential:
return None
# Build update values
update_values = {"updated_at": datetime.now(UTC)}
if name is not None:
update_values["name"] = name
if description is not None:
update_values["description"] = description
if credential_type is not None:
update_values["credential_type"] = credential_type
if credential_data is not None:
update_values["credential_data"] = credential_data
await session.execute(
update(ExternalCredentialModel)
.where(
ExternalCredentialModel.credential_uuid == credential_uuid,
ExternalCredentialModel.organization_id == organization_id,
)
.values(**update_values)
)
await session.commit()
# Fetch updated credential
result = await session.execute(
select(ExternalCredentialModel).where(
ExternalCredentialModel.credential_uuid == credential_uuid
)
)
updated_credential = result.scalar_one()
logger.info(
f"Updated webhook credential {credential_uuid} "
f"for organization {organization_id}"
)
return updated_credential
async def delete_credential(
self, credential_uuid: str, organization_id: int
) -> bool:
"""Soft delete a credential by UUID.
Args:
credential_uuid: The unique credential UUID
organization_id: ID of the organization (for authorization)
Returns:
True if credential was deleted, False if not found
"""
async with self.async_session() as session:
result = await session.execute(
update(ExternalCredentialModel)
.where(
ExternalCredentialModel.credential_uuid == credential_uuid,
ExternalCredentialModel.organization_id == organization_id,
ExternalCredentialModel.is_active.is_(True),
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await session.commit()
if result.rowcount > 0:
logger.info(
f"Soft deleted webhook credential {credential_uuid} "
f"for organization {organization_id}"
)
return True
return False
async def validate_credential_uuid(
self, credential_uuid: str, organization_id: int
) -> bool:
"""Check if a credential UUID exists and belongs to the organization.
This is useful for workflow validation to ensure referenced credentials exist.
Args:
credential_uuid: The credential UUID to validate
organization_id: ID of the organization
Returns:
True if valid, False otherwise
"""
credential = await self.get_credential_by_uuid(credential_uuid, organization_id)
return credential is not None

View file

@ -394,8 +394,9 @@ class WorkflowRunClient(BaseDBClient):
result = await session.execute(
select(WorkflowRunModel)
.options(
selectinload(WorkflowRunModel.workflow).selectinload(
WorkflowModel.user
selectinload(WorkflowRunModel.workflow).options(
selectinload(WorkflowModel.user),
selectinload(WorkflowModel.current_definition),
)
)
.where(WorkflowRunModel.id == workflow_run_id)

View file

@ -56,8 +56,8 @@ class StorageBackend(Enum):
class WorkflowRunState(Enum):
INITIALIZED = "initialized" # Workflow run created, ready for connection
RUNNING = "running" # Websocket connected and pipeline active
COMPLETED = "completed" # Workflow run finished
RUNNING = "running" # Websocket connected and pipeline active
COMPLETED = "completed" # Workflow run finished
class WorkflowRunStatus(Enum):
@ -92,3 +92,20 @@ class RedisChannel(Enum):
"""Redis pub/sub channel names"""
CAMPAIGN_EVENTS = "campaign_events"
class TriggerState(Enum):
"""Agent trigger state values"""
ACTIVE = "active"
ARCHIVED = "archived"
class WebhookCredentialType(Enum):
"""Webhook credential authentication types"""
NONE = "none" # No authentication
API_KEY = "api_key" # API key in header
BEARER_TOKEN = "bearer_token" # Bearer token auth
BASIC_AUTH = "basic_auth" # Username/password
CUSTOM_HEADER = "custom_header" # Custom header key-value

View file

@ -9,6 +9,7 @@ from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey
from api.services.auth.depends import get_user
from api.services.campaign.runner import campaign_runner_service
from api.services.quota_service import check_dograh_quota
from api.services.storage import storage_fs
router = APIRouter(prefix="/campaign")
@ -182,6 +183,11 @@ async def start_campaign(
detail="You must configure telephony first by going to APP_URL/configure-telephony",
)
# Check Dograh quota before starting campaign
quota_result = await check_dograh_quota(user)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Verify campaign exists and belongs to organization
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
if not campaign:
@ -290,6 +296,11 @@ async def resume_campaign(
detail="You must configure telephony first by going to APP_URL/configure-telephony",
)
# Check Dograh quota before resuming campaign
quota_result = await check_dograh_quota(user)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Verify campaign exists and belongs to organization
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
if not campaign:

284
api/routes/credentials.py Normal file
View file

@ -0,0 +1,284 @@
"""API routes for managing webhook credentials."""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.enums import WebhookCredentialType
from api.services.auth.depends import get_user
router = APIRouter(prefix="/credentials")
# Request/Response schemas
class CreateCredentialRequest(BaseModel):
"""Request schema for creating a webhook credential."""
name: str
description: Optional[str] = None
credential_type: WebhookCredentialType
credential_data: dict # Validated based on credential_type
class UpdateCredentialRequest(BaseModel):
"""Request schema for updating a webhook credential."""
name: Optional[str] = None
description: Optional[str] = None
credential_type: Optional[WebhookCredentialType] = None
credential_data: Optional[dict] = None
class CredentialResponse(BaseModel):
"""Response schema for a webhook credential (never includes sensitive data)."""
uuid: str
name: str
description: Optional[str]
credential_type: str
created_at: datetime
updated_at: Optional[datetime]
class Config:
from_attributes = True
def validate_credential_data(
credential_type: WebhookCredentialType, credential_data: dict
) -> None:
"""Validate that credential_data matches the expected structure for the credential type.
Args:
credential_type: The type of credential
credential_data: The credential data to validate
Raises:
HTTPException: If validation fails
"""
if credential_type == WebhookCredentialType.NONE:
# No data required
return
if credential_type == WebhookCredentialType.API_KEY:
if "header_name" not in credential_data or "api_key" not in credential_data:
raise HTTPException(
status_code=400,
detail="API Key credential requires 'header_name' and 'api_key' fields",
)
elif credential_type == WebhookCredentialType.BEARER_TOKEN:
if "token" not in credential_data:
raise HTTPException(
status_code=400,
detail="Bearer Token credential requires 'token' field",
)
elif credential_type == WebhookCredentialType.BASIC_AUTH:
if "username" not in credential_data or "password" not in credential_data:
raise HTTPException(
status_code=400,
detail="Basic Auth credential requires 'username' and 'password' fields",
)
elif credential_type == WebhookCredentialType.CUSTOM_HEADER:
if (
"header_name" not in credential_data
or "header_value" not in credential_data
):
raise HTTPException(
status_code=400,
detail="Custom Header credential requires 'header_name' and 'header_value' fields",
)
def build_credential_response(credential) -> CredentialResponse:
"""Build a response from a credential model (excluding sensitive data)."""
return CredentialResponse(
uuid=credential.credential_uuid,
name=credential.name,
description=credential.description,
credential_type=credential.credential_type,
created_at=credential.created_at,
updated_at=credential.updated_at,
)
@router.get("/")
async def list_credentials(
user: UserModel = Depends(get_user),
) -> List[CredentialResponse]:
"""
List all webhook credentials for the user's organization.
Returns:
List of credentials (without sensitive data)
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
credentials = await db_client.get_credentials_for_organization(
user.selected_organization_id
)
return [build_credential_response(cred) for cred in credentials]
@router.post("/")
async def create_credential(
request: CreateCredentialRequest,
user: UserModel = Depends(get_user),
) -> CredentialResponse:
"""
Create a new webhook credential.
Args:
request: The credential creation request
Returns:
The created credential (without sensitive data)
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
# Validate credential data structure
validate_credential_data(request.credential_type, request.credential_data)
try:
credential = await db_client.create_credential(
organization_id=user.selected_organization_id,
user_id=user.id,
name=request.name,
description=request.description,
credential_type=request.credential_type.value,
credential_data=request.credential_data,
)
return build_credential_response(credential)
except Exception as e:
# Handle unique constraint violation
if "unique_org_credential_name" in str(e):
raise HTTPException(
status_code=409,
detail=f"A credential with the name '{request.name}' already exists",
)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{credential_uuid}")
async def get_credential(
credential_uuid: str,
user: UserModel = Depends(get_user),
) -> CredentialResponse:
"""
Get a specific webhook credential by UUID.
Args:
credential_uuid: The UUID of the credential
Returns:
The credential (without sensitive data)
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
credential = await db_client.get_credential_by_uuid(
credential_uuid, user.selected_organization_id
)
if not credential:
raise HTTPException(status_code=404, detail="Credential not found")
return build_credential_response(credential)
@router.put("/{credential_uuid}")
async def update_credential(
credential_uuid: str,
request: UpdateCredentialRequest,
user: UserModel = Depends(get_user),
) -> CredentialResponse:
"""
Update a webhook credential.
Args:
credential_uuid: The UUID of the credential to update
request: The update request
Returns:
The updated credential (without sensitive data)
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
# Validate credential data if provided
if request.credential_type and request.credential_data:
validate_credential_data(request.credential_type, request.credential_data)
try:
credential = await db_client.update_credential(
credential_uuid=credential_uuid,
organization_id=user.selected_organization_id,
name=request.name,
description=request.description,
credential_type=request.credential_type.value
if request.credential_type
else None,
credential_data=request.credential_data,
)
if not credential:
raise HTTPException(status_code=404, detail="Credential not found")
return build_credential_response(credential)
except HTTPException:
raise
except Exception as e:
if "unique_org_credential_name" in str(e):
raise HTTPException(
status_code=409,
detail=f"A credential with the name '{request.name}' already exists",
)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{credential_uuid}")
async def delete_credential(
credential_uuid: str,
user: UserModel = Depends(get_user),
) -> dict:
"""
Delete (soft delete) a webhook credential.
Args:
credential_uuid: The UUID of the credential to delete
Returns:
Success message
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
deleted = await db_client.delete_credential(
credential_uuid, user.selected_organization_id
)
if not deleted:
raise HTTPException(status_code=404, detail="Credential not found")
return {"status": "deleted", "uuid": credential_uuid}

View file

@ -2,10 +2,12 @@ from fastapi import APIRouter
from loguru import logger
from api.routes.campaign import router as campaign_router
from api.routes.credentials import router as credentials_router
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_agent import router as public_agent_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
@ -29,6 +31,7 @@ router.include_router(superuser_router)
router.include_router(workflow_router)
router.include_router(user_router)
router.include_router(campaign_router)
router.include_router(credentials_router)
router.include_router(integration_router)
router.include_router(organization_router)
router.include_router(s3_router)
@ -38,6 +41,7 @@ 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(public_agent_router)
router.include_router(workflow_embed_router)

187
api/routes/public_agent.py Normal file
View file

@ -0,0 +1,187 @@
"""Public API endpoints for agent triggers.
These endpoints are accessible with API key authentication and allow
external systems to programmatically trigger phone calls.
"""
import random
from typing import Optional
from fastapi import APIRouter, Header, HTTPException
from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import TriggerState
from api.services.quota_service import check_dograh_quota_by_user_id
from api.services.telephony.factory import get_telephony_provider
from api.utils.tunnel import TunnelURLProvider
router = APIRouter(prefix="/public/agent")
class TriggerCallRequest(BaseModel):
"""Request model for triggering a call via API"""
phone_number: str
initial_context: Optional[dict] = None
class TriggerCallResponse(BaseModel):
"""Response model for successful call initiation"""
status: str
workflow_run_id: int
workflow_run_name: str
def trigger_exists_in_workflow(workflow_definition: dict, trigger_path: str) -> bool:
"""Check if trigger node exists in workflow definition.
Args:
workflow_definition: The workflow definition JSON
trigger_path: The trigger UUID to look for
Returns:
True if trigger node exists, False otherwise
"""
nodes = workflow_definition.get("nodes", [])
for node in nodes:
if node.get("type") == "trigger":
if node.get("data", {}).get("trigger_path") == trigger_path:
return True
return False
@router.post("/{uuid}", response_model=TriggerCallResponse)
async def initiate_call(
uuid: str,
request: TriggerCallRequest,
x_api_key: str = Header(..., alias="X-API-Key"),
):
"""Initiate a phone call via API trigger.
This endpoint allows external systems (CRMs, automation tools, etc.) to
programmatically trigger outbound phone calls with custom context variables.
Args:
uuid: The unique trigger UUID
request: The call request with phone number and optional context
x_api_key: API key for authentication (passed in X-API-Key header)
Returns:
TriggerCallResponse with workflow run details
Raises:
HTTPException: Various error conditions (401, 403, 404, 400)
"""
# 1. Validate API key
api_key = await db_client.validate_api_key(x_api_key)
if not api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
# 2. Lookup agent trigger by UUID
trigger = await db_client.get_agent_trigger_by_path(uuid)
if not trigger:
raise HTTPException(status_code=404, detail="Agent trigger not found")
# 3. Validate organization match (API key org must match trigger org)
if api_key.organization_id != trigger.organization_id:
raise HTTPException(status_code=403, detail="Access denied")
# 4. Validate trigger is active
if trigger.state != TriggerState.ACTIVE.value:
raise HTTPException(status_code=404, detail="Agent trigger is not active")
# 4.5 Check Dograh quota before initiating the call
quota_result = await check_dograh_quota_by_user_id(api_key.created_by)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# 5. Get workflow and validate trigger exists in definition
workflow = await db_client.get_workflow_by_id(trigger.workflow_id)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
# Get workflow definition (with fallback to legacy field)
workflow_definition = workflow.workflow_definition_with_fallback
# Validate trigger node still exists in the workflow definition
if not trigger_exists_in_workflow(workflow_definition, uuid):
raise HTTPException(
status_code=404,
detail="Trigger not found or has been removed from workflow",
)
# 6. Get telephony provider for the organization
provider = await get_telephony_provider(trigger.organization_id)
# Validate provider is configured
if not provider.validate_config():
raise HTTPException(
status_code=400,
detail="Telephony provider not configured for this organization",
)
# 7. Determine the workflow run mode based on provider type
workflow_run_mode = provider.PROVIDER_NAME
# 8. Create workflow run
workflow_run_name = f"WR-API-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=trigger.workflow_id,
mode=workflow_run_mode,
initial_context={
"phone_number": request.phone_number,
"agent_uuid": uuid,
**(request.initial_context or {}),
},
user_id=api_key.created_by,
)
logger.info(
f"Created workflow run {workflow_run.id} for API trigger {uuid} "
f"to phone number {request.phone_number}"
)
# 9. Construct webhook URL for telephony provider callback
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={trigger.workflow_id}"
f"&user_id={api_key.created_by}"
f"&workflow_run_id={workflow_run.id}"
f"&organization_id={trigger.organization_id}"
)
# 10. Initiate call via telephony provider
result = await provider.initiate_call(
to_number=request.phone_number,
webhook_url=webhook_url,
workflow_run_id=workflow_run.id,
)
# 11. Store provider metadata in workflow run context
gathered_context = {
"provider": provider.PROVIDER_NAME,
"triggered_by": "api",
"trigger_uuid": uuid,
**(result.provider_metadata or {}),
}
await db_client.update_workflow_run(
run_id=workflow_run.id, gathered_context=gathered_context
)
logger.info(
f"Call initiated successfully for workflow run {workflow_run.id} "
f"via trigger {uuid}"
)
return TriggerCallResponse(
status="initiated",
workflow_run_id=workflow_run.id,
workflow_run_name=workflow_run_name,
)

View file

@ -18,6 +18,7 @@ from api.enums import WorkflowRunState
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
from api.services.quota_service import check_dograh_quota
from api.services.telephony.factory import get_telephony_provider
from api.utils.tunnel import TunnelURLProvider
from pipecat.utils.context import set_current_run_id
@ -100,6 +101,11 @@ async def initiate_call(
detail="telephony_not_configured",
)
# Check Dograh quota before initiating the call
quota_result = await check_dograh_quota(user)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Determine the workflow run mode based on provider type
workflow_run_mode = provider.PROVIDER_NAME
@ -234,7 +240,9 @@ async def websocket_endpoint(
logger.warning(
f"Workflow run {workflow_run_id} not in initialized state: {workflow_run.state}"
)
await websocket.close(code=4409, reason="Workflow run not available for connection")
await websocket.close(
code=4409, reason="Workflow run not available for connection"
)
return
# Extract provider type from workflow run context
@ -267,10 +275,9 @@ async def websocket_endpoint(
# Set workflow run state to 'running' before starting the pipeline
await db_client.update_workflow_run(
run_id=workflow_run_id,
state=WorkflowRunState.RUNNING.value
run_id=workflow_run_id, state=WorkflowRunState.RUNNING.value
)
logger.info(
f"[run {workflow_run_id}] Set workflow run state to 'running' for {provider_type} provider"
)
@ -382,9 +389,9 @@ async def _process_status_update(
# Mark workflow run as completed
await db_client.update_workflow_run(
run_id=workflow_run_id,
run_id=workflow_run_id,
is_completed=True,
state=WorkflowRunState.COMPLETED.value
state=WorkflowRunState.COMPLETED.value,
)
elif status.status in ["failed", "busy", "no-answer", "canceled"]:

View file

@ -22,9 +22,8 @@ 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.configuration.registry import ServiceProviders
from api.services.mps_service_key_client import mps_service_key_client
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
from api.services.quota_service import check_dograh_quota
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.utils.context import set_current_run_id
@ -67,75 +66,6 @@ class SignalingManager:
self._connections: Dict[str, WebSocket] = {}
self._peer_connections: Dict[str, SmallWebRTCConnection] = {}
async def _check_dograh_quota(self, user: UserModel) -> tuple[bool, str]:
"""Check if user has sufficient Dograh quota for making a call.
Args:
user_id: The user ID to check quota for
Returns:
Tuple of (has_quota, error_message)
- has_quota: True if user has sufficient quota or not using Dograh
- error_message: Error message if quota check fails, empty string otherwise
"""
try:
# Get user configurations
user_config = await db_client.get_user_configurations(user.id)
# Check if user is using any Dograh service
using_dograh = False
dograh_api_keys = set()
if user_config.llm and user_config.llm.provider == ServiceProviders.DOGRAH:
using_dograh = True
dograh_api_keys.add(user_config.llm.api_key)
if user_config.stt and user_config.stt.provider == ServiceProviders.DOGRAH:
using_dograh = True
dograh_api_keys.add(user_config.stt.api_key)
if user_config.tts and user_config.tts.provider == ServiceProviders.DOGRAH:
using_dograh = True
dograh_api_keys.add(user_config.tts.api_key)
# If not using Dograh, quota check passes
if not using_dograh:
return True, ""
# Check quota for ALL Dograh keys
for api_key in dograh_api_keys:
try:
usage = await mps_service_key_client.check_service_key_usage(
api_key, created_by=user.provider_id
)
remaining = usage.get("remaining_credits", 0.0)
# Require at least $0.10 for a short call
if remaining < 0.10:
logger.warning(
f"Insufficient Dograh credits for key ...{api_key[-8:]}: "
f"${remaining:.2f} remaining"
)
return False, (
"You have exhausted your trial credits."
"Please email founders@dograh.com for additional credits."
)
logger.info(
f"Dograh quota check passed for key ...{api_key[-8:]}: "
f"${remaining:.2f} remaining"
)
except Exception as e:
logger.error(f"Failed to check quota for Dograh key: {str(e)}")
return False, "Could not verify Dograh credits. Please try again."
return True, ""
except Exception as e:
logger.error(f"Error during quota check: {str(e)}")
# On unexpected error, allow the call to proceed
return True, ""
async def handle_websocket(
self,
websocket: WebSocket,
@ -210,15 +140,15 @@ class SignalingManager:
set_current_run_id(workflow_run_id)
# Check Dograh quota before initiating the call
has_quota, error_message = await self._check_dograh_quota(user)
if not has_quota:
quota_result = await check_dograh_quota(user)
if not quota_result.has_quota:
# Send error response for quota issues
await ws.send_json(
{
"type": "error",
"payload": {
"error_type": "quota_exceeded",
"message": error_message,
"message": quota_result.error_message,
},
}
)

View file

@ -1,4 +1,5 @@
import json
import uuid
from datetime import datetime
from typing import List, Literal, Optional
@ -18,6 +19,62 @@ from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.errors import ItemKind, WorkflowError
from api.services.workflow.workflow import WorkflowGraph
def extract_trigger_paths(workflow_definition: dict) -> List[str]:
"""Extract trigger UUIDs from workflow definition.
Args:
workflow_definition: The workflow definition JSON
Returns:
List of trigger UUIDs found in the workflow
"""
if not workflow_definition:
return []
nodes = workflow_definition.get("nodes", [])
trigger_paths = []
for node in nodes:
if node.get("type") == "trigger":
trigger_path = node.get("data", {}).get("trigger_path")
if trigger_path:
trigger_paths.append(trigger_path)
return trigger_paths
def regenerate_trigger_uuids(workflow_definition: dict) -> dict:
"""Regenerate UUIDs for all trigger nodes in a workflow definition.
This should be called when creating a new workflow from a template or
duplicating a workflow to avoid trigger UUID conflicts.
Args:
workflow_definition: The workflow definition JSON
Returns:
Updated workflow definition with new trigger UUIDs
"""
if not workflow_definition:
return workflow_definition
# Deep copy to avoid modifying the original
import copy
updated_definition = copy.deepcopy(workflow_definition)
nodes = updated_definition.get("nodes", [])
for node in nodes:
if node.get("type") == "trigger":
# Generate a new UUID for this trigger
if "data" not in node:
node["data"] = {}
node["data"]["trigger_path"] = str(uuid.uuid4())
return updated_definition
router = APIRouter(prefix="/workflow")
@ -181,6 +238,17 @@ async def create_workflow(
user.id,
user.selected_organization_id,
)
# Sync agent triggers if workflow definition contains any
if request.workflow_definition:
trigger_paths = extract_trigger_paths(request.workflow_definition)
if trigger_paths:
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
return {
"id": workflow.id,
"name": workflow.name,
@ -238,13 +306,27 @@ async def create_workflow_from_template(
)
# Create the workflow in our database
# Regenerate trigger UUIDs to avoid conflicts with existing triggers
workflow_def = regenerate_trigger_uuids(
workflow_data.get("workflow_definition", {})
)
workflow = await db_client.create_workflow(
name=workflow_data.get("name", f"{request.use_case} - {request.call_type}"),
workflow_definition=workflow_data.get("workflow_definition", {}),
workflow_definition=workflow_def,
user_id=user.id,
organization_id=user.selected_organization_id,
)
# Sync agent triggers if workflow definition contains any
if workflow_def:
trigger_paths = extract_trigger_paths(workflow_def)
if trigger_paths:
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
return {
"id": workflow.id,
"name": workflow.name,
@ -434,6 +516,16 @@ async def update_workflow(
workflow_configurations=request.workflow_configurations,
organization_id=user.selected_organization_id,
)
# Sync agent triggers if workflow definition was updated
if request.workflow_definition:
trigger_paths = extract_trigger_paths(request.workflow_definition)
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
return {
"id": workflow.id,
"name": workflow.name,
@ -645,13 +737,25 @@ async def duplicate_workflow_template(
)
# Create a new workflow from the template
# Regenerate trigger UUIDs to avoid conflicts with existing triggers
workflow_def = regenerate_trigger_uuids(template.template_json)
workflow = await db_client.create_workflow(
request.workflow_name,
template.template_json,
workflow_def,
user.id,
user.selected_organization_id,
)
# Sync agent triggers if template contains any
if workflow_def:
trigger_paths = extract_trigger_paths(workflow_def)
if trigger_paths:
await db_client.sync_triggers_for_workflow(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
trigger_paths=trigger_paths,
)
return {
"id": workflow.id,
"name": workflow.name,

View file

@ -19,7 +19,6 @@ from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
from pipecat.audio.vad.silero import SileroVADAnalyzer, VADParams
from pipecat.serializers.plivo import PlivoFrameSerializer
from pipecat.serializers.twilio import TwilioFrameSerializer
from pipecat.serializers.vobiz import VobizFrameSerializer
from pipecat.serializers.vonage import VonageFrameSerializer

View file

@ -0,0 +1,122 @@
"""Quota checking service for Dograh credits.
This module provides reusable quota checking functionality that can be used
across different endpoints (WebRTC signaling, telephony, public API triggers).
"""
from dataclasses import dataclass
from loguru import logger
from api.db import db_client
from api.db.models import UserModel
from api.services.configuration.registry import ServiceProviders
from api.services.mps_service_key_client import mps_service_key_client
@dataclass
class QuotaCheckResult:
"""Result of a quota check."""
has_quota: bool
error_message: str = ""
async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
"""Check if user has sufficient Dograh quota for making a call.
This function checks if the user is using any Dograh services (LLM, STT, TTS)
and validates that they have sufficient credits remaining.
Args:
user: The user to check quota for
Returns:
QuotaCheckResult with has_quota=True if user has sufficient quota or
is not using Dograh services, or has_quota=False with error_message
if quota is insufficient.
"""
try:
# Get user configurations
user_config = await db_client.get_user_configurations(user.id)
# Check if user is using any Dograh service
using_dograh = False
dograh_api_keys = set()
if user_config.llm and user_config.llm.provider == ServiceProviders.DOGRAH:
using_dograh = True
dograh_api_keys.add(user_config.llm.api_key)
if user_config.stt and user_config.stt.provider == ServiceProviders.DOGRAH:
using_dograh = True
dograh_api_keys.add(user_config.stt.api_key)
if user_config.tts and user_config.tts.provider == ServiceProviders.DOGRAH:
using_dograh = True
dograh_api_keys.add(user_config.tts.api_key)
# If not using Dograh, quota check passes
if not using_dograh:
return QuotaCheckResult(has_quota=True)
# Check quota for ALL Dograh keys
for api_key in dograh_api_keys:
try:
usage = await mps_service_key_client.check_service_key_usage(
api_key, created_by=user.provider_id
)
remaining = usage.get("remaining_credits", 0.0)
# Require at least $0.10 for a short call
if remaining < 0.10:
logger.warning(
f"Insufficient Dograh credits for key ...{api_key[-8:]}: "
f"${remaining:.2f} remaining"
)
return QuotaCheckResult(
has_quota=False,
error_message=(
"You have exhausted your trial credits. "
"Please email founders@dograh.com for additional Dograh credits "
"or change providers in Models configurations."
),
)
logger.info(
f"Dograh quota check passed for key ...{api_key[-8:]}: "
f"${remaining:.2f} remaining"
)
except Exception as e:
logger.error(f"Failed to check quota for Dograh key: {str(e)}")
return QuotaCheckResult(
has_quota=False,
error_message="Could not verify Dograh credits. Please try again.",
)
return QuotaCheckResult(has_quota=True)
except Exception as e:
logger.error(f"Error during quota check: {str(e)}")
# On unexpected error, allow the call to proceed
return QuotaCheckResult(has_quota=True)
async def check_dograh_quota_by_user_id(user_id: int) -> QuotaCheckResult:
"""Check Dograh quota by user ID.
Convenience function that fetches the user and then checks quota.
Args:
user_id: The ID of the user to check quota for
Returns:
QuotaCheckResult with quota status
"""
user = await db_client.get_user_by_id(user_id)
if not user:
return QuotaCheckResult(
has_quota=False,
error_message="User not found",
)
return await check_dograh_quota(user)

View file

@ -299,11 +299,11 @@ class VobizProvider(TelephonyProvider):
message handling to VobizFrameSerializer.
"""
from api.services.pipecat.run_pipeline import run_pipeline_vobiz
first_msg = await websocket.receive_text()
start_msg = json.loads(first_msg)
logger.debug(f"Received the first message: {start_msg}")
# Validate that this is a start event
if start_msg.get("event") != "start":
logger.error(f"Expected 'start' event, got: {start_msg.get('event')}")
@ -317,7 +317,7 @@ class VobizProvider(TelephonyProvider):
start_data = start_msg.get("start", {})
stream_id = start_data.get("streamId")
call_id = start_data.get("callId")
if not stream_id or not call_id:
logger.error(f"Missing streamId or callId in start event: {start_data}")
await websocket.close(code=4400, reason="Missing streamId or callId")

View file

@ -9,6 +9,8 @@ class NodeType(str, Enum):
endNode = "endCall"
agentNode = "agentNode"
globalNode = "globalNode"
trigger = "trigger"
webhook = "webhook"
class Position(BaseModel):
@ -28,9 +30,20 @@ class ExtractionVariableDTO(BaseModel):
prompt: Optional[str] = None
class CustomHeaderDTO(BaseModel):
key: str
value: str
class RetryConfigDTO(BaseModel):
enabled: bool = False
max_retries: int = 3
retry_delay_seconds: int = 5
class NodeDataDTO(BaseModel):
name: str = Field(..., min_length=1)
prompt: str = Field(..., min_length=1)
prompt: Optional[str] = Field(default=None)
is_static: bool = False
is_start: bool = False
is_end: bool = False
@ -44,6 +57,15 @@ class NodeDataDTO(BaseModel):
detect_voicemail: bool = True
delayed_start: bool = False
delayed_start_duration: Optional[float] = None
trigger_path: Optional[str] = None
# Webhook node specific fields
enabled: bool = True
http_method: Optional[str] = None
endpoint_url: Optional[str] = None
credential_uuid: Optional[str] = None
custom_headers: Optional[list[CustomHeaderDTO]] = None
payload_template: Optional[dict] = None
retry_config: Optional[RetryConfigDTO] = None
class RFNodeDTO(BaseModel):
@ -52,6 +74,14 @@ class RFNodeDTO(BaseModel):
position: Position
data: NodeDataDTO
@model_validator(mode="after")
def _validate_prompt_required(self):
"""Require prompt for all node types except trigger and webhook."""
if self.type not in (NodeType.trigger, NodeType.webhook):
if not self.data.prompt or len(self.data.prompt.strip()) == 0:
raise ValueError("Prompt is required for non-trigger nodes")
return self
class EdgeDataDTO(BaseModel):
label: str = Field(..., min_length=1)

View file

@ -1,227 +1,227 @@
import os
"""Execute webhook integrations after workflow run completion."""
import base64
from typing import Any, Dict
import aiohttp
import httpx
from loguru import logger
from api.db import db_client
from api.db.models import IntegrationModel
from api.enums import OrganizationConfigurationKey, WorkflowRunMode
from api.db.models import ExternalCredentialModel, WorkflowRunModel
from api.utils.template_renderer import render_template
from pipecat.utils.context import set_current_run_id
async def run_integrations_post_workflow_run(ctx, workflow_run_id: int):
async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int):
"""
Run integrations after a workflow run completes.
Run webhook integrations after a workflow run completes.
This function:
1. Gets the workflow run and its gathered_context
2. Determines the organization_id through the workflow -> user -> organization chain
3. Fetches all active integrations for that organization
4. For Slack integrations, sends the gathered_context to the webhook URL
Args:
workflow_run_id: The ID of the completed workflow run
1. Gets the workflow run and its contexts
2. Extracts webhook nodes from workflow definition
3. Executes each enabled webhook node
"""
# Set the workflow_run_id in context variable for consistent logging format
set_current_run_id(workflow_run_id)
logger.info("Running integrations for workflow run")
logger.info("Running webhook integrations for workflow run")
try:
# Step 1: Get workflow run details with gathered_context using DB client
# Step 1: Get workflow run with full context
workflow_run, organization_id = await db_client.get_workflow_run_with_context(
workflow_run_id
)
if not workflow_run:
logger.error("Workflow run not found")
if not workflow_run or not workflow_run.workflow:
logger.error("Workflow run or workflow not found")
return
if not workflow_run.workflow:
logger.error("Workflow not found for workflow run")
return
if not workflow_run.workflow.user:
logger.error("User not found for workflow run")
return
gathered_context = workflow_run.gathered_context
initial_context = workflow_run.initial_context
if not gathered_context:
logger.info("No gathered context for workflow run, skipping integrations")
return
# Check if workflow run mode is stasis and sync with vendor
if workflow_run.mode == WorkflowRunMode.STASIS.value:
await _sync_vendor_data(initial_context, gathered_context)
# Step 2: Check if organization_id is available
if not organization_id:
logger.warning(
f"No organization found for workflow run, skipping integrations"
)
logger.warning("No organization found, skipping webhooks")
return
logger.debug(f"Found organization_id {organization_id} for workflow run")
# Step 2: Get workflow definition
workflow_definition = workflow_run.workflow.workflow_definition_with_fallback
if not workflow_definition:
logger.debug("No workflow definition, skipping webhooks")
return
# Step 3: Get all active integrations for the organization using DB client
integrations = await db_client.get_active_integrations_by_organization(
organization_id
)
# Step 3: Extract webhook nodes
nodes = workflow_definition.get("nodes", [])
webhook_nodes = [n for n in nodes if n.get("type") == "webhook"]
logger.info(
f"Found {len(integrations)} active integrations for organization {organization_id}"
)
if not webhook_nodes:
logger.debug("No webhook nodes in workflow")
return
# Step 4: Process each integration
for integration in integrations:
await _process_integration(integration, gathered_context)
logger.info(f"Found {len(webhook_nodes)} webhook nodes to execute")
# Step 4: Build render context
render_context = _build_render_context(workflow_run)
# Step 5: Execute each webhook node
for node in webhook_nodes:
webhook_data = node.get("data", {})
try:
await _execute_webhook_node(
webhook_data=webhook_data,
render_context=render_context,
organization_id=organization_id,
)
except Exception as e:
# Log error but continue with other webhooks
logger.error(
f"Failed to execute webhook '{webhook_data.get('name', 'unknown')}': {e}"
)
except Exception as e:
logger.error(f"Error running integrations for workflow run: {str(e)}")
logger.error(f"Error running webhook integrations: {e}", exc_info=True)
raise
async def _sync_vendor_data(initial_context: dict, gathered_context: dict):
def _build_render_context(workflow_run: WorkflowRunModel) -> Dict[str, Any]:
"""Build the context dict for template rendering."""
return {
# Top-level fields
"workflow_run_id": workflow_run.id,
"workflow_run_name": workflow_run.name,
"workflow_id": workflow_run.workflow_id,
"workflow_name": workflow_run.workflow.name if workflow_run.workflow else None,
# Nested contexts
"initial_context": workflow_run.initial_context or {},
"gathered_context": workflow_run.gathered_context or {},
"cost_info": workflow_run.usage_info or {},
"recording_url": getattr(workflow_run, "recording_url", None),
"transcript_url": getattr(workflow_run, "transcript_url", None),
}
async def _execute_webhook_node(
webhook_data: Dict[str, Any],
render_context: Dict[str, Any],
organization_id: int,
) -> bool:
"""
Sync data with external vendor for stasis mode workflow runs.
Execute a single webhook node.
Args:
initial_context: The initial context containing lead_id
gathered_context: The gathered context containing mapped_call_disposition
webhook_data: The webhook node's data dict from workflow definition
render_context: Context for template rendering
organization_id: For credential lookup
Returns:
True if successful, False otherwise
"""
if not os.getenv("ARI_DATA_SYNCING_URI"):
logger.info("ARI_DATA_SYNCING_URI not configured, skipping vendor sync")
return
webhook_name = webhook_data.get("name", "Unnamed Webhook")
try:
lead_id = initial_context.get("lead_id")
status = gathered_context.get("mapped_call_disposition")
# 1. Check if enabled
if not webhook_data.get("enabled", True):
logger.debug(f"Webhook '{webhook_name}' is disabled, skipping")
return True
if lead_id and status:
ari_data_uri = os.getenv("ARI_DATA_SYNCING_URI")
# Add URL params to the base URL
sync_url = f"{ari_data_uri}&lead_id={lead_id}&status={status}"
# 2. Validate endpoint URL
url = webhook_data.get("endpoint_url")
if not url:
logger.error(f"Webhook '{webhook_name}' has no endpoint URL")
return False
async with httpx.AsyncClient() as client:
response = await client.post(sync_url, timeout=10.0)
response.raise_for_status()
logger.info(
f"Successfully synced data for lead_id: {lead_id} with status: {status}"
)
# 3. Build headers
headers = {"Content-Type": "application/json"}
# 4. Add auth header if credential configured
credential_uuid = webhook_data.get("credential_uuid")
if credential_uuid:
credential = await db_client.get_credential_by_uuid(
credential_uuid, organization_id
)
if credential:
auth_header = _build_auth_header(credential)
headers.update(auth_header)
logger.debug(f"Applied credential '{credential.name}' to webhook")
else:
logger.warning(
f"Missing lead_id or status for syncing - lead_id: {lead_id}, status: {status}"
f"Credential {credential_uuid} not found for webhook '{webhook_name}'"
)
except Exception as e:
logger.error(f"Failed to sync data to ARI_DATA_SYNCING_URI: {e}")
# 5. Add custom headers
custom_headers = webhook_data.get("custom_headers", [])
for h in custom_headers:
if h.get("key") and h.get("value"):
headers[h["key"]] = h["value"]
async def _process_integration(
integration: IntegrationModel,
gathered_context: dict,
):
"""
Process a single integration.
# 6. Render payload template
payload_template = webhook_data.get("payload_template", {})
payload = render_template(payload_template, render_context)
Args:
integration: The integration model
gathered_context: The gathered context from the workflow run
workflow_run_name: Name of the workflow run
run_id: The workflow run ID for logging context
"""
logger.info(
f"Processing integration {integration.id} (provider: {integration.provider})"
)
# 7. Make HTTP request
method = webhook_data.get("http_method", "POST").upper()
logger.info(f"Executing webhook '{webhook_name}': {method}")
try:
if integration.provider.lower() == "slack":
await _process_slack_integration(integration, gathered_context)
else:
logger.info(
f"Integration provider '{integration.provider}' not supported yet"
)
async with httpx.AsyncClient() as client:
if method in ("POST", "PUT", "PATCH"):
response = await client.request(
method=method,
url=url,
json=payload,
headers=headers,
timeout=30.0,
)
else: # GET, DELETE
response = await client.request(
method=method,
url=url,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
logger.info(f"Webhook '{webhook_name}' succeeded: {response.status_code}")
return True
except httpx.HTTPStatusError as e:
logger.error(
f"Webhook '{webhook_name}' failed: {e.response.status_code} - {e.response.text[:200]}"
)
return False
except httpx.RequestError as e:
logger.error(f"Webhook '{webhook_name}' request error: {e}")
return False
except Exception as e:
logger.error(f"Error processing integration {integration.id}: {str(e)}")
logger.error(f"Webhook '{webhook_name}' unexpected error: {e}")
return False
async def _process_slack_integration(
integration: IntegrationModel, gathered_context: dict
):
def _build_auth_header(credential: ExternalCredentialModel) -> Dict[str, str]:
"""
Process a Slack integration by sending gathered_context to the webhook.
Build authentication header based on credential type.
Args:
integration: The Slack integration model
gathered_context: The gathered context from the workflow run
workflow_run_name: Name of the workflow run
run_id: The workflow run ID for logging context
credential: The credential model
Returns:
Dict with header name and value
"""
logger.info(f"Processing Slack integration {integration.id}")
cred_type = credential.credential_type
cred_data = credential.credential_data or {}
# TODO: Generalise this
if gathered_context.get("mapped_call_disposition") != "XFER":
logger.debug(
f"Not sending message on slack since not XFER: {gathered_context.get('mapped_call_disposition')}"
)
return
if cred_type == "bearer_token":
token = cred_data.get("token", "")
return {"Authorization": f"Bearer {token}"}
try:
# Extract webhook URL from connection_details
connection_details = integration.connection_details
elif cred_type == "api_key":
header_name = cred_data.get("header_name", "X-API-Key")
api_key = cred_data.get("api_key", "")
return {header_name: api_key}
if not connection_details:
logger.error(
f"No connection details found for Slack integration {integration.id}"
)
return
elif cred_type == "basic_auth":
username = cred_data.get("username", "")
password = cred_data.get("password", "")
encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {encoded}"}
# Navigate to incoming_webhook.url in the connection_details
webhook_url = connection_details.get("connection_config", {}).get(
"incoming_webhook.url"
)
if not webhook_url:
logger.error(
f"No incoming_webhook found in connection details for integration {integration.id}"
)
return
elif cred_type == "custom_header":
header_name = cred_data.get("header_name", "X-Custom")
header_value = cred_data.get("header_value", "")
return {header_name: header_value}
logger.info(f"Found Slack webhook URL for integration {integration.id}")
# Get message template configuration
# Get organization_id from the integration model
organization_id = integration.organisation_id
message_templates = await db_client.get_configuration_value(
organization_id,
OrganizationConfigurationKey.DISPOSITION_MESSAGE_TEMPLATE.value,
default={},
)
# Check if there's a custom template for Slack
slack_template = message_templates.get("slack", {})
rendered_text = render_template(slack_template, gathered_context)
slack_message = {"text": rendered_text}
# Send to Slack webhook
async with aiohttp.ClientSession() as session:
async with session.post(
webhook_url,
json=slack_message,
headers={"Content-Type": "application/json"},
) as response:
if response.status == 200:
logger.info(
f"Successfully sent message to Slack for integration {integration.id}"
)
else:
error_text = await response.text()
logger.error(
f"Failed to send Slack message for integration {integration.id}: {response.status} - {error_text}"
)
except Exception as e:
logger.error(f"Error processing Slack integration {integration.id}: {str(e)}")
return {}

View file

@ -1,136 +1,330 @@
"""Tests for webhook execution in run_integrations.py."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.tasks.run_integrations import _process_slack_integration
from api.tasks.run_integrations import (
_build_auth_header,
_build_render_context,
_execute_webhook_node,
)
@pytest.fixture(autouse=True)
def mock_logger():
"""Mock the logger for all tests."""
with patch("api.tasks.run_integrations.logger") as mock_logger:
# Mock the bind method to return the logger itself
mock_logger.bind.return_value = mock_logger
yield mock_logger
with patch("api.tasks.run_integrations.logger") as mock_log:
mock_log.bind.return_value = mock_log
yield mock_log
@pytest.mark.asyncio
async def test_slack_integration_with_template():
"""Test that Slack integration uses render_template correctly."""
# Mock integration
mock_integration = MagicMock()
mock_integration.id = 1
mock_integration.organisation_id = 123
mock_integration.connection_details = {
"connection_config": {"incoming_webhook.url": "https://hooks.slack.com/test"}
}
class TestBuildAuthHeader:
"""Tests for _build_auth_header function."""
# Mock gathered context
gathered_context = {
"call_disposition": "XFER",
"mapped_call_disposition": "XFER", # Required for Slack integration to proceed
"call_duration": "300",
"agent_name": "Alex",
}
def test_bearer_token(self):
"""Test bearer token auth header."""
credential = MagicMock()
credential.credential_type = "bearer_token"
credential.credential_data = {"token": "my-secret-token"}
# Mock db_client
with patch("api.tasks.run_integrations.db_client") as mock_db_client:
# Mock message template configuration
mock_db_client.get_configuration_value = AsyncMock(
return_value={
"slack": {
"DISPOSITION_CODE": "Agent: {{agent_name}}\\nDisposition: {{call_disposition}}\\nDuration: {{call_duration}}s"
}
}
result = _build_auth_header(credential)
assert result == {"Authorization": "Bearer my-secret-token"}
def test_api_key(self):
"""Test API key auth header."""
credential = MagicMock()
credential.credential_type = "api_key"
credential.credential_data = {"header_name": "X-API-Key", "api_key": "key123"}
result = _build_auth_header(credential)
assert result == {"X-API-Key": "key123"}
def test_api_key_default_header(self):
"""Test API key with default header name."""
credential = MagicMock()
credential.credential_type = "api_key"
credential.credential_data = {"api_key": "key123"}
result = _build_auth_header(credential)
assert result == {"X-API-Key": "key123"}
def test_basic_auth(self):
"""Test basic auth header."""
credential = MagicMock()
credential.credential_type = "basic_auth"
credential.credential_data = {"username": "user", "password": "pass"}
result = _build_auth_header(credential)
# base64 of "user:pass" is "dXNlcjpwYXNz"
assert result == {"Authorization": "Basic dXNlcjpwYXNz"}
def test_custom_header(self):
"""Test custom header auth."""
credential = MagicMock()
credential.credential_type = "custom_header"
credential.credential_data = {
"header_name": "X-Custom-Auth",
"header_value": "custom-value",
}
result = _build_auth_header(credential)
assert result == {"X-Custom-Auth": "custom-value"}
def test_unknown_type(self):
"""Test unknown credential type returns empty dict."""
credential = MagicMock()
credential.credential_type = "unknown"
credential.credential_data = {}
result = _build_auth_header(credential)
assert result == {}
class TestBuildRenderContext:
"""Tests for _build_render_context function."""
def test_basic_context(self):
"""Test building render context from workflow run."""
workflow_run = MagicMock()
workflow_run.id = 123
workflow_run.name = "WR-TEST-001"
workflow_run.workflow_id = 456
workflow_run.workflow.name = "Test Workflow"
workflow_run.initial_context = {"phone_number": "+1234567890"}
workflow_run.gathered_context = {
"customer_name": "John",
"mapped_call_disposition": "QUALIFIED",
}
workflow_run.usage_info = {"call_duration_seconds": 120}
workflow_run.completed_at = None
result = _build_render_context(workflow_run)
assert result["workflow_run_id"] == 123
assert result["workflow_run_name"] == "WR-TEST-001"
assert result["workflow_id"] == 456
assert result["workflow_name"] == "Test Workflow"
assert result["initial_context"]["phone_number"] == "+1234567890"
assert result["gathered_context"]["customer_name"] == "John"
assert result["cost_info"]["call_duration_seconds"] == 120
assert result["disposition_code"] == "QUALIFIED"
def test_empty_contexts(self):
"""Test with empty/None contexts."""
workflow_run = MagicMock()
workflow_run.id = 1
workflow_run.name = "Test"
workflow_run.workflow_id = 1
workflow_run.workflow.name = "Workflow"
workflow_run.initial_context = None
workflow_run.gathered_context = None
workflow_run.usage_info = None
workflow_run.completed_at = None
result = _build_render_context(workflow_run)
assert result["initial_context"] == {}
assert result["gathered_context"] == {}
assert result["cost_info"] == {}
assert result["disposition_code"] is None
class TestExecuteWebhookNode:
"""Tests for _execute_webhook_node function."""
@pytest.mark.asyncio
async def test_disabled_webhook_skipped(self):
"""Test that disabled webhooks are skipped."""
webhook_data = {"name": "Test Webhook", "enabled": False}
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
# Mock aiohttp session
with patch(
"api.tasks.run_integrations.aiohttp.ClientSession"
) as mock_session_class:
mock_response = MagicMock()
mock_response.status = 200
assert result is True # Returns True for skipped webhooks
mock_session = MagicMock()
mock_session.__aenter__.return_value = mock_session
mock_session.__aexit__.return_value = AsyncMock()
@pytest.mark.asyncio
async def test_missing_url_returns_false(self):
"""Test that missing endpoint URL returns False."""
webhook_data = {"name": "Test Webhook", "enabled": True, "endpoint_url": None}
mock_post = MagicMock()
mock_post.__aenter__.return_value = mock_response
mock_post.__aexit__.return_value = AsyncMock()
mock_session.post.return_value = mock_post
mock_session_class.return_value = mock_session
# Call the function
await _process_slack_integration(mock_integration, gathered_context)
# Verify the message was formatted correctly
mock_session.post.assert_called_once()
call_args = mock_session.post.call_args
# Check the webhook URL
assert call_args[0][0] == "https://hooks.slack.com/test"
# Check the message content
json_data = call_args[1]["json"]
# Check that the template was rendered correctly
expected_text = "Agent: Alex\nDisposition: XFER\nDuration: 300s"
assert json_data["text"] == expected_text
@pytest.mark.asyncio
async def test_slack_integration_with_missing_template_vars():
"""Test template rendering with missing variables."""
# Mock integration
mock_integration = MagicMock()
mock_integration.id = 1
mock_integration.organisation_id = 123
mock_integration.connection_details = {
"connection_config": {"incoming_webhook.url": "https://hooks.slack.com/test"}
}
# Mock gathered context with missing values
gathered_context = {
"call_disposition": "XFER",
"mapped_call_disposition": "XFER", # Required for Slack integration to proceed
# call_duration is missing
}
# Mock db_client
with patch("api.tasks.run_integrations.db_client") as mock_db_client:
# Mock message template configuration with fallback
mock_db_client.get_configuration_value = AsyncMock(
return_value={
"slack": {
"DISPOSITION_CODE": "Disposition: {{call_disposition}}\\nDuration: {{call_duration | fallback:N/A}}"
}
}
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
# Mock aiohttp session
with patch(
"api.tasks.run_integrations.aiohttp.ClientSession"
) as mock_session_class:
mock_response = MagicMock()
mock_response.status = 200
assert result is False
mock_session = MagicMock()
mock_session.__aenter__.return_value = mock_session
mock_session.__aexit__.return_value = AsyncMock()
@pytest.mark.asyncio
async def test_successful_post_request(self):
"""Test successful POST webhook execution."""
webhook_data = {
"name": "CRM Sync",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"payload_template": {
"call_id": "{{workflow_run_id}}",
"phone": "{{initial_context.phone_number}}",
},
}
mock_post = MagicMock()
mock_post.__aenter__.return_value = mock_response
mock_post.__aexit__.return_value = AsyncMock()
render_context = {
"workflow_run_id": 123,
"initial_context": {"phone_number": "+1234567890"},
}
mock_session.post.return_value = mock_post
mock_session_class.return_value = mock_session
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=None)
# Call the function
await _process_slack_integration(mock_integration, gathered_context)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
# Check that the template was rendered with fallback
json_data = mock_session.post.call_args[1]["json"]
expected_text = "Disposition: XFER\nDuration: N/A"
assert json_data["text"] == expected_text
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context=render_context,
organization_id=1,
)
assert result is True
# Verify the request was made correctly
mock_client_instance.request.assert_called_once()
call_kwargs = mock_client_instance.request.call_args[1]
assert call_kwargs["method"] == "POST"
assert call_kwargs["url"] == "https://api.example.com/webhook"
assert call_kwargs["json"] == {
"call_id": "123",
"phone": "+1234567890",
}
@pytest.mark.asyncio
async def test_webhook_with_credential(self):
"""Test webhook execution with credential auth."""
webhook_data = {
"name": "Authenticated Webhook",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"credential_uuid": "cred-123",
"payload_template": {},
}
mock_credential = MagicMock()
mock_credential.name = "API Key"
mock_credential.credential_type = "bearer_token"
mock_credential.credential_data = {"token": "secret-token"}
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=mock_credential)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
assert result is True
# Verify auth header was included
call_kwargs = mock_client_instance.request.call_args[1]
assert call_kwargs["headers"]["Authorization"] == "Bearer secret-token"
@pytest.mark.asyncio
async def test_webhook_with_custom_headers(self):
"""Test webhook execution with custom headers."""
webhook_data = {
"name": "Custom Headers Webhook",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"custom_headers": [
{"key": "X-Source", "value": "dograh"},
{"key": "X-Workflow", "value": "test"},
],
"payload_template": {},
}
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=None)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
assert result is True
# Verify custom headers were included
call_kwargs = mock_client_instance.request.call_args[1]
assert call_kwargs["headers"]["X-Source"] == "dograh"
assert call_kwargs["headers"]["X-Workflow"] == "test"
@pytest.mark.asyncio
async def test_webhook_http_error(self):
"""Test webhook execution with HTTP error."""
import httpx
webhook_data = {
"name": "Failing Webhook",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"payload_template": {},
}
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=None)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
"Server Error",
request=MagicMock(),
response=mock_response,
)
)
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
assert result is False

View file

@ -1,46 +1,126 @@
"""Common template rendering utility."""
"""Template rendering utility with support for nested JSON paths."""
import json
import re
from typing import Any, Dict
from typing import Any, Dict, Union
def render_template(template_str: str, template_var_mapping: Dict[str, Any]) -> str: # noqa: C901 complex but self-contained
"""Replace template placeholders in *template_str* with values from *template_var_mapping*.
Supported syntax:
* ``{{ variable_name }}``
* ``{{ variable_name | fallback }}``
* ``{{ variable_name | fallback:default_value }}``
If the variable is undefined and a *fallback* filter is specified the value
of *default_value* (or the *variable_name* itself if no default is given)
is used instead.
def get_nested_value(obj: Any, path: str) -> Any:
"""
Get a nested value from a dictionary using dot notation.
Args:
obj: The object to traverse (dict or any)
path: Dot-separated path (e.g., "a.b.c")
Returns:
The value at the path, or None if not found
Examples:
get_nested_value({"a": {"b": 1}}, "a.b") -> 1
get_nested_value({"a": {"b": {"c": 2}}}, "a.b.c") -> 2
get_nested_value({"a": 1}, "a.b") -> None
"""
if not path:
return obj
keys = path.split(".")
current = obj
for key in keys:
if isinstance(current, dict):
current = current.get(key)
else:
return None
if current is None:
return None
return current
def render_template(
template: Union[str, dict, list, None],
context: Dict[str, Any],
) -> Union[str, dict, list, None]: # noqa: C901 complex but self-contained
"""
Render a template with variable substitution supporting nested paths.
Supports:
- String templates: "Hello {{name}}"
- JSON templates: {"key": "{{value}}"}
- Nested paths: "{{initial_context.phone_number}}"
- Deep nesting: "{{gathered_context.customer.address.city}}"
- Fallback: "{{name | fallback:Unknown}}"
Args:
template: String, dict, list, or None with {{variable}} placeholders
context: Dict containing all available variables
Returns:
Rendered template with variables replaced
"""
if template is None:
return None
# Handle dict templates recursively
if isinstance(template, dict):
return {
_render_string(str(k), context)
if isinstance(k, str)
else k: render_template(v, context)
for k, v in template.items()
}
# Handle list templates recursively
if isinstance(template, list):
return [render_template(item, context) for item in template]
# Handle non-string types (int, float, bool, etc.)
if not isinstance(template, str):
return template
return _render_string(template, context)
def _render_string(template_str: str, context: Dict[str, Any]) -> str:
"""
Render a string template with variable substitution.
Args:
template_str: String with {{variable}} placeholders
context: Dict containing all available variables
Returns:
Rendered string with variables replaced
"""
if not template_str:
return template_str
# Regex matches e.g. ``{{ name }}``, ``{{ name | fallback }}``, ``{{ name | fallback:John }}``
# Pattern: {{ path }} or {{ path | filter }} or {{ path | filter:default }}
pattern = r"\{\{\s*([^|\s}]+)(?:\s*\|\s*([^:}]+)(?::([^}]+))?)?\s*\}\}"
def _replace(match: re.Match[str]) -> str: # type: ignore[type-arg]
variable_name = match.group(1).strip()
variable_path = match.group(1).strip()
filter_name = match.group(2).strip() if match.group(2) else None
filter_value = match.group(3).strip() if match.group(3) else None
# Pull value from context
value = template_var_mapping.get(variable_name)
# Get value using nested path lookup
value = get_nested_value(context, variable_path)
# Apply filters
if filter_name == "fallback":
if value is None or value == "":
# Use explicit default value or a title-cased variable name.
value = (
filter_value if filter_value is not None else variable_name.title()
filter_value if filter_value is not None else variable_path.title()
)
# Convert *None* to an empty string so that re.sub replacement works.
return str(value) if value is not None else ""
# Convert to string for substitution
if value is None:
return ""
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
# Replace template variables
result = re.sub(pattern, _replace, template_str)

@ -1 +1 @@
Subproject commit 322116d74f4568804b0f11000e187b2b964bde56
Subproject commit 06abb8ef6b7d410cdd6ad37595dd9322204c3c7c

View file

@ -16,7 +16,7 @@ import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import { AgentNode, EndCall, GlobalNode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { EmbedDialog } from './components/EmbedDialog';
import { PhoneCallDialog } from './components/PhoneCallDialog';
@ -32,6 +32,8 @@ const nodeTypes = {
[NodeType.AGENT_NODE]: AgentNode,
[NodeType.END_CALL]: EndCall,
[NodeType.GLOBAL_NODE]: GlobalNode,
[NodeType.TRIGGER]: TriggerNode,
[NodeType.WEBHOOK]: WebhookNode,
};
const edgeTypes = {

View file

@ -1,7 +1,7 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { ArrowLeft, ChevronDown, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
import { AlertCircle, ArrowLeft, ChevronDown, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
@ -14,6 +14,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
interface WorkflowEditorHeaderProps {
@ -103,6 +108,52 @@ export const WorkflowEditorHeader = ({
</div>
)}
{/* Validation errors indicator */}
{hasValidationErrors && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 transition-colors cursor-pointer">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-500">
{workflowValidationErrors.length} {workflowValidationErrors.length === 1 ? "error" : "errors"}
</span>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-80 bg-[#1a1a1a] border-[#3a3a3a] p-0"
>
<div className="px-4 py-3 border-b border-[#3a3a3a]">
<h3 className="text-sm font-medium text-white">Validation Errors</h3>
</div>
<div className="max-h-64 overflow-y-auto">
{workflowValidationErrors.map((error, index) => (
<div
key={index}
className="px-4 py-3 border-b border-[#2a2a2a] last:border-b-0"
>
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
{(error.kind === "node" || error.kind === "edge") && error.id && (
<p className="text-xs text-gray-400 mb-1">
{error.kind === "node" ? "Node" : "Edge"}: {error.id}
{error.field && <span className="text-gray-500"> {error.field}</span>}
</p>
)}
<p className="text-sm text-white break-words">
{error.message}
</p>
</div>
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
)}
{/* Call button with dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -48,7 +48,8 @@ const defaultNodes: FlowNode[] = [
];
const getNewNode = (type: string, position: { x: number, y: number }, existingNodes: FlowNode[]) => {
return {
// Base node configuration
const baseNode = {
id: getNextNodeId(existingNodes),
type,
position,
@ -60,10 +61,28 @@ const getNewNode = (type: string, position: { x: number, y: number }, existingNo
[NodeType.GLOBAL_NODE]: "Global Node",
[NodeType.START_CALL]: "Start Call",
[NodeType.END_CALL]: "End Call",
[NodeType.WEBHOOK]: "Webhook",
}[type] || "",
allow_interrupt: getDefaultAllowInterrupt(type),
},
};
// Add webhook-specific defaults
if (type === NodeType.WEBHOOK) {
return {
...baseNode,
data: {
...baseNode.data,
enabled: true,
http_method: "POST" as const,
endpoint_url: "",
custom_headers: [],
payload_template: {},
},
};
}
return baseNode;
};
interface UseWorkflowStateProps {

View file

@ -1,7 +1,13 @@
import dagre from '@dagrejs/dagre';
import { ReactFlowInstance } from "@xyflow/react";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
// Node dimensions
const NODE_WIDTH = 350;
const NODE_HEIGHT = 120;
const VERTICAL_SPACING = 150; // Vertical spacing between stacked nodes
const SECTION_HORIZONTAL_GAP = 500; // Horizontal gap between sections
export const layoutNodes = (
nodes: FlowNode[],
@ -9,51 +15,75 @@ export const layoutNodes = (
rankdir: 'TB' | 'LR',
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>
) => {
// Separate nodes by type
const triggerNodes = nodes.filter(n => n.type === NodeType.TRIGGER);
const webhookNodes = nodes.filter(n => n.type === NodeType.WEBHOOK);
const globalNodes = nodes.filter(n => n.type === NodeType.GLOBAL_NODE || n.type === 'global');
const workflowNodes = nodes.filter(n =>
n.type === NodeType.START_CALL ||
n.type === NodeType.AGENT_NODE ||
n.type === NodeType.END_CALL ||
n.type === 'startCall' ||
n.type === 'agentNode' ||
n.type === 'endCall'
);
// If no workflow nodes, just return original nodes
if (workflowNodes.length === 0) {
return nodes;
}
// Layout workflow nodes using dagre
const g = new dagre.graphlib.Graph();
// For TB (top-to-bottom) layout:
// - nodesep: horizontal spacing between nodes at the same depth level
// - ranksep: vertical spacing between depth levels
g.setGraph({ rankdir, nodesep: 400, ranksep: 300 });
g.setDefaultEdgeLabel(() => ({}));
// Sort nodes so startCall nodes come first and endCall nodes come last
const sortedNodes = [...nodes].sort((a, b) => {
if (a.type === 'startCall') return -1;
if (b.type === 'startCall') return 1;
if (a.type === 'endCall') return 1;
if (b.type === 'endCall') return -1;
// Sort workflow nodes so startCall comes first and endCall comes last
const sortedWorkflowNodes = [...workflowNodes].sort((a, b) => {
if (a.type === 'startCall' || a.type === NodeType.START_CALL) return -1;
if (b.type === 'startCall' || b.type === NodeType.START_CALL) return 1;
if (a.type === 'endCall' || a.type === NodeType.END_CALL) return 1;
if (b.type === 'endCall' || b.type === NodeType.END_CALL) return -1;
return 0;
});
// Use larger node dimensions to account for actual rendered size
// This prevents overlapping when dagre calculates positions
sortedNodes.forEach((node) => {
g.setNode(node.id, { width: 350, height: 120 });
sortedWorkflowNodes.forEach((node) => {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach((edge) => {
// Only include edges between workflow nodes
const workflowNodeIds = new Set(workflowNodes.map(n => n.id));
const workflowEdges = edges.filter(e =>
workflowNodeIds.has(e.source) && workflowNodeIds.has(e.target)
);
workflowEdges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
// Group nodes by their Y position (rank/depth level)
// Group workflow nodes by their Y position (rank/depth level)
const nodesByRank = new Map<number, { node: FlowNode; dagreNode: dagre.Node }[]>();
sortedNodes.forEach((node) => {
sortedWorkflowNodes.forEach((node) => {
const dagreNode = g.node(node.id);
const rankY = Math.round(dagreNode.y / 50) * 50; // Round to group nearby Y values
const rankY = Math.round(dagreNode.y / 50) * 50;
if (!nodesByRank.has(rankY)) {
nodesByRank.set(rankY, []);
}
nodesByRank.get(rankY)!.push({ node, dagreNode });
});
// Calculate horizontal offset for zigzag pattern
// Nodes at each rank level get staggered left/right
const horizontalStagger = 600; // How much to offset alternating ranks
const horizontalStagger = 600;
const ranks = Array.from(nodesByRank.keys()).sort((a, b) => a - b);
const newNodes = sortedNodes.map((node) => {
// Calculate workflow bounds
let workflowMinX = Infinity;
let workflowMaxX = -Infinity;
let workflowMinY = Infinity;
let workflowMaxY = -Infinity;
const positionedWorkflowNodes = sortedWorkflowNodes.map((node) => {
const dagreNode = g.node(node.id);
const rankY = Math.round(dagreNode.y / 50) * 50;
const rankIndex = ranks.indexOf(rankY);
@ -61,25 +91,91 @@ export const layoutNodes = (
let xOffset = 0;
// Apply zigzag pattern: alternate ranks offset left/right
// But only if there's a single node at this rank (linear chain)
// Apply zigzag pattern for single nodes at each rank
if (nodesAtRank.length === 1) {
// Skip startCall (keep centered) and endCall (keep centered)
if (node.type !== 'startCall' && node.type !== 'endCall' && node.type !== 'global') {
if (node.type !== 'startCall' && node.type !== NodeType.START_CALL &&
node.type !== 'endCall' && node.type !== NodeType.END_CALL) {
xOffset = (rankIndex % 2 === 0) ? -horizontalStagger : horizontalStagger;
}
}
const x = dagreNode.x + xOffset;
const y = dagreNode.y;
workflowMinX = Math.min(workflowMinX, x);
workflowMaxX = Math.max(workflowMaxX, x + NODE_WIDTH);
workflowMinY = Math.min(workflowMinY, y);
workflowMaxY = Math.max(workflowMaxY, y + NODE_HEIGHT);
return {
...node,
position: { x, y }
};
});
// Calculate center Y of the workflow for vertical alignment
const workflowCenterY = (workflowMinY + workflowMaxY) / 2;
const workflowTopY = workflowMinY;
// Position global nodes to the left of the workflow, close to it
const globalNodesX = workflowMinX - SECTION_HORIZONTAL_GAP;
const positionedGlobalNodes = globalNodes.map((node, index) => {
const totalHeight = globalNodes.length * NODE_HEIGHT + (globalNodes.length - 1) * VERTICAL_SPACING;
const startY = workflowCenterY - totalHeight / 2;
return {
...node,
position: {
x: dagreNode.x + xOffset,
y: dagreNode.y
x: globalNodesX,
y: startY + index * (NODE_HEIGHT + VERTICAL_SPACING)
}
};
});
// Fit view to the new layout and save the viewport position
// Position trigger nodes to the left of global nodes (or workflow if no global)
const triggerNodesX = globalNodes.length > 0
? globalNodesX - SECTION_HORIZONTAL_GAP
: workflowMinX - SECTION_HORIZONTAL_GAP;
const positionedTriggerNodes = triggerNodes.map((node, index) => {
const totalHeight = triggerNodes.length * NODE_HEIGHT + (triggerNodes.length - 1) * VERTICAL_SPACING;
const startY = workflowTopY + (workflowMaxY - workflowMinY) / 2 - totalHeight / 2;
return {
...node,
position: {
x: triggerNodesX,
y: startY + index * (NODE_HEIGHT + VERTICAL_SPACING)
}
};
});
// Position webhook nodes to the right of the workflow
const webhookNodesX = workflowMaxX + SECTION_HORIZONTAL_GAP;
const positionedWebhookNodes = webhookNodes.map((node, index) => {
const totalHeight = webhookNodes.length * NODE_HEIGHT + (webhookNodes.length - 1) * VERTICAL_SPACING;
const startY = workflowCenterY - totalHeight / 2;
return {
...node,
position: {
x: webhookNodesX,
y: startY + index * (NODE_HEIGHT + VERTICAL_SPACING)
}
};
});
// Combine all positioned nodes
const allPositionedNodes = [
...positionedTriggerNodes,
...positionedGlobalNodes,
...positionedWorkflowNodes,
...positionedWebhookNodes
];
// Create a map for quick lookup
const positionedNodeMap = new Map(allPositionedNodes.map(n => [n.id, n]));
// Return nodes in original order but with new positions
const newNodes = nodes.map(node => positionedNodeMap.get(node.id) || node);
// Fit view to the new layout
setTimeout(() => {
rfInstance.current?.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);

File diff suppressed because one or more lines are too long

View file

@ -100,6 +100,18 @@ export type CreateCampaignRequest = {
source_id: string;
};
/**
* Request schema for creating a webhook credential.
*/
export type CreateCredentialRequest = {
name: string;
description?: string | null;
credential_type: WebhookCredentialType;
credential_data: {
[key: string]: unknown;
};
};
export type CreateLoadTestRequest = {
name_prefix: string;
actor_workflow_id: number;
@ -162,6 +174,18 @@ export type CreateWorkflowTemplateRequest = {
activity_description: string;
};
/**
* Response schema for a webhook credential (never includes sensitive data).
*/
export type CredentialResponse = {
uuid: string;
name: string;
description: string | null;
credential_type: string;
created_at: string;
updated_at: string | null;
};
export type CurrentUsageResponse = {
period_start: string;
period_end: string;
@ -476,6 +500,25 @@ export type TestSessionResponse = {
completed_at: string | null;
};
/**
* Request model for triggering a call via API
*/
export type TriggerCallRequest = {
phone_number: string;
initial_context?: {
[key: string]: unknown;
} | null;
};
/**
* Response model for successful call initiation
*/
export type TriggerCallResponse = {
status: string;
workflow_run_id: number;
workflow_run_name: string;
};
/**
* Request schema for Twilio configuration.
*/
@ -505,6 +548,18 @@ export type TwilioConfigurationResponse = {
from_numbers: Array<string>;
};
/**
* Request schema for updating a webhook credential.
*/
export type UpdateCredentialRequest = {
name?: string | null;
description?: string | null;
credential_type?: WebhookCredentialType | null;
credential_data?: {
[key: string]: unknown;
} | null;
};
export type UpdateIntegrationRequest = {
selected_files: Array<{
[key: string]: unknown;
@ -634,6 +689,11 @@ export type VonageConfigurationResponse = {
from_numbers: Array<string>;
};
/**
* Webhook credential authentication types
*/
export type WebhookCredentialType = 'none' | 'api_key' | 'bearer_token' | 'basic_auth' | 'custom_header';
export type WorkflowError = {
kind: ItemKind;
id: string | null;
@ -2070,6 +2130,174 @@ export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrl
export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses];
export type ListCredentialsApiV1CredentialsGetData = {
body?: never;
headers?: {
authorization?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/credentials/';
};
export type ListCredentialsApiV1CredentialsGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListCredentialsApiV1CredentialsGetError = ListCredentialsApiV1CredentialsGetErrors[keyof ListCredentialsApiV1CredentialsGetErrors];
export type ListCredentialsApiV1CredentialsGetResponses = {
/**
* Successful Response
*/
200: Array<CredentialResponse>;
};
export type ListCredentialsApiV1CredentialsGetResponse = ListCredentialsApiV1CredentialsGetResponses[keyof ListCredentialsApiV1CredentialsGetResponses];
export type CreateCredentialApiV1CredentialsPostData = {
body: CreateCredentialRequest;
headers?: {
authorization?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/credentials/';
};
export type CreateCredentialApiV1CredentialsPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateCredentialApiV1CredentialsPostError = CreateCredentialApiV1CredentialsPostErrors[keyof CreateCredentialApiV1CredentialsPostErrors];
export type CreateCredentialApiV1CredentialsPostResponses = {
/**
* Successful Response
*/
200: CredentialResponse;
};
export type CreateCredentialApiV1CredentialsPostResponse = CreateCredentialApiV1CredentialsPostResponses[keyof CreateCredentialApiV1CredentialsPostResponses];
export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteData = {
body?: never;
headers?: {
authorization?: string | null;
};
path: {
credential_uuid: string;
};
query?: never;
url: '/api/v1/credentials/{credential_uuid}';
};
export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteError = DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors[keyof DeleteCredentialApiV1CredentialsCredentialUuidDeleteErrors];
export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses = {
/**
* Successful Response
*/
200: {
[key: string]: unknown;
};
};
export type DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse = DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses[keyof DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponses];
export type GetCredentialApiV1CredentialsCredentialUuidGetData = {
body?: never;
headers?: {
authorization?: string | null;
};
path: {
credential_uuid: string;
};
query?: never;
url: '/api/v1/credentials/{credential_uuid}';
};
export type GetCredentialApiV1CredentialsCredentialUuidGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetCredentialApiV1CredentialsCredentialUuidGetError = GetCredentialApiV1CredentialsCredentialUuidGetErrors[keyof GetCredentialApiV1CredentialsCredentialUuidGetErrors];
export type GetCredentialApiV1CredentialsCredentialUuidGetResponses = {
/**
* Successful Response
*/
200: CredentialResponse;
};
export type GetCredentialApiV1CredentialsCredentialUuidGetResponse = GetCredentialApiV1CredentialsCredentialUuidGetResponses[keyof GetCredentialApiV1CredentialsCredentialUuidGetResponses];
export type UpdateCredentialApiV1CredentialsCredentialUuidPutData = {
body: UpdateCredentialRequest;
headers?: {
authorization?: string | null;
};
path: {
credential_uuid: string;
};
query?: never;
url: '/api/v1/credentials/{credential_uuid}';
};
export type UpdateCredentialApiV1CredentialsCredentialUuidPutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateCredentialApiV1CredentialsCredentialUuidPutError = UpdateCredentialApiV1CredentialsCredentialUuidPutErrors[keyof UpdateCredentialApiV1CredentialsCredentialUuidPutErrors];
export type UpdateCredentialApiV1CredentialsCredentialUuidPutResponses = {
/**
* Successful Response
*/
200: CredentialResponse;
};
export type UpdateCredentialApiV1CredentialsCredentialUuidPutResponse = UpdateCredentialApiV1CredentialsCredentialUuidPutResponses[keyof UpdateCredentialApiV1CredentialsCredentialUuidPutResponses];
export type GetIntegrationsApiV1IntegrationGetData = {
body?: never;
headers?: {
@ -3147,6 +3375,40 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = {
200: unknown;
};
export type InitiateCallApiV1PublicAgentUuidPostData = {
body: TriggerCallRequest;
headers: {
'X-API-Key': string;
};
path: {
uuid: string;
};
query?: never;
url: '/api/v1/public/agent/{uuid}';
};
export type InitiateCallApiV1PublicAgentUuidPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type InitiateCallApiV1PublicAgentUuidPostError = InitiateCallApiV1PublicAgentUuidPostErrors[keyof InitiateCallApiV1PublicAgentUuidPostErrors];
export type InitiateCallApiV1PublicAgentUuidPostResponses = {
/**
* Successful Response
*/
200: TriggerCallResponse;
};
export type InitiateCallApiV1PublicAgentUuidPostResponse = InitiateCallApiV1PublicAgentUuidPostResponses[keyof InitiateCallApiV1PublicAgentUuidPostResponses];
export type DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData = {
body?: never;
headers?: {

View file

@ -1,17 +1,24 @@
import { Globe, Headset, OctagonX, Play, X } from 'lucide-react';
import { Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
type NodeTypeConfig = {
type: NodeType;
label: string;
description: string;
icon: LucideIcon;
};
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
};
const NODE_TYPES = [
const NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.START_CALL,
label: 'Start Call',
@ -32,14 +39,72 @@ const NODE_TYPES = [
}
];
const GLOBAL_NODE_TYPES = [
const GLOBAL_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.GLOBAL_NODE,
label: 'Global Node',
description: 'Create a global node',
icon: Globe
}
]
];
const TRIGGER_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.TRIGGER,
label: 'API Trigger',
description: 'Enable API-based call triggering',
icon: Webhook
}
];
const WEBHOOK_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.WEBHOOK,
label: 'Webhook',
description: 'Send HTTP request after workflow completion',
icon: Link2
}
];
function NodeSection({
title,
nodes,
onNodeSelect
}: {
title: string;
nodes: NodeTypeConfig[];
onNodeSelect: (nodeType: NodeType) => void;
}) {
return (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
<div className="space-y-2">
{nodes.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-5 w-5" />
</div>
<div className="flex flex-col items-start text-left min-w-0">
<span className="font-medium text-sm">{node.label}</span>
<span className="text-xs text-muted-foreground whitespace-normal">
{node.description}
</span>
</div>
</div>
</Button>
))}
</div>
</div>
);
}
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
useEffect(() => {
@ -58,58 +123,38 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
className={`fixed z-51 right-0 top-0 h-full w-80 bg-background shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<div className="p-4 h-full overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold">Add New Node</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<h1 className="text-sm text-muted-foreground mb-2">Agent Nodes</h1>
<div className="space-y-6">
<NodeSection
title="Triggers"
nodes={TRIGGER_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<div className="space-y-2">
{NODE_TYPES.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-muted-foreground">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
<NodeSection
title="Agent Nodes"
nodes={NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<h1 className="text-sm text-muted-foreground mb-2">Global Nodes</h1>
<NodeSection
title="Global Nodes"
nodes={GLOBAL_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<div className="space-y-2">
{GLOBAL_NODE_TYPES.map((node) => (
<Button
variant="outline"
className="w-full justify-start p-4 h-auto"
key={node.type}
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-muted-foreground">{node.description}</span>
</div>
</div>
</Button>
))}
<NodeSection
title="Integrations"
nodes={WEBHOOK_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
</div>
</div>
</div>

View file

@ -0,0 +1,234 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Check, Copy, Edit, Trash2Icon, Webhook } from "lucide-react";
import Link from "next/link";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface TriggerNodeEditFormProps {
name: string;
setName: (value: string) => void;
endpoint: string;
}
interface TriggerNodeProps extends NodeProps {
data: FlowNodeData;
}
export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// Form state
const [name, setName] = useState(data.name || "API Trigger");
// Generate trigger_path if not present (should be done on node creation)
const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
// Get backend URL from environment
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
// Copy state for button feedback
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(endpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleSave = async () => {
handleSaveNodeData({
...data,
name,
trigger_path: triggerPath,
});
setOpen(false);
// Save the workflow after updating node data
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setName(data.name || "API Trigger");
}
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setName(data.name || "API Trigger");
}
}, [data, open]);
// Ensure trigger_path is saved on initial render if it was generated
useEffect(() => {
if (!data.trigger_path && triggerPath) {
handleSaveNodeData({
...data,
trigger_path: triggerPath,
name: data.name || "API Trigger",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || "API Trigger"}
icon={<Webhook />}
nodeType="trigger"
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">API Endpoint:</p>
<div className="flex items-center gap-1">
<code className="text-xs break-all bg-muted px-1 py-0.5 rounded flex-1">
{endpoint}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
<Button onClick={handleDeleteNode} variant="outline" size="icon">
<Trash2Icon />
</Button>
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Edit API Trigger"
onSave={handleSave}
>
{open && (
<TriggerNodeEditForm
name={name}
setName={setName}
endpoint={endpoint}
/>
)}
</NodeEditDialog>
</>
);
});
const TriggerNodeEditForm = ({
name,
setName,
endpoint,
}: TriggerNodeEditFormProps) => {
const [copied, setCopied] = useState(false);
const [curlCopied, setCurlCopied] = useState(false);
const handleCopyEndpoint = async () => {
await navigator.clipboard.writeText(endpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const curlExample = `curl -X POST "${endpoint}" \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone_number": "+1234567890", "initial_context": {}}'`;
const handleCopyCurl = async () => {
await navigator.clipboard.writeText(curlExample);
setCurlCopied(true);
setTimeout(() => setCurlCopied(false), 2000);
};
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
A display name for this trigger.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>API Endpoint</Label>
<Label className="text-xs text-muted-foreground">
Use this endpoint to trigger calls via API. Requires an API key in the X-API-Key header.{" "}
<Link href="/api-keys" target="_blank" className="text-primary underline hover:no-underline">
Get your API key
</Link>
</Label>
<div className="flex items-center gap-2">
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
{endpoint}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopyEndpoint}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>Example Request</Label>
<div className="relative">
<pre className="text-xs bg-muted px-3 py-2 rounded overflow-x-auto whitespace-pre-wrap">
{curlExample}
</pre>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2"
onClick={handleCopyCurl}
>
{curlCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
);
};
TriggerNode.displayName = "TriggerNode";

View file

@ -0,0 +1,691 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { AlertCircle, Check, Circle, Copy, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import {
createCredentialApiV1CredentialsPost,
listCredentialsApiV1CredentialsGet,
} from "@/client";
import { CredentialResponse, WebhookCredentialType } from "@/client/types.gen";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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 { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface WebhookNodeProps extends NodeProps {
data: FlowNodeData;
}
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface CustomHeader {
key: string;
value: string;
}
export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
const { getAccessToken } = useAuth();
// Form state
const [name, setName] = useState(data.name || "Webhook");
const [enabled, setEnabled] = useState(data.enabled ?? true);
const [httpMethod, setHttpMethod] = useState<HttpMethod>(data.http_method || "POST");
const [endpointUrl, setEndpointUrl] = useState(data.endpoint_url || "");
const [credentialUuid, setCredentialUuid] = useState(data.credential_uuid || "");
const [customHeaders, setCustomHeaders] = useState<CustomHeader[]>(
data.custom_headers || []
);
const [payloadTemplate, setPayloadTemplate] = useState(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
// Credentials state
const [credentials, setCredentials] = useState<CredentialResponse[]>([]);
const [credentialsLoading, setCredentialsLoading] = useState(false);
// Fetch credentials when dialog opens
const fetchCredentials = useCallback(async () => {
setCredentialsLoading(true);
try {
const accessToken = await getAccessToken();
const response = await listCredentialsApiV1CredentialsGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.error) {
console.error("Failed to fetch credentials:", response.error);
setCredentials([]);
return;
}
if (response.data) {
setCredentials(response.data);
}
} catch (error) {
console.error("Failed to fetch credentials:", error);
setCredentials([]);
} finally {
setCredentialsLoading(false);
}
}, [getAccessToken]);
const handleSave = async () => {
let parsedPayload = {};
try {
parsedPayload = JSON.parse(payloadTemplate);
} catch {
// Keep empty object if invalid JSON
}
handleSaveNodeData({
...data,
name,
enabled,
http_method: httpMethod,
endpoint_url: endpointUrl,
credential_uuid: credentialUuid || undefined,
custom_headers: customHeaders.filter((h) => h.key && h.value),
payload_template: parsedPayload,
});
setOpen(false);
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setName(data.name || "Webhook");
setEnabled(data.enabled ?? true);
setHttpMethod(data.http_method || "POST");
setEndpointUrl(data.endpoint_url || "");
setCredentialUuid(data.credential_uuid || "");
setCustomHeaders(data.custom_headers || []);
setPayloadTemplate(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
// Fetch credentials when dialog opens
fetchCredentials();
}
setOpen(newOpen);
};
useEffect(() => {
if (open) {
setName(data.name || "Webhook");
setEnabled(data.enabled ?? true);
setHttpMethod(data.http_method || "POST");
setEndpointUrl(data.endpoint_url || "");
setCredentialUuid(data.credential_uuid || "");
setCustomHeaders(data.custom_headers || []);
setPayloadTemplate(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
}
}, [data, open]);
const truncateUrl = (url: string, maxLength: number = 30) => {
if (!url) return "Not configured";
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + "...";
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || "Webhook"}
icon={<Link2 />}
nodeType="webhook"
onDoubleClick={() => handleOpenChange(true)}
nodeId={id}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{data.http_method || "POST"}
</span>
<span className="text-xs text-muted-foreground truncate flex-1">
{truncateUrl(data.endpoint_url || "")}
</span>
</div>
<div className="flex items-center gap-1.5">
<Circle
className={`h-2 w-2 ${data.enabled !== false ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400"}`}
/>
<span className="text-xs text-muted-foreground">
{data.enabled !== false ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => handleOpenChange(true)} variant="outline" size="icon">
<Edit />
</Button>
<Button onClick={handleDeleteNode} variant="outline" size="icon">
<Trash2Icon />
</Button>
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Edit Webhook"
onSave={handleSave}
>
{open && (
<WebhookNodeEditForm
name={name}
setName={setName}
enabled={enabled}
setEnabled={setEnabled}
httpMethod={httpMethod}
setHttpMethod={setHttpMethod}
endpointUrl={endpointUrl}
setEndpointUrl={setEndpointUrl}
credentialUuid={credentialUuid}
setCredentialUuid={setCredentialUuid}
credentials={credentials}
credentialsLoading={credentialsLoading}
onRefreshCredentials={fetchCredentials}
getAccessToken={getAccessToken}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
payloadTemplate={payloadTemplate}
setPayloadTemplate={setPayloadTemplate}
/>
)}
</NodeEditDialog>
</>
);
});
interface WebhookNodeEditFormProps {
name: string;
setName: (value: string) => void;
enabled: boolean;
setEnabled: (value: boolean) => void;
httpMethod: HttpMethod;
setHttpMethod: (value: HttpMethod) => void;
endpointUrl: string;
setEndpointUrl: (value: string) => void;
credentialUuid: string;
setCredentialUuid: (value: string) => void;
credentials: CredentialResponse[];
credentialsLoading: boolean;
onRefreshCredentials: () => Promise<void>;
getAccessToken: () => Promise<string>;
customHeaders: CustomHeader[];
setCustomHeaders: (value: CustomHeader[]) => void;
payloadTemplate: string;
setPayloadTemplate: (value: string) => void;
}
const WebhookNodeEditForm = ({
name,
setName,
enabled,
setEnabled,
httpMethod,
setHttpMethod,
endpointUrl,
setEndpointUrl,
credentialUuid,
setCredentialUuid,
credentials,
credentialsLoading,
onRefreshCredentials,
getAccessToken,
customHeaders,
setCustomHeaders,
payloadTemplate,
setPayloadTemplate,
}: WebhookNodeEditFormProps) => {
const [copied, setCopied] = useState(false);
// Add Credential Dialog state
const [isAddCredentialOpen, setIsAddCredentialOpen] = useState(false);
const [newCredName, setNewCredName] = useState("");
const [newCredDescription, setNewCredDescription] = useState("");
const [newCredType, setNewCredType] = useState<WebhookCredentialType>("bearer_token");
const [newCredData, setNewCredData] = useState<Record<string, string>>({});
const [isCreatingCredential, setIsCreatingCredential] = useState(false);
const [credentialError, setCredentialError] = useState<string | null>(null);
const handleCreateCredential = async () => {
if (!newCredName.trim()) return;
setIsCreatingCredential(true);
setCredentialError(null);
try {
const accessToken = await getAccessToken();
const response = await createCredentialApiV1CredentialsPost({
headers: { Authorization: `Bearer ${accessToken}` },
body: {
name: newCredName,
description: newCredDescription || undefined,
credential_type: newCredType,
credential_data: newCredData,
},
});
if (response.error) {
const errorDetail = (response.error as { detail?: string })?.detail
|| "Failed to create credential";
setCredentialError(errorDetail);
return;
}
if (response.data) {
// Refresh credentials list
await onRefreshCredentials();
// Select the newly created credential
setCredentialUuid(response.data.uuid);
// Close dialog and reset form
setIsAddCredentialOpen(false);
setNewCredName("");
setNewCredDescription("");
setNewCredType("bearer_token");
setNewCredData({});
setCredentialError(null);
}
} catch (error) {
console.error("Failed to create credential:", error);
setCredentialError(
error instanceof Error ? error.message : "An unexpected error occurred"
);
} finally {
setIsCreatingCredential(false);
}
};
const handleAddCredentialDialogChange = (open: boolean) => {
setIsAddCredentialOpen(open);
if (!open) {
// Reset error when closing dialog
setCredentialError(null);
}
};
const getCredentialDataFields = (type: WebhookCredentialType) => {
switch (type) {
case "api_key":
return [
{ key: "header_name", label: "Header Name", placeholder: "X-API-Key" },
{ key: "api_key", label: "API Key", placeholder: "your-api-key", isSecret: true },
];
case "bearer_token":
return [
{ key: "token", label: "Token", placeholder: "your-bearer-token", isSecret: true },
];
case "basic_auth":
return [
{ key: "username", label: "Username", placeholder: "username" },
{ key: "password", label: "Password", placeholder: "password", isSecret: true },
];
case "custom_header":
return [
{ key: "header_name", label: "Header Name", placeholder: "X-Custom-Header" },
{ key: "header_value", label: "Header Value", placeholder: "header-value", isSecret: true },
];
default:
return [];
}
};
const handleCopyPayload = async () => {
await navigator.clipboard.writeText(payloadTemplate);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const addHeader = () => {
setCustomHeaders([...customHeaders, { key: "", value: "" }]);
};
const updateHeader = (index: number, field: "key" | "value", value: string) => {
const newHeaders = [...customHeaders];
newHeaders[index] = { ...newHeaders[index], [field]: value };
setCustomHeaders(newHeaders);
};
const removeHeader = (index: number) => {
setCustomHeaders(customHeaders.filter((_, i) => i !== index));
};
const availableVariables = [
{ name: "workflow_run_id", description: "Unique ID of the workflow run" },
{ name: "workflow_run_name", description: "Name of the workflow run" },
{ name: "workflow_id", description: "ID of the workflow" },
{ name: "workflow_name", description: "Name of the workflow" },
{ name: "initial_context.*", description: "Initial context variables" },
{ name: "gathered_context.*", description: "Extracted variables" },
{ name: "cost_info.call_duration_seconds", description: "Call duration" },
{ name: "completed_at", description: "Completion timestamp" },
{ name: "disposition_code", description: "Final disposition code" },
{ name: "recording_url", description: "Call recording URL" },
{ name: "transcript_url", description: "Transcript URL" },
];
return (
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="auth">Auth</TabsTrigger>
<TabsTrigger value="headers">Headers</TabsTrigger>
<TabsTrigger value="payload">Payload</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
A display name for this webhook.
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="enabled">Enabled</Label>
<Label className="text-xs text-muted-foreground ml-2">
Whether this webhook is active.
</Label>
</div>
<div className="grid gap-2">
<Label>HTTP Method</Label>
<Select value={httpMethod} onValueChange={(v) => setHttpMethod(v as HttpMethod)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Endpoint URL</Label>
<Label className="text-xs text-muted-foreground">
The URL to send the webhook request to.
</Label>
<Input
value={endpointUrl}
onChange={(e) => setEndpointUrl(e.target.value)}
placeholder="https://api.example.com/webhook"
/>
</div>
</TabsContent>
<TabsContent value="auth" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Credential</Label>
<Label className="text-xs text-muted-foreground">
Select a credential for authentication, or leave empty for no auth.
</Label>
<div className="flex gap-2">
<Select
value={credentialUuid || "none"}
onValueChange={(v) => setCredentialUuid(v === "none" ? "" : v)}
disabled={credentialsLoading}
>
<SelectTrigger className="flex-1">
{credentialsLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
) : (
<SelectValue placeholder="No authentication" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No authentication</SelectItem>
{credentials.map((cred) => (
<SelectItem key={cred.uuid} value={cred.uuid}>
{cred.name} ({cred.credential_type})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => setIsAddCredentialOpen(true)}
title="Add new credential"
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
{credentials.length === 0 && !credentialsLoading && (
<div className="p-3 border rounded-md bg-muted/20">
<p className="text-sm text-muted-foreground">
No credentials found. Click the + button to create one.
</p>
</div>
)}
{/* Add Credential Dialog */}
<Dialog open={isAddCredentialOpen} onOpenChange={handleAddCredentialDialogChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Credential</DialogTitle>
<DialogDescription>
Create a new credential for webhook authentication.
</DialogDescription>
</DialogHeader>
{/* Error display */}
{credentialError && (
<div className="flex items-start gap-2 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{credentialError}</span>
</div>
)}
<div className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="cred-name">Name *</Label>
<Input
id="cred-name"
value={newCredName}
onChange={(e) => setNewCredName(e.target.value)}
placeholder="My API Key"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cred-description">Description</Label>
<Input
id="cred-description"
value={newCredDescription}
onChange={(e) => setNewCredDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="grid gap-2">
<Label>Credential Type</Label>
<Select
value={newCredType}
onValueChange={(v) => {
setNewCredType(v as WebhookCredentialType);
setNewCredData({});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bearer_token">Bearer Token</SelectItem>
<SelectItem value="api_key">API Key</SelectItem>
<SelectItem value="basic_auth">Basic Auth</SelectItem>
<SelectItem value="custom_header">Custom Header</SelectItem>
</SelectContent>
</Select>
</div>
{getCredentialDataFields(newCredType).map((field) => (
<div key={field.key} className="grid gap-2">
<Label htmlFor={`cred-${field.key}`}>{field.label}</Label>
<Input
id={`cred-${field.key}`}
type={field.isSecret ? "password" : "text"}
value={newCredData[field.key] || ""}
onChange={(e) =>
setNewCredData((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
placeholder={field.placeholder}
/>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddCredentialOpen(false)}
disabled={isCreatingCredential}
>
Cancel
</Button>
<Button
onClick={handleCreateCredential}
disabled={!newCredName.trim() || isCreatingCredential}
>
{isCreatingCredential ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="headers" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Custom Headers</Label>
<Label className="text-xs text-muted-foreground">
Add custom headers to include in the webhook request.
</Label>
{customHeaders.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<Input
placeholder="Header name"
value={header.key}
onChange={(e) => updateHeader(index, "key", e.target.value)}
className="flex-1"
/>
<Input
placeholder="Header value"
value={header.value}
onChange={(e) => updateHeader(index, "value", e.target.value)}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={() => removeHeader(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addHeader} className="w-fit">
<PlusIcon className="h-4 w-4 mr-1" /> Add Header
</Button>
</div>
</TabsContent>
<TabsContent value="payload" className="space-y-4 mt-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Payload Template (JSON)</Label>
<Button
variant="outline"
size="sm"
onClick={handleCopyPayload}
>
{copied ? (
<Check className="h-4 w-4 mr-1" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
Copy
</Button>
</div>
<Label className="text-xs text-muted-foreground">
Define the JSON payload. Use {"{{variable}}"} syntax for dynamic values.
</Label>
<Textarea
value={payloadTemplate}
onChange={(e) => setPayloadTemplate(e.target.value)}
className="min-h-[200px] font-mono text-sm"
placeholder='{"call_id": "{{workflow_run_id}}"}'
/>
</div>
<div className="border rounded-md p-3 bg-muted/20">
<Label className="text-sm font-medium">Available Variables</Label>
<div className="mt-2 space-y-1">
{availableVariables.map((v) => (
<div key={v.name} className="text-xs">
<code className="bg-muted px-1 py-0.5 rounded">
{`{{${v.name}}}`}
</code>
<span className="text-muted-foreground ml-2">{v.description}</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
};
WebhookNode.displayName = "WebhookNode";

View file

@ -12,7 +12,7 @@ interface NodeContentProps {
hovered_through_edge?: boolean;
title: string;
icon: ReactNode;
nodeType?: 'start' | 'agent' | 'end' | 'global';
nodeType?: 'start' | 'agent' | 'end' | 'global' | 'trigger' | 'webhook';
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
@ -32,6 +32,10 @@ const getNodeTypeBadge = (nodeType?: string) => {
return { label: 'End Node', className: 'bg-rose-500 text-white' };
case 'global':
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
case 'trigger':
return { label: 'API Trigger', className: 'bg-purple-500 text-white' };
case 'webhook':
return { label: 'Webhook', className: 'bg-indigo-500 text-white' };
default:
return { label: 'Node', className: 'bg-zinc-500 text-white' };
}
@ -50,6 +54,7 @@ export const NodeContent = ({
children,
className = "",
onDoubleClick,
nodeId,
}: NodeContentProps) => {
const badge = getNodeTypeBadge(nodeType);
@ -80,6 +85,11 @@ export const NodeContent = ({
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground truncate">
{title}
{nodeId && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
#{nodeId}
</span>
)}
</h3>
</div>
</div>

View file

@ -2,3 +2,5 @@ export * from './AgentNode';
export * from './EndCall';
export * from './GlobalNode';
export * from './StartCall';
export * from './TriggerNode';
export * from './WebhookNode';

View file

@ -2,7 +2,9 @@ export enum NodeType {
START_CALL = 'startCall',
AGENT_NODE = 'agentNode',
END_CALL = 'endCall',
GLOBAL_NODE = 'globalNode'
GLOBAL_NODE = 'globalNode',
TRIGGER = 'trigger',
WEBHOOK = 'webhook'
}
export type FlowNodeData = {
@ -24,6 +26,20 @@ export type FlowNodeData = {
detect_voicemail?: boolean;
delayed_start?: boolean;
delayed_start_duration?: number;
// Trigger node specific
trigger_path?: string;
// Webhook node specific
enabled?: boolean;
http_method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
endpoint_url?: string;
credential_uuid?: string;
custom_headers?: Array<{ key: string; value: string }>;
payload_template?: Record<string, unknown>;
retry_config?: {
enabled: boolean;
max_retries: number;
retry_delay_seconds: number;
};
}
export type FlowNode = {
@ -84,3 +100,15 @@ export type ExtractionVariable = {
prompt?: string;
};
// Credential types for webhook authentication
export type CredentialType = 'none' | 'api_key' | 'bearer_token' | 'basic_auth' | 'custom_header';
export interface Credential {
uuid: string;
name: string;
description?: string;
credential_type: CredentialType;
created_at: string;
updated_at: string;
}