feat: add Tuner Integration to Dograh (#311)

* Add tuner integration

* bump pipecat version

* chore: update pipecat submodule to match upstream and use tuner-pipecat-sdk 0.2.0

Update pipecat submodule from 0.0.109.dev23 to 13e98d0d9 (the exact commit
upstream dograh-hq/dograh uses after v1.30.1). This installs pipecat-ai as
1.1.0.post277 via setuptools_scm, satisfying tuner-pipecat-sdk 0.2.0's
pipecat-ai>=1.0.0 requirement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* wire tuner

* feat: refactor integrations into self contained packages

* chore: simplify ensure_public_access_token

* fix: remove NodeSpec and make DTOs the source of truth

* feat: send relevant signal to mcp using to_mcp_dict

* fix: fix tests

* cleanup: remove nango integrations

* feat: add agents.md for integrations

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Mohamed-Mamdouh 2026-05-20 10:07:33 +01:00 committed by GitHub
parent afa78fe859
commit 5f28c1b2a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 3388 additions and 3414 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
organization_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,
organization_id=integration.organization_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
@ -26,6 +25,7 @@ from api.routes.webrtc_signaling import router as webrtc_signaling_router
from api.routes.workflow import router as workflow_router
from api.routes.workflow_embed import router as workflow_embed_router
from api.routes.workflow_recording import router as workflow_recording_router
from api.services.integrations import all_routers
router = APIRouter(
tags=["main"],
@ -39,7 +39,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)
@ -57,6 +56,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