Merge remote-tracking branch 'origin/main' into feat/text-chat

This commit is contained in:
Abhishek Kumar 2026-05-21 07:47:07 +05:30
commit 129a6d700c
160 changed files with 9287 additions and 3935 deletions

View file

@ -152,8 +152,8 @@ class CircuitBreakerConfigResponse(BaseModel):
class CreateCampaignRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
workflow_id: int
source_type: str = Field(..., pattern="^(google-sheet|csv)$")
source_id: str # Google Sheet URL or CSV file key
source_type: str = Field(..., pattern="^csv$")
source_id: str # CSV file key
# Optional during the legacy → multi-config migration window. Required in
# a follow-up. When omitted, the dispatcher falls back to the org's
# default config.
@ -929,8 +929,6 @@ async def get_campaign_source_download_url(
user: UserModel = Depends(get_user),
) -> CampaignSourceDownloadResponse:
"""Get presigned download URL for campaign CSV source file
Only works for CSV source type. For Google Sheets, use the source_id directly.
Validates that the campaign belongs to the user's organization for security.
"""
# Verify campaign exists and belongs to organization

View file

@ -1,266 +0,0 @@
"""
Route for 3rd party integrations. Currently being backed by nango.
"""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, TypedDict
from fastapi import APIRouter, Depends, HTTPException, Request
from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_user
from api.services.integrations.nango import nango_service
router = APIRouter(prefix="/integration")
@dataclass
class IntegrationResponse:
id: int
integration_id: str
organisation_id: int
created_by: Optional[int]
provider: str
is_active: bool
created_at: str
action: str
provider_data: dict
class SessionResponse(TypedDict):
session_token: str
expires_at: str
class WebhookResponse(TypedDict):
status: str
message: str
class UpdateIntegrationRequest(BaseModel):
selected_files: List[Dict[str, Any]]
class AccessTokenResponse(BaseModel):
access_token: Optional[str]
refresh_token: Optional[str]
expires_at: Optional[str]
connection_id: str
def build_integration_response(integration) -> IntegrationResponse:
"""Build a standardized integration response with provider-specific data."""
provider_data = {}
if integration.provider == "google-sheet":
# For Google Sheets, include selected_files
provider_data["selected_files"] = integration.connection_details.get(
"selected_files", []
)
elif integration.provider == "slack":
# For Slack, include channel information
channel = integration.connection_details.get("connection_config", {}).get(
"incoming_webhook.channel"
)
if channel:
provider_data["channel"] = channel
return IntegrationResponse(
id=integration.id,
integration_id=integration.integration_id,
organisation_id=integration.organisation_id,
created_by=integration.created_by,
provider=integration.provider,
is_active=integration.is_active,
created_at=integration.created_at.isoformat(),
action=integration.action,
provider_data=provider_data,
)
@router.get("/")
async def get_integrations(
user: UserModel = Depends(get_user),
) -> list[IntegrationResponse]:
"""
Get all integrations for the user's selected organization.
Returns:
List of integrations associated with the user's selected organization
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
integrations = await db_client.get_integrations_by_organization_id(
user.selected_organization_id
)
return [build_integration_response(integration) for integration in integrations]
@router.post("/session")
async def create_session(
user: UserModel = Depends(get_user),
) -> SessionResponse:
"""
Create a Nango session for the user's selected organization.
Returns:
Session token and ID for the created session
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
try:
session_data = await nango_service.create_session(
user_id=str(user.id), organization_id=user.selected_organization_id
)
return {
"session_token": session_data["data"]["token"],
"expires_at": session_data["data"]["expires_at"],
}
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to create session: {str(e)}"
)
@router.put("/{integration_id}")
async def update_integration(
integration_id: int,
request: UpdateIntegrationRequest,
user: UserModel = Depends(get_user),
) -> IntegrationResponse:
"""
Update an integration's selected files (for Google Sheets).
Args:
integration_id: The ID of the integration to update
request: The update request containing selected files
user: The authenticated user
Returns:
Updated integration details
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
# Get the integration first to verify ownership
integrations = await db_client.get_integrations_by_organization_id(
user.selected_organization_id
)
integration = next((i for i in integrations if i.id == integration_id), None)
if not integration:
raise HTTPException(status_code=404, detail="Integration not found")
# Only allow updating selected_files for google-sheet provider
if integration.provider != "google-sheet":
raise HTTPException(
status_code=400,
detail="This endpoint only supports updating Google Sheet integrations",
)
# Update the connection_details with the new selected_files
updated_connection_details = integration.connection_details.copy()
updated_connection_details["selected_files"] = request.selected_files
# Update the integration
updated_integration = await db_client.update_integration_connection_details(
integration_id=integration_id, connection_details=updated_connection_details
)
if not updated_integration:
raise HTTPException(status_code=500, detail="Failed to update integration")
return build_integration_response(updated_integration)
@router.get("/{integration_id}/access-token")
async def get_integration_access_token(
integration_id: int,
user: UserModel = Depends(get_user),
) -> AccessTokenResponse:
"""
Get the latest access token for an integration from Nango.
Args:
integration_id: The ID of the integration
user: The authenticated user
Returns:
Dict containing access token and expiration info
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
# Get the integration to verify ownership and get connection details
integrations = await db_client.get_integrations_by_organization_id(
user.selected_organization_id
)
integration = next((i for i in integrations if i.id == integration_id), None)
if not integration:
raise HTTPException(status_code=404, detail="Integration not found")
try:
# Fetch the latest access token from Nango
token_data = await nango_service.get_access_token(
connection_id=integration.integration_id,
provider_config_key=integration.provider,
)
# Extract relevant fields
return AccessTokenResponse(
access_token=token_data.get("credentials", {}).get("access_token"),
refresh_token=token_data.get("credentials", {}).get("refresh_token"),
expires_at=token_data.get("credentials", {}).get("expires_at"),
connection_id=integration.integration_id,
)
except Exception as e:
logger.error(f"Failed to get access token: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to fetch access token: {str(e)}"
)
@router.post("/webhook", include_in_schema=False)
async def handle_nango_webhook(
request: Request,
) -> WebhookResponse:
"""
Handle Nango integration webhook requests.
Processes webhook events from Nango when integrations are created/updated
and stores the integration details in the database.
Args:
request: The raw FastAPI request object
Returns:
WebhookResponse with status and message
"""
raw_body = await request.body()
# Get signature from headers (you may need to adjust the header name)
signature = request.headers.get("X-Nango-Signature")
# Use the nango service to process the webhook
result = await nango_service.process_webhook(raw_body, signature)
return result

View file

@ -6,7 +6,6 @@ from api.routes.agent_stream import router as agent_stream_router
from api.routes.auth import router as auth_router
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.knowledge_base import router as knowledge_base_router
from api.routes.node_types import router as node_types_router
from api.routes.organization import router as organization_router
@ -27,6 +26,7 @@ from api.routes.workflow import router as workflow_router
from api.routes.workflow_embed import router as workflow_embed_router
from api.routes.workflow_recording import router as workflow_recording_router
from api.routes.workflow_text_chat import router as workflow_text_chat_router
from api.services.integrations import all_routers
router = APIRouter(
tags=["main"],
@ -41,7 +41,6 @@ router.include_router(user_router)
router.include_router(campaign_router)
router.include_router(credentials_router)
router.include_router(tool_router)
router.include_router(integration_router)
router.include_router(organization_router)
router.include_router(s3_router)
router.include_router(service_keys_router)
@ -59,6 +58,9 @@ router.include_router(auth_router)
router.include_router(node_types_router)
router.include_router(agent_stream_router)
for _integration_router in all_routers():
router.include_router(_integration_router)
class HealthResponse(BaseModel):
status: str

View file

@ -1,8 +1,9 @@
"""Public download endpoints for workflow recordings and transcripts.
These endpoints provide secure, token-based public access to workflow artifacts
without requiring authentication. Tokens are generated on-demand when webhooks
are executed and included in the webhook payload.
without requiring authentication. Tokens are generated on-demand during
post-call processing for runs that execute integrations, QA, or campaign
reporting.
"""
from typing import Literal

View file

@ -1,10 +1,12 @@
"""API routes for managing tools."""
import asyncio
import re
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel, Field, field_validator
from api.db import db_client
@ -13,9 +15,23 @@ from api.enums import PostHogEvent, ToolCategory, ToolStatus
from api.sdk_expose import sdk_expose
from api.services.auth.depends import get_user
from api.services.posthog_client import capture_event
from api.services.workflow.mcp_tool_session import discover_mcp_tools
from api.services.workflow.tools.mcp_tool import (
McpDefinitionError,
validate_mcp_definition,
)
from api.services.workflow.tools.mcp_tool import (
McpToolConfig as SharedMcpToolConfig,
)
from api.services.workflow.tools.mcp_tool import (
McpToolDefinition as SharedMcpToolDefinition,
)
router = APIRouter(prefix="/tools")
McpToolConfig = SharedMcpToolConfig
McpToolDefinition = SharedMcpToolDefinition
# Request/Response schemas
class ToolParameter(BaseModel):
@ -183,6 +199,7 @@ ToolDefinition = Annotated[
EndCallToolDefinition,
TransferCallToolDefinition,
CalculatorToolDefinition,
McpToolDefinition,
],
Field(discriminator="type"),
]
@ -248,6 +265,14 @@ class ToolResponse(BaseModel):
from_attributes = True
class McpRefreshResponse(BaseModel):
"""Result of re-discovering an MCP server's tool catalog."""
tool_uuid: str
discovered_tools: list = Field(default_factory=list)
error: Optional[str] = None
def build_tool_response(tool, include_created_by: bool = False) -> ToolResponse:
"""Build a response from a tool model."""
created_by = None
@ -336,6 +361,52 @@ async def list_tools(
return [build_tool_response(tool) for tool in tools]
async def _fetch_credential(credential_uuid: Optional[str], organization_id: int):
"""Best-effort credential lookup for MCP auth. A missing/failed credential
degrades to ``None`` (unauthenticated) rather than failing the request."""
if not credential_uuid:
return None
try:
return await db_client.get_credential_by_uuid(credential_uuid, organization_id)
except Exception as e: # noqa: BLE001
logger.warning(f"MCP: credential fetch failed: {e}")
return None
async def _populate_discovered_tools(definition: dict, *, organization_id: int) -> dict:
"""Best-effort: for an MCP definition, connect to the server, list its
tools, and overwrite ``config.discovered_tools``. Never raises and never
blocks tool save a dead server yields ``discovered_tools: []``. Non-MCP
definitions pass through untouched."""
if not isinstance(definition, dict) or definition.get("type") != "mcp":
return definition
try:
cfg = validate_mcp_definition(definition)
except McpDefinitionError:
return definition
credential = await _fetch_credential(cfg.get("credential_uuid"), organization_id)
# Run discovery in an isolated asyncio task so an anyio cancel-scope
# CancelledError doesn't bleed into the parent task and corrupt the
# subsequent DB write. _run() never raises (degrades to []).
async def _run() -> list:
try:
return await discover_mcp_tools(
url=cfg["url"],
credential=credential,
timeout_secs=cfg["timeout_secs"],
sse_read_timeout_secs=cfg["sse_read_timeout_secs"],
)
except BaseException as e: # noqa: BLE001
logger.warning(f"MCP discovery failed; caching empty list: {e}")
return []
discovered = await asyncio.ensure_future(_run())
definition["config"]["discovered_tools"] = discovered
return definition
@router.post("/")
async def create_tool(
request: CreateToolRequest,
@ -357,11 +428,16 @@ async def create_tool(
validate_category(request.category)
definition = await _populate_discovered_tools(
request.definition.model_dump(),
organization_id=user.selected_organization_id,
)
tool = await db_client.create_tool(
organization_id=user.selected_organization_id,
user_id=user.id,
name=request.name,
definition=request.definition.model_dump(),
definition=definition,
category=request.category,
description=request.description,
icon=request.icon,
@ -410,6 +486,67 @@ async def get_tool(
return build_tool_response(tool, include_created_by=True)
@router.post("/{tool_uuid}/mcp/refresh")
async def refresh_mcp_tools(
tool_uuid: str,
user: UserModel = Depends(get_user),
) -> McpRefreshResponse:
"""Re-discover an MCP tool's server catalog and overwrite the cached
``definition.config.discovered_tools``. Server down 200 with error
(cache not overwritten on transient failure)."""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
tool = await db_client.get_tool_by_uuid(
tool_uuid, user.selected_organization_id, include_archived=True
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
if tool.category != ToolCategory.MCP.value:
raise HTTPException(status_code=400, detail="Tool is not an MCP tool")
try:
cfg = validate_mcp_definition(tool.definition)
except McpDefinitionError as e:
raise HTTPException(status_code=400, detail=f"Invalid MCP definition: {e}")
credential = await _fetch_credential(
cfg.get("credential_uuid"), user.selected_organization_id
)
try:
discovered = await discover_mcp_tools(
url=cfg["url"],
credential=credential,
timeout_secs=cfg["timeout_secs"],
sse_read_timeout_secs=cfg["sse_read_timeout_secs"],
)
except Exception as e: # noqa: BLE001
logger.warning(f"MCP refresh discovery failed: {e}")
discovered = []
if not discovered:
error = (
f"Could not reach the MCP server at {cfg['url']} "
f"(or it exposes no tools). Previously cached list retained."
)
# Do NOT clobber a previously-good cache with [] on a transient outage.
return McpRefreshResponse(tool_uuid=tool_uuid, discovered_tools=[], error=error)
new_def = dict(tool.definition or {})
new_def["config"] = {**new_def.get("config", {}), "discovered_tools": discovered}
await db_client.update_tool(
tool_uuid=tool_uuid,
organization_id=user.selected_organization_id,
definition=new_def,
)
return McpRefreshResponse(
tool_uuid=tool_uuid, discovered_tools=discovered, error=None
)
@router.put("/{tool_uuid}")
async def update_tool(
tool_uuid: str,
@ -434,12 +571,21 @@ async def update_tool(
if request.status:
validate_status(request.status)
definition = (
await _populate_discovered_tools(
request.definition.model_dump(),
organization_id=user.selected_organization_id,
)
if request.definition
else None
)
tool = await db_client.update_tool(
tool_uuid=tool_uuid,
organization_id=user.selected_organization_id,
name=request.name,
description=request.description,
definition=request.definition.model_dump() if request.definition else None,
definition=definition,
icon=request.icon,
icon_color=request.icon_color,
status=request.status,

View file

@ -18,6 +18,7 @@ import asyncio
import ipaddress
import os
from datetime import UTC, datetime
from enum import Enum
from typing import Dict, List, Optional
from aiortc import RTCIceServer
@ -49,6 +50,63 @@ from api.services.quota_service import check_dograh_quota
router = APIRouter(prefix="/ws")
class NonRelayFilterPolicy(Enum):
"""What to filter from non-relay ICE candidates. Relay candidates always pass."""
NONE = "none" # filter nothing — pass all candidates
PRIVATE = "private" # filter non-relay candidates with private/CGNAT IPs
ALL = "all" # filter all non-relay candidates (relay-only mode)
def is_local_or_cgnat_ip(ip_str: str) -> bool:
"""Return True for RFC1918, loopback, link-local, and CGNAT addresses."""
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
is_cgnat = ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10")
return ip.is_private or ip.is_loopback or ip.is_link_local or is_cgnat
def resolve_ice_filter_policies(
environment: str,
force_turn_relay: bool,
server_ip: str,
) -> tuple[NonRelayFilterPolicy, NonRelayFilterPolicy]:
"""Resolve outbound and inbound non-relay filtering for this deployment."""
private_lan_deployment = (
environment != Environment.LOCAL.value and is_local_or_cgnat_ip(server_ip)
)
if force_turn_relay:
# Relay-only diagnostics stay explicit. On private LAN deployments we
# must still accept inbound private candidates for relay<->host pairs.
outbound_policy = NonRelayFilterPolicy.ALL
inbound_policy = (
NonRelayFilterPolicy.NONE
if private_lan_deployment
else NonRelayFilterPolicy.PRIVATE
)
return outbound_policy, inbound_policy
if environment == Environment.LOCAL.value or private_lan_deployment:
return NonRelayFilterPolicy.NONE, NonRelayFilterPolicy.NONE
# Public remote deployment: drop private-IP host candidates to avoid
# coturn denied-peer-ip errors against Docker bridge and LAN interfaces.
return NonRelayFilterPolicy.PRIVATE, NonRelayFilterPolicy.PRIVATE
ICE_OUTBOUND_POLICY, ICE_INBOUND_POLICY = resolve_ice_filter_policies(
ENVIRONMENT,
FORCE_TURN_RELAY,
os.getenv("SERVER_IP", ""),
)
def is_private_ip_candidate(candidate_str: str) -> bool:
"""Check if ICE candidate contains a private IP address or CGNAT IP Address.
@ -69,61 +127,58 @@ def is_private_ip_candidate(candidate_str: str) -> bool:
if "typ" in parts:
typ_index = parts.index("typ")
ip_str = parts[typ_index - 2]
ip = ipaddress.ip_address(ip_str)
is_cgnat = ip in ipaddress.ip_network("100.64.0.0/10")
return ip.is_private or is_cgnat
return is_local_or_cgnat_ip(ip_str)
except (ValueError, IndexError):
pass
return False
def filter_outbound_sdp(sdp: str) -> str:
"""Strip ICE candidates from an outbound answer SDP based on env config.
def _keep_candidate(candidate_str: str, policy: NonRelayFilterPolicy) -> bool:
"""Return True if this ICE candidate should be kept under the given policy.
Two filters apply:
1. In non-LOCAL environments, drop host candidates with private/CGNAT IPs.
aiortc gathers host candidates from every interface on the box, including
Docker bridges (172.17.0.1, 172.18.0.1). Advertising those to the browser
causes coturn "peer IP X denied" errors when the browser asks TURN to
permit them.
2. When FORCE_TURN_RELAY is set, drop every non-relay candidate so the
only path the browser can use is via TURN. Lets you verify TURN
connectivity end-to-end if TURN is broken, the call simply fails.
Relay candidates always pass a relay with a private IP (LAN TURN server)
must never be dropped regardless of policy.
"""
if ENVIRONMENT == Environment.LOCAL.value and not FORCE_TURN_RELAY:
if " typ relay" in candidate_str:
return True
if policy == NonRelayFilterPolicy.NONE:
return True
if policy == NonRelayFilterPolicy.ALL:
return False
# PRIVATE: drop non-relay candidates with private/CGNAT IPs
return not is_private_ip_candidate(candidate_str)
def filter_outbound_sdp(sdp: str) -> str:
"""Strip ICE candidates from an outbound answer SDP based on ICE_OUTBOUND_POLICY."""
if ICE_OUTBOUND_POLICY == NonRelayFilterPolicy.NONE:
return sdp
lines = sdp.split("\r\n")
filtered: List[str] = []
dropped_non_relay = 0
dropped = 0
kept_relay = 0
for line in lines:
if line.startswith("a=candidate:"):
candidate_str = line[2:]
if FORCE_TURN_RELAY and " typ relay" not in candidate_str:
dropped_non_relay += 1
if not _keep_candidate(candidate_str, ICE_OUTBOUND_POLICY):
dropped += 1
continue
if ENVIRONMENT != Environment.LOCAL.value and is_private_ip_candidate(
candidate_str
):
continue
if FORCE_TURN_RELAY:
if " typ relay" in candidate_str:
kept_relay += 1
filtered.append(line)
if FORCE_TURN_RELAY:
if ICE_OUTBOUND_POLICY == NonRelayFilterPolicy.ALL:
if kept_relay == 0:
logger.warning(
"FORCE_TURN_RELAY is on but the answer SDP has no relay candidates "
f"(dropped {dropped_non_relay} non-relay). TURN may be unreachable; "
f"(dropped {dropped} non-relay). TURN may be unreachable; "
"the connection will fail."
)
else:
logger.info(
f"FORCE_TURN_RELAY: kept {kept_relay} relay candidates, "
f"dropped {dropped_non_relay} non-relay"
f"dropped {dropped} non-relay"
)
return "\r\n".join(filtered)
@ -370,9 +425,7 @@ class SignalingManager:
Uses SmallWebRTC's native ICE trickling support via add_ice_candidate().
Candidates are parsed using aiortc's candidate_from_sdp() for proper formatting,
consistent with SmallWebRTCRequestHandler.handle_patch_request().
In non-local environments, private IP candidates are filtered out to prevent
TURN relay errors when coturn blocks private IP ranges (denied-peer-ip).
Candidates are filtered according to ICE_INBOUND_POLICY before being added.
"""
pc_id = payload.get("pc_id")
candidate_data = payload.get("candidate")
@ -389,13 +442,9 @@ class SignalingManager:
if candidate_data:
candidate_str = candidate_data.get("candidate", "")
# Filter out private IP candidates in non-local environments
# This prevents TURN relay errors when coturn blocks private IP ranges
if ENVIRONMENT != Environment.LOCAL.value and is_private_ip_candidate(
candidate_str
):
if not _keep_candidate(candidate_str, ICE_INBOUND_POLICY):
logger.debug(
f"Skipping private IP candidate in {ENVIRONMENT}: {candidate_str[:50]}..."
f"Dropping inbound candidate per policy ({ICE_INBOUND_POLICY.value}): {candidate_str[:50]}..."
)
return