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" }