suna/backend/webhooks/api.py

481 lines
21 KiB
Python

from fastapi import APIRouter, HTTPException, Request, Header
from fastapi.responses import JSONResponse
from typing import Optional, Dict, Any
import uuid
import asyncio
from datetime import datetime, timezone
import json
from .models import SlackEventRequest, TelegramUpdateRequest, WebhookExecutionResult
from .providers import SlackWebhookProvider, TelegramWebhookProvider, GenericWebhookProvider
from workflows.models import WorkflowDefinition
from flags.flags import is_enabled
from services.supabase import DBConnection
from utils.logger import logger
router = APIRouter()
db = DBConnection()
def initialize(database: DBConnection):
"""Initialize the webhook API with database connection."""
global db
db = database
def _map_db_to_workflow_definition(data: dict) -> WorkflowDefinition:
"""Helper function to map database record to WorkflowDefinition."""
definition = data.get('definition', {})
return WorkflowDefinition(
id=data['id'],
name=data['name'],
description=data.get('description'),
steps=definition.get('steps', []),
entry_point=definition.get('entry_point', ''),
triggers=definition.get('triggers', []),
state=data.get('status', 'draft').upper(),
created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None,
updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None,
created_by=data.get('created_by'),
project_id=data['project_id'],
agent_id=definition.get('agent_id'),
is_template=False,
max_execution_time=definition.get('max_execution_time', 3600),
max_retries=definition.get('max_retries', 3)
)
@router.post("/webhooks/trigger/{workflow_id}")
async def trigger_workflow_webhook(
workflow_id: str,
request: Request,
x_slack_signature: Optional[str] = Header(None),
x_slack_request_timestamp: Optional[str] = Header(None),
x_telegram_bot_api_secret_token: Optional[str] = Header(None)
):
if not await is_enabled("workflows"):
raise HTTPException(
status_code=403,
detail="This feature is not available at the moment."
)
"""Handle webhook triggers for workflows."""
try:
logger.info(f"[Webhook] Received request for workflow {workflow_id}")
logger.info(f"[Webhook] Headers: {dict(request.headers)}")
body = await request.body()
logger.info(f"[Webhook] Body length: {len(body)}")
logger.info(f"[Webhook] Body preview: {body[:500]}")
try:
if len(body) == 0:
data = {}
logger.info(f"[Webhook] Empty body received, using empty dict")
else:
data = await request.json()
logger.info(f"[Webhook] Parsed JSON data keys: {list(data.keys()) if isinstance(data, dict) else 'Not a dict'}")
except Exception as e:
logger.error(f"[Webhook] Failed to parse JSON: {e}")
raise HTTPException(status_code=400, detail=f"Invalid JSON payload: {str(e)}")
# Detect provider type based on headers and data structure
if x_slack_signature:
provider_type = "slack"
elif x_telegram_bot_api_secret_token or (data and "update_id" in data):
provider_type = "telegram"
else:
provider_type = "generic"
logger.info(f"[Webhook] Detected provider type: {provider_type}")
logger.info(f"[Webhook] Slack signature present: {bool(x_slack_signature)}")
logger.info(f"[Webhook] Slack timestamp present: {bool(x_slack_request_timestamp)}")
logger.info(f"[Webhook] Telegram secret token present: {bool(x_telegram_bot_api_secret_token)}")
# Handle Slack URL verification challenge first
if provider_type == "slack" and data.get("type") == "url_verification":
logger.info(f"[Webhook] Handling Slack URL verification challenge")
challenge = data.get("challenge")
if challenge:
logger.info(f"[Webhook] Returning challenge: {challenge}")
return JSONResponse(content={"challenge": challenge})
else:
logger.error(f"[Webhook] No challenge found in URL verification request")
raise HTTPException(status_code=400, detail="No challenge found in URL verification request")
if provider_type == "slack" and not data:
logger.info(f"[Webhook] Received empty Slack request, likely verification ping")
if x_slack_signature and x_slack_request_timestamp:
client = await db.client
result = await client.table('workflows').select('*').eq('id', workflow_id).execute()
if result.data:
workflow_data = result.data[0]
workflow = _map_db_to_workflow_definition(workflow_data)
webhook_config = None
for trigger in workflow.triggers:
if trigger.type == 'WEBHOOK' and trigger.config.get('type') == 'slack':
webhook_config = trigger.config
break
if webhook_config and webhook_config.get('slack', {}).get('signing_secret'):
signing_secret = webhook_config['slack']['signing_secret']
if not SlackWebhookProvider.validate_request_timing(x_slack_request_timestamp):
logger.warning(f"[Webhook] Request timestamp is too old")
raise HTTPException(status_code=400, detail="Request timestamp is too old")
if not SlackWebhookProvider.verify_signature(body, x_slack_request_timestamp, x_slack_signature, signing_secret):
logger.warning(f"[Webhook] Invalid Slack signature for empty request")
raise HTTPException(status_code=401, detail="Invalid Slack signature")
logger.info(f"[Webhook] Empty Slack request verified successfully")
else:
logger.warning(f"[Webhook] No signing secret configured for Slack webhook verification")
return JSONResponse(content={"message": "Verification successful"})
client = await db.client
logger.info(f"[Webhook] Looking up workflow {workflow_id} in database")
result = await client.table('workflows').select('*').eq('id', workflow_id).execute()
if not result.data:
logger.error(f"[Webhook] Workflow {workflow_id} not found in database")
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_data = result.data[0]
workflow = _map_db_to_workflow_definition(workflow_data)
logger.info(f"[Webhook] Found workflow: {workflow.name}, state: {workflow.state}")
logger.info(f"[Webhook] Workflow triggers: {[t.type for t in workflow.triggers]}")
if workflow.state not in ['ACTIVE', 'DRAFT']:
logger.error(f"[Webhook] Workflow {workflow_id} is not active or draft (state: {workflow.state})")
raise HTTPException(status_code=400, detail=f"Workflow must be active or draft (current state: {workflow.state})")
has_webhook_trigger = any(trigger.type == 'WEBHOOK' for trigger in workflow.triggers)
if not has_webhook_trigger:
logger.warning(f"[Webhook] Workflow {workflow_id} does not have webhook trigger configured, but allowing for testing")
if provider_type == "slack":
# Skip calling _handle_slack_webhook for empty data since we already handled it above
if not data:
result = {
"should_execute": False,
"response": {"message": "Verification successful"}
}
else:
result = await _handle_slack_webhook(workflow, data, body, x_slack_signature, x_slack_request_timestamp)
elif provider_type == "telegram":
result = await _handle_telegram_webhook(workflow, data, x_telegram_bot_api_secret_token)
else:
result = await _handle_generic_webhook(workflow, data)
if result.get("should_execute", False):
from run_agent_background import run_workflow_background
execution_id = str(uuid.uuid4())
execution_data = {
"id": execution_id,
"workflow_id": workflow.id,
"workflow_version": 1,
"workflow_name": workflow.name,
"execution_context": result.get("execution_variables", {}),
"project_id": workflow.project_id,
"account_id": workflow.created_by,
"triggered_by": "WEBHOOK",
"status": "pending",
"started_at": datetime.now(timezone.utc).isoformat()
}
client = await db.client
await client.table('workflow_executions').insert(execution_data).execute()
thread_id = str(uuid.uuid4())
project_result = await client.table('projects').select('account_id').eq('project_id', workflow.project_id).execute()
if not project_result.data:
raise HTTPException(status_code=404, detail=f"Project {workflow.project_id} not found")
account_id = project_result.data[0]['account_id']
await client.table('threads').insert({
"thread_id": thread_id,
"project_id": workflow.project_id,
"account_id": account_id,
"metadata": {
"workflow_id": workflow.id,
"workflow_name": workflow.name,
"is_workflow_execution": True,
"workflow_run_name": f"Workflow Run: {workflow.name}",
"triggered_by": "WEBHOOK",
"execution_id": execution_id
}
}).execute()
logger.info(f"Created thread for webhook workflow: {thread_id}")
initial_message_content = f"Execute the workflow: {workflow.name}"
if workflow.description:
initial_message_content += f"\n\nDescription: {workflow.description}"
if result.get("execution_variables"):
initial_message_content += f"\n\nWorkflow Variables: {json.dumps(result.get('execution_variables'), indent=2)}"
message_data = {
"message_id": str(uuid.uuid4()),
"thread_id": thread_id,
"type": "user",
"is_llm_message": True,
"content": json.dumps({"role": "user", "content": initial_message_content}),
"created_at": datetime.now(timezone.utc).isoformat()
}
await client.table('messages').insert(message_data).execute()
logger.info(f"Created initial user message for webhook workflow: {thread_id}")
# Small delay to ensure database transaction is committed before background worker starts
import asyncio
await asyncio.sleep(0.1)
agent_run = await client.table('agent_runs').insert({
"thread_id": thread_id,
"status": "running",
"started_at": datetime.now(timezone.utc).isoformat()
}).execute()
agent_run_id = agent_run.data[0]['id']
logger.info(f"Created agent run for webhook workflow: {agent_run_id}")
if hasattr(workflow, 'model_dump'):
workflow_dict = workflow.model_dump(mode='json')
else:
workflow_dict = workflow.dict()
if 'created_at' in workflow_dict and workflow_dict['created_at']:
workflow_dict['created_at'] = workflow_dict['created_at'].isoformat()
if 'updated_at' in workflow_dict and workflow_dict['updated_at']:
workflow_dict['updated_at'] = workflow_dict['updated_at'].isoformat()
run_workflow_background.send(
execution_id=execution_id,
workflow_id=workflow.id,
workflow_name=workflow.name,
workflow_definition=workflow_dict,
variables=result.get("execution_variables", {}),
triggered_by="WEBHOOK",
project_id=workflow.project_id,
thread_id=thread_id,
agent_run_id=agent_run_id
)
return JSONResponse(content={
"message": "Webhook received and workflow execution started",
"workflow_id": workflow_id,
"execution_id": execution_id,
"thread_id": thread_id,
"agent_run_id": agent_run_id,
"provider": provider_type
})
else:
return JSONResponse(content=result.get("response", {"message": "Webhook processed"}))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error processing webhook: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def _handle_slack_webhook(
workflow: WorkflowDefinition,
data: Dict[str, Any],
body: bytes,
signature: Optional[str],
timestamp: Optional[str]
) -> Dict[str, Any]:
"""Handle Slack webhook specifically."""
try:
# Handle empty data (common during Slack verification)
if not data:
logger.info("[Webhook] Empty Slack data received, likely verification ping")
# Still verify signature if provided
if signature and timestamp:
webhook_config = None
for trigger in workflow.triggers:
if trigger.type == 'WEBHOOK' and trigger.config.get('type') == 'slack':
webhook_config = trigger.config
break
if webhook_config and webhook_config.get('slack', {}).get('signing_secret'):
signing_secret = webhook_config['slack']['signing_secret']
if not SlackWebhookProvider.validate_request_timing(timestamp):
raise HTTPException(status_code=400, detail="Request timestamp is too old")
if not SlackWebhookProvider.verify_signature(body, timestamp, signature, signing_secret):
raise HTTPException(status_code=401, detail="Invalid Slack signature")
logger.info("[Webhook] Empty Slack request signature verified")
return {
"should_execute": False,
"response": {"message": "Verification successful"}
}
# Validate as SlackEventRequest
slack_event = SlackEventRequest(**data)
# Handle URL verification challenge
if slack_event.type == "url_verification":
return {
"should_execute": False,
"response": {"challenge": slack_event.challenge}
}
# Handle case where type is None (empty request)
if slack_event.type is None:
logger.info("[Webhook] Slack event with no type, likely verification ping")
return {
"should_execute": False,
"response": {"message": "Verification successful"}
}
webhook_config = None
for trigger in workflow.triggers:
if trigger.type == 'WEBHOOK' and trigger.config.get('type') == 'slack':
webhook_config = trigger.config
break
if not webhook_config:
raise HTTPException(status_code=400, detail="Slack webhook not configured for this workflow")
signing_secret = webhook_config.get('slack', {}).get('signing_secret')
if not signing_secret:
raise HTTPException(status_code=400, detail="Slack signing secret not configured")
if signature and timestamp:
if not SlackWebhookProvider.validate_request_timing(timestamp):
raise HTTPException(status_code=400, detail="Request timestamp is too old")
if not SlackWebhookProvider.verify_signature(body, timestamp, signature, signing_secret):
raise HTTPException(status_code=401, detail="Invalid Slack signature")
payload = SlackWebhookProvider.process_event(slack_event)
if payload:
execution_variables = {
"slack_text": payload.text,
"slack_user_id": payload.user_id,
"slack_channel_id": payload.channel_id,
"slack_team_id": payload.team_id,
"slack_timestamp": payload.timestamp,
"trigger_type": "webhook",
"webhook_provider": "slack"
}
return {
"should_execute": True,
"execution_variables": execution_variables,
"trigger_data": payload.model_dump()
}
else:
return {
"should_execute": False,
"response": {"message": "Event processed but no action needed"}
}
except Exception as e:
logger.error(f"Error handling Slack webhook: {e}")
raise HTTPException(status_code=400, detail=f"Error processing Slack webhook: {str(e)}")
async def _handle_telegram_webhook(
workflow: WorkflowDefinition,
data: Dict[str, Any],
secret_token: Optional[str]
) -> Dict[str, Any]:
"""Handle Telegram webhook specifically."""
try:
# Validate as TelegramUpdateRequest
telegram_update = TelegramUpdateRequest(**data)
# Find Telegram webhook config
webhook_config = None
for trigger in workflow.triggers:
if trigger.type == 'WEBHOOK' and trigger.config.get('type') == 'telegram':
webhook_config = trigger.config
break
if not webhook_config:
raise HTTPException(status_code=400, detail="Telegram webhook not configured for this workflow")
# Verify secret token if configured
if webhook_config.get('telegram', {}).get('secret_token'):
expected_secret = webhook_config['telegram']['secret_token']
if not secret_token or not TelegramWebhookProvider.verify_webhook_secret(b'', secret_token, expected_secret):
raise HTTPException(status_code=401, detail="Invalid Telegram secret token")
payload = TelegramWebhookProvider.process_update(telegram_update)
if payload:
execution_variables = {
"telegram_text": payload.text,
"telegram_user_id": payload.user_id,
"telegram_chat_id": payload.chat_id,
"telegram_message_id": payload.message_id,
"telegram_timestamp": payload.timestamp,
"telegram_update_type": payload.update_type,
"telegram_user_first_name": payload.user_first_name,
"telegram_user_last_name": payload.user_last_name,
"telegram_user_username": payload.user_username,
"telegram_chat_type": payload.chat_type,
"telegram_chat_title": payload.chat_title,
"trigger_type": "webhook",
"webhook_provider": "telegram"
}
return {
"should_execute": True,
"execution_variables": execution_variables,
"trigger_data": payload.model_dump()
}
else:
return {
"should_execute": False,
"response": {"message": "Update processed but no action needed"}
}
except Exception as e:
logger.error(f"Error handling Telegram webhook: {e}")
raise HTTPException(status_code=400, detail=f"Error processing Telegram webhook: {str(e)}")
async def _handle_generic_webhook(workflow: WorkflowDefinition, data: Dict[str, Any]) -> Dict[str, Any]:
"""Handle generic webhook."""
try:
processed_data = GenericWebhookProvider.process_payload(data)
execution_variables = {
"webhook_payload": data,
"trigger_type": "webhook",
"webhook_provider": "generic",
"processed_data": processed_data
}
return {
"should_execute": True,
"execution_variables": execution_variables,
"trigger_data": data
}
except Exception as e:
logger.error(f"Error handling generic webhook: {e}")
raise HTTPException(status_code=400, detail=f"Error processing generic webhook: {str(e)}")
@router.get("/webhooks/test/{workflow_id}")
async def test_webhook_endpoint(workflow_id: str):
if not await is_enabled("workflows"):
raise HTTPException(
status_code=403,
detail="This feature is not available at the moment."
)
"""Test endpoint to verify webhook URL is accessible."""
return {
"message": f"Webhook endpoint for workflow {workflow_id} is accessible",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "ok"
}