suna/backend/composio_integration/api.py

1014 lines
38 KiB
Python

from fastapi import APIRouter, HTTPException, Depends, Query, Request
from fastapi.responses import JSONResponse
from typing import Dict, Any, Optional
from pydantic import BaseModel
from uuid import uuid4
from utils.auth_utils import get_current_user_id_from_jwt, get_optional_current_user_id_from_jwt
from utils.logger import logger
from services.supabase import DBConnection
from datetime import datetime
import os
import hmac
import httpx
import asyncio
import json
import hashlib
import time
import re
import base64
from .composio_service import (
get_integration_service,
)
from .toolkit_service import ToolkitService, ToolsListResponse
from .composio_profile_service import ComposioProfileService, ComposioProfile
from .composio_trigger_service import ComposioTriggerService
from triggers.trigger_service import get_trigger_service, TriggerEvent, TriggerType
from triggers.execution_service import get_execution_service
from .client import ComposioClient
from triggers.api import sync_triggers_to_version_config
router = APIRouter(prefix="/composio", tags=["composio"])
db: Optional[DBConnection] = None
# Cache is now handled by ComposioTriggerService
def initialize(database: DBConnection):
global db
db = database
COMPOSIO_API_BASE = os.getenv("COMPOSIO_API_BASE", "https://backend.composio.dev")
# Standard webhook verification used for Composio (hex secret, base64 signature)
def verify_std_webhook(wid: str, wts: str, wsig: str, raw: bytes, hex_secret: str, max_skew: int = 300) -> bool:
if not (wid and wts and wsig and hex_secret):
return False
try:
now = int(time.time())
ts_int = int(wts)
if abs(now - ts_int) > max_skew:
return False
except Exception:
# Allow non-epoch timestamps
pass
try:
key = bytes.fromhex(hex_secret)
except Exception:
return False
msg = wid.encode() + b"." + (wts.encode()) + b"." + raw
digest = hmac.new(key, msg, hashlib.sha256).digest()
expected_b64 = base64.b64encode(digest).decode()
candidates = []
for entry in wsig.split():
entry = entry.strip()
if "," in entry: # e.g., "v1,<b64>"
candidates.append(entry.split(",", 1)[1].strip())
elif "=" in entry: # e.g., "sha256=<hex>"
candidates.append(entry.split("=", 1)[1].strip())
else:
candidates.append(entry)
# Compare against expected base64; also tolerate hex
if any(hmac.compare_digest(expected_b64, c) for c in candidates):
return True
try:
expected_hex = digest.hex()
if any(hmac.compare_digest(expected_hex, c.lower()) for c in candidates):
return True
except Exception:
pass
return False
# Drop-in verifier per standard-webhooks style; tries ASCII, HEX, B64 keys and id.ts.body/ts.body
def _parse_sigs(wsig: str):
out = []
for part in wsig.split():
part = part.strip()
if "," in part:
part = part.split(",", 1)[1].strip()
elif "=" in part:
part = part.split("=", 1)[1].strip()
out.append(part)
return out
def _b64(d: bytes) -> str:
return base64.b64encode(d).decode()
async def verify_composio(request: Request, secret_env: str = "COMPOSIO_WEBHOOK_SECRET", max_skew: int = 300) -> bool:
secret = os.getenv(secret_env, "")
if not secret:
raise HTTPException(status_code=500, detail="Webhook secret not configured")
wid = request.headers.get("webhook-id", "")
wts = request.headers.get("webhook-timestamp", "")
wsig = request.headers.get("webhook-signature", "")
if not (wid and wts and wsig):
raise HTTPException(status_code=401, detail="Missing standard-webhooks headers")
# normalize timestamp tolerance
try:
ts = int(wts)
if ts > 10**12:
ts //= 1000
if abs(int(time.time()) - ts) > max_skew:
raise HTTPException(status_code=401, detail="Timestamp outside tolerance")
except ValueError:
pass
raw = await request.body()
keys = [("ascii", secret.encode())]
try:
keys.append(("hex", bytes.fromhex(secret)))
except Exception:
pass
try:
keys.append(("b64", base64.b64decode(secret, validate=False)))
except Exception:
pass
msgs = [
("id.ts.body", wid.encode() + b"." + wts.encode() + b"." + raw),
("ts.body", wts.encode() + b"." + raw),
]
header_sigs = _parse_sigs(wsig)
for kname, key in keys:
for mname, msg in msgs:
dig = hmac.new(key, msg, hashlib.sha256).digest()
exp_b64 = _b64(dig)
if any(hmac.compare_digest(exp_b64, s) for s in header_sigs):
request.state._sig_match = (kname, mname, "b64")
return True
exp_hex = dig.hex()
if any(hmac.compare_digest(exp_hex, s.lower()) for s in header_sigs):
request.state._sig_match = (kname, mname, "hex")
return True
request.state._sig_match = ("none", "none", "none")
raise HTTPException(status_code=401, detail="Invalid signature")
class IntegrateToolkitRequest(BaseModel):
toolkit_slug: str
profile_name: Optional[str] = None
display_name: Optional[str] = None
mcp_server_name: Optional[str] = None
save_as_profile: bool = True
class IntegrationStatusResponse(BaseModel):
status: str
toolkit: str
auth_config_id: str
connected_account_id: str
mcp_server_id: str
final_mcp_url: str
profile_id: Optional[str] = None
redirect_url: Optional[str] = None
class CreateProfileRequest(BaseModel):
toolkit_slug: str
profile_name: str
display_name: Optional[str] = None
mcp_server_name: Optional[str] = None
is_default: bool = False
initiation_fields: Optional[Dict[str, str]] = None
class ToolsListRequest(BaseModel):
toolkit_slug: str
limit: int = 50
cursor: Optional[str] = None
class ProfileResponse(BaseModel):
profile_id: str
profile_name: str
display_name: str
toolkit_slug: str
toolkit_name: str
mcp_url: str
redirect_url: Optional[str] = None
connected_account_id: Optional[str] = None
is_connected: bool
is_default: bool
created_at: str
@classmethod
def from_composio_profile(cls, profile: ComposioProfile) -> "ProfileResponse":
return cls(
profile_id=profile.profile_id,
profile_name=profile.profile_name,
display_name=profile.display_name,
toolkit_slug=profile.toolkit_slug,
toolkit_name=profile.toolkit_name,
mcp_url=profile.mcp_url,
redirect_url=profile.redirect_url,
connected_account_id=getattr(profile, 'connected_account_id', None),
is_connected=profile.is_connected,
is_default=profile.is_default,
created_at=profile.created_at.isoformat() if profile.created_at else datetime.now().isoformat()
)
@router.get("/categories")
async def list_categories(
user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
logger.debug("Fetching Composio categories")
toolkit_service = ToolkitService()
categories = await toolkit_service.list_categories()
return {
"success": True,
"categories": [cat.dict() for cat in categories],
"total": len(categories)
}
except Exception as e:
logger.error(f"Failed to fetch categories: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to fetch categories: {str(e)}")
@router.get("/toolkits")
async def list_toolkits(
limit: int = Query(100, le=500),
cursor: Optional[str] = Query(None),
search: Optional[str] = Query(None),
category: Optional[str] = Query(None),
user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
logger.debug(f"Fetching Composio toolkits with limit: {limit}, cursor: {cursor}, search: {search}, category: {category}")
service = get_integration_service()
if search:
result = await service.search_toolkits(search, category=category, limit=limit, cursor=cursor)
else:
result = await service.list_available_toolkits(limit, cursor=cursor, category=category)
return {
"success": True,
"toolkits": [toolkit.dict() for toolkit in result.get('items', [])],
"total_items": result.get('total_items', 0),
"total_pages": result.get('total_pages', 0),
"current_page": result.get('current_page', 1),
"next_cursor": result.get('next_cursor'),
"has_more": result.get('next_cursor') is not None
}
except Exception as e:
logger.error(f"Failed to fetch toolkits: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to fetch toolkits: {str(e)}")
@router.get("/toolkits/{toolkit_slug}/details")
async def get_toolkit_details(
toolkit_slug: str,
user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
logger.debug(f"Fetching detailed toolkit info for: {toolkit_slug}")
toolkit_service = ToolkitService()
detailed_toolkit = await toolkit_service.get_detailed_toolkit_info(toolkit_slug)
if not detailed_toolkit:
raise HTTPException(status_code=404, detail=f"Toolkit {toolkit_slug} not found")
return {
"success": True,
"toolkit": detailed_toolkit.dict()
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to fetch toolkit details for {toolkit_slug}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to fetch toolkit details: {str(e)}")
@router.post("/integrate", response_model=IntegrationStatusResponse)
async def integrate_toolkit(
request: IntegrateToolkitRequest,
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> IntegrationStatusResponse:
try:
integration_user_id = str(uuid4())
logger.debug(f"Generated integration user_id: {integration_user_id} for account: {current_user_id}")
service = get_integration_service(db_connection=db)
result = await service.integrate_toolkit(
toolkit_slug=request.toolkit_slug,
account_id=current_user_id,
user_id=integration_user_id,
profile_name=request.profile_name,
display_name=request.display_name,
mcp_server_name=request.mcp_server_name,
save_as_profile=request.save_as_profile
)
return IntegrationStatusResponse(
status="integrated",
toolkit=result.toolkit.name,
auth_config_id=result.auth_config.id,
connected_account_id=result.connected_account.id,
mcp_server_id=result.mcp_server.id,
final_mcp_url=result.final_mcp_url,
profile_id=result.profile_id,
redirect_url=result.connected_account.redirect_url
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Integration failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/profiles", response_model=ProfileResponse)
async def create_profile(
request: CreateProfileRequest,
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> ProfileResponse:
try:
integration_user_id = str(uuid4())
logger.debug(f"Generated integration user_id: {integration_user_id} for account: {current_user_id}")
service = get_integration_service(db_connection=db)
result = await service.integrate_toolkit(
toolkit_slug=request.toolkit_slug,
account_id=current_user_id,
user_id=integration_user_id,
profile_name=request.profile_name,
display_name=request.display_name,
mcp_server_name=request.mcp_server_name,
save_as_profile=True,
initiation_fields=request.initiation_fields
)
logger.debug(f"Integration result for {request.toolkit_slug}: redirect_url = {result.connected_account.redirect_url}")
profile_service = ComposioProfileService(db)
profiles = await profile_service.get_profiles(current_user_id, request.toolkit_slug)
created_profile = None
for profile in profiles:
if profile.profile_name == request.profile_name:
created_profile = profile
break
if not created_profile:
raise HTTPException(status_code=500, detail="Profile created but not found")
logger.debug(f"Returning profile response with redirect_url: {created_profile.redirect_url}")
return ProfileResponse.from_composio_profile(created_profile)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create profile: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/profiles")
async def get_profiles(
toolkit_slug: Optional[str] = Query(None),
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
profile_service = ComposioProfileService(db)
profiles = await profile_service.get_profiles(current_user_id, toolkit_slug)
profile_responses = [ProfileResponse.from_composio_profile(profile) for profile in profiles]
return {
"success": True,
"profiles": profile_responses
}
except Exception as e:
logger.error(f"Failed to get profiles: {e}", exc_info=True)
return {
"success": False,
"profiles": [],
"error": str(e)
}
@router.get("/profiles/{profile_id}/mcp-config")
async def get_profile_mcp_config(
profile_id: str,
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
profile_service = ComposioProfileService(db)
mcp_config = await profile_service.get_mcp_config_for_agent(profile_id)
return {
"success": True,
"mcp_config": mcp_config,
"profile_id": profile_id
}
except Exception as e:
logger.error(f"Failed to get MCP config for profile {profile_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get MCP config: {str(e)}")
@router.get("/profiles/{profile_id}")
async def get_profile_info(
profile_id: str,
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
profile_service = ComposioProfileService(db)
profile = await profile_service.get_profile(profile_id, current_user_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return {
"success": True,
"profile": {
"profile_id": profile.profile_id,
"profile_name": profile.profile_name,
"toolkit_name": profile.toolkit_name,
"toolkit_slug": profile.toolkit_slug,
"created_at": profile.created_at.isoformat() if profile.created_at else None
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get profile info for {profile_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get profile info: {str(e)}")
@router.get("/integration/{connected_account_id}/status")
async def get_integration_status(
connected_account_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
service = get_integration_service()
status = await service.get_integration_status(connected_account_id)
return {"connected_account_id": connected_account_id, **status}
except Exception as e:
logger.error(f"Failed to get status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/profiles/{profile_id}/discover-tools")
async def discover_composio_tools(
profile_id: str,
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
try:
profile_service = ComposioProfileService(db)
config = await profile_service.get_profile_config(profile_id)
if config.get('type') != 'composio':
raise HTTPException(status_code=400, detail="Not a Composio profile")
mcp_url = config.get('mcp_url')
if not mcp_url:
raise HTTPException(status_code=400, detail="Profile has no MCP URL")
from mcp_module.mcp_service import mcp_service
result = await mcp_service.discover_custom_tools(
request_type="http",
config={"url": mcp_url}
)
if not result.success:
raise HTTPException(status_code=500, detail=f"Failed to discover tools: {result.message}")
logger.debug(f"Discovered {len(result.tools)} tools from Composio profile {profile_id}")
return {
"success": True,
"profile_id": profile_id,
"toolkit_name": config.get('toolkit_name', 'Unknown'),
"tools": result.tools,
"total_tools": len(result.tools)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to discover tools for profile {profile_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/discover-tools/{profile_id}")
async def discover_tools_post(
profile_id: str,
current_user_id: str = Depends(get_current_user_id_from_jwt)
) -> Dict[str, Any]:
return await discover_composio_tools(profile_id, current_user_id)
@router.get("/toolkits/{toolkit_slug}/icon")
async def get_toolkit_icon(
toolkit_slug: str,
current_user_id: Optional[str] = Depends(get_optional_current_user_id_from_jwt)
):
try:
toolkit_service = ToolkitService()
icon_url = await toolkit_service.get_toolkit_icon(toolkit_slug)
if icon_url:
return {
"success": True,
"toolkit_slug": toolkit_slug,
"icon_url": icon_url
}
else:
return {
"success": False,
"toolkit_slug": toolkit_slug,
"icon_url": None,
"message": "Icon not found"
}
except Exception as e:
logger.error(f"Error getting toolkit icon: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/tools/list")
async def list_toolkit_tools(
request: ToolsListRequest,
current_user_id: str = Depends(get_current_user_id_from_jwt)
):
try:
logger.debug(f"User {current_user_id} requesting tools for toolkit: {request.toolkit_slug}")
toolkit_service = ToolkitService()
tools_response = await toolkit_service.get_toolkit_tools(
toolkit_slug=request.toolkit_slug,
limit=request.limit,
cursor=request.cursor
)
return {
"success": True,
"tools": [tool.dict() for tool in tools_response.items],
"total_items": tools_response.total_items,
"current_page": tools_response.current_page,
"total_pages": tools_response.total_pages,
"next_cursor": tools_response.next_cursor
}
except Exception as e:
logger.error(f"Failed to list toolkit tools for {request.toolkit_slug}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get toolkit tools: {str(e)}")
@router.get("/triggers/apps")
async def list_apps_with_triggers(
user_id: str = Depends(get_current_user_id_from_jwt),
) -> Dict[str, Any]:
try:
trigger_service = ComposioTriggerService()
return await trigger_service.list_apps_with_triggers()
except httpx.HTTPError as e:
logger.error(f"Failed to fetch Composio triggers/apps: {e}")
raise HTTPException(status_code=502, detail="Composio API error")
except Exception as e:
logger.error(f"Error building apps-with-triggers list: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/triggers/apps/{toolkit_slug}")
async def list_triggers_for_app(
toolkit_slug: str,
user_id: str = Depends(get_current_user_id_from_jwt),
) -> Dict[str, Any]:
try:
trigger_service = ComposioTriggerService()
return await trigger_service.list_triggers_for_app(toolkit_slug)
except httpx.HTTPError as e:
logger.error(f"Failed to fetch triggers for app {toolkit_slug}: {e}")
raise HTTPException(status_code=502, detail="Composio API error")
except Exception as e:
logger.error(f"Error listing triggers for app {toolkit_slug}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
class CreateComposioTriggerRequest(BaseModel):
agent_id: str
profile_id: str
slug: str
trigger_config: Dict[str, Any]
route: str # 'agent' | 'workflow'
name: Optional[str] = None
agent_prompt: Optional[str] = None
workflow_id: Optional[str] = None
workflow_input: Optional[Dict[str, Any]] = None
connected_account_id: Optional[str] = None
webhook_url: Optional[str] = None
toolkit_slug: Optional[str] = None
@router.post("/triggers/create")
async def create_composio_trigger(req: CreateComposioTriggerRequest, current_user_id: str = Depends(get_current_user_id_from_jwt)) -> Dict[str, Any]:
try:
client_db = await db.client
agent_check = await client_db.table('agents').select('agent_id').eq('agent_id', req.agent_id).eq('account_id', current_user_id).execute()
if not agent_check.data:
raise HTTPException(status_code=404, detail="Agent not found or access denied")
profile_service = ComposioProfileService(db)
profile_config = await profile_service.get_profile_config(req.profile_id)
composio_user_id = profile_config.get("user_id")
if not composio_user_id:
raise HTTPException(status_code=400, detail="Composio profile is missing user_id")
toolkit_slug = req.toolkit_slug
if not toolkit_slug:
toolkit_slug = profile_config.get("toolkit_slug")
if not toolkit_slug and req.slug:
toolkit_slug = req.slug.split('_')[0].lower() if '_' in req.slug else 'composio'
qualified_name = f'composio.{toolkit_slug}' if toolkit_slug and toolkit_slug != 'composio' else 'composio'
api_key = os.getenv("COMPOSIO_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="COMPOSIO_API_KEY not configured")
url = f"{COMPOSIO_API_BASE}/api/v3/trigger_instances/{req.slug}/upsert"
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
base_url = os.getenv("WEBHOOK_BASE_URL", "http://localhost:8000")
secret = os.getenv("COMPOSIO_WEBHOOK_SECRET", "")
webhook_headers = {"X-Composio-Secret": secret} if secret else {}
vercel_bypass = os.getenv("VERCEL_PROTECTION_BYPASS_KEY", "")
if vercel_bypass:
webhook_headers["X-Vercel-Protection-Bypass"] = vercel_bypass
coerced_config = dict(req.trigger_config or {})
try:
type_url = f"{COMPOSIO_API_BASE}/api/v3/triggers_types/{req.slug}"
async with httpx.AsyncClient(timeout=10) as http_client:
tr = await http_client.get(type_url, headers=headers)
if tr.status_code == 200:
tdata = tr.json()
schema = tdata.get("config") or {}
props = schema.get("properties") or {}
for key, prop in props.items():
if key not in coerced_config:
continue
val = coerced_config[key]
ptype = prop.get("type") if isinstance(prop, dict) else None
try:
if ptype == "array":
if isinstance(val, str):
coerced_config[key] = [val]
elif ptype == "integer":
if isinstance(val, str) and val.isdigit():
coerced_config[key] = int(val)
elif ptype == "number":
if isinstance(val, str):
coerced_config[key] = float(val)
elif ptype == "boolean":
if isinstance(val, str):
coerced_config[key] = val.lower() in ("true", "1", "yes")
elif ptype == "string":
if isinstance(val, (list, tuple)):
# join list into comma-separated string
coerced_config[key] = ",".join(str(x) for x in val)
elif not isinstance(val, str):
coerced_config[key] = str(val)
except Exception:
pass
except Exception:
pass
body = {
"user_id": composio_user_id,
"trigger_config": coerced_config,
}
if req.connected_account_id:
body["connected_account_id"] = req.connected_account_id
async with httpx.AsyncClient(timeout=20) as http_client:
resp = await http_client.post(url, headers=headers, json=body)
try:
resp.raise_for_status()
except httpx.HTTPStatusError:
ct = resp.headers.get("content-type", "")
if "application/json" in ct:
detail = resp.json()
else:
detail = resp.text
logger.error(f"Composio upsert error: {detail}")
raise HTTPException(status_code=400, detail=detail)
created = resp.json()
try:
top_keys = list(created.keys()) if isinstance(created, dict) else None
logger.debug(
"Composio upsert ok",
slug=req.slug,
status_code=resp.status_code,
top_keys=top_keys,
)
except Exception:
pass
composio_trigger_id = None
def _extract_id(obj: Dict[str, Any]) -> Optional[str]:
if not isinstance(obj, dict):
return None
cand = (
obj.get("id")
or obj.get("trigger_id")
or obj.get("triggerId")
or obj.get("nano_id")
or obj.get("nanoId")
or obj.get("triggerNanoId")
)
if cand:
return cand
# Nested shapes
for k in ("trigger", "trigger_instance", "triggerInstance", "data", "result"):
nested = obj.get(k)
if isinstance(nested, dict):
nid = _extract_id(nested)
if nid:
return nid
if isinstance(nested, list) and nested:
nid = _extract_id(nested[0])
if nid:
return nid
return None
if isinstance(created, dict):
composio_trigger_id = _extract_id(created)
try:
logger.debug(
"Composio extracted trigger id",
slug=req.slug,
extracted_id=composio_trigger_id,
)
except Exception:
pass
if not composio_trigger_id:
raise HTTPException(status_code=500, detail="Failed to get Composio trigger id from response")
# Build Suna trigger config
suna_config: Dict[str, Any] = {
"provider_id": "composio",
"composio_trigger_id": composio_trigger_id,
"trigger_slug": req.slug,
"qualified_name": qualified_name, # Store the qualified_name for template export
"execution_type": req.route if req.route in ("agent", "workflow") else "agent",
"profile_id": req.profile_id,
}
if suna_config["execution_type"] == "agent":
if req.agent_prompt:
suna_config["agent_prompt"] = req.agent_prompt
else:
if not req.workflow_id:
raise HTTPException(status_code=400, detail="workflow_id is required for workflow route")
suna_config["workflow_id"] = req.workflow_id
if req.workflow_input:
suna_config["workflow_input"] = req.workflow_input
# Create Suna trigger
trigger_service = get_trigger_service(db)
trigger = await trigger_service.create_trigger(
agent_id=req.agent_id,
provider_id="composio",
name=req.name or f"{req.slug}",
config=suna_config,
description=f"Composio event: {req.slug}"
)
# Immediately sync triggers to the current version config
await sync_triggers_to_version_config(req.agent_id)
base_url = os.getenv("WEBHOOK_BASE_URL", "http://localhost:8000")
webhook_url = f"{base_url}/api/composio/webhook"
return {
"success": True,
"trigger_id": trigger.trigger_id,
"agent_id": trigger.agent_id,
"provider": "composio",
"composio_trigger_id": composio_trigger_id,
"slug": req.slug,
"webhook_url": webhook_url,
"config": trigger.config,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create Composio trigger: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webhook")
async def composio_webhook(request: Request):
"""Shared Composio webhook endpoint. Verifies secret, matches triggers, and enqueues execution."""
try:
# Read raw body first (can only be done once)
try:
body = await request.body()
body_str = body.decode('utf-8') if body else ""
logger.info("Composio webhook raw body", body=body_str, body_length=len(body) if body else 0)
except Exception as e:
logger.info("Composio webhook body read failed", error=str(e))
body_str = ""
# Get webhook ID early for logging
wid = request.headers.get("webhook-id", "")
# Minimal request diagnostics (no secrets)
try:
client_ip = request.client.host if request.client else None
header_names = list(request.headers.keys())
has_auth = bool(request.headers.get("authorization"))
has_x_secret = bool(request.headers.get("x-composio-secret") or request.headers.get("X-Composio-Secret"))
has_x_trigger = bool(request.headers.get("x-trigger-secret") or request.headers.get("X-Trigger-Secret"))
# Parse payload for logging
payload_preview = {"keys": []}
try:
if body_str:
_p = json.loads(body_str)
payload_preview = {
"keys": list(_p.keys()) if isinstance(_p, dict) else [],
"id": _p.get("id") if isinstance(_p, dict) else None,
"triggerSlug": _p.get("triggerSlug") if isinstance(_p, dict) else None,
}
except Exception:
payload_preview = {"keys": []}
except Exception:
pass
secret = os.getenv("COMPOSIO_WEBHOOK_SECRET")
if not secret:
logger.error("COMPOSIO_WEBHOOK_SECRET is not configured")
raise HTTPException(status_code=500, detail="Webhook secret not configured")
# Use robust verifier (tries ASCII/HEX/B64 keys and id.ts.body/ts.body)
await verify_composio(request, "COMPOSIO_WEBHOOK_SECRET")
# Parse payload for processing
try:
payload = json.loads(body_str) if body_str else {}
except Exception as parse_error:
logger.error(f"Failed to parse webhook payload: {parse_error}", payload_raw=body_str)
payload = {}
# Look for trigger_nano_id in data.trigger_nano_id (the actual Composio trigger instance ID)
composio_trigger_id = (
(payload.get("data", {}) or {}).get("trigger_nano_id")
)
provider_event_id = (
payload.get("eventId")
or payload.get("payload", {}).get("id")
or payload.get("id")
or wid
)
# Derive trigger slug from various shapes
trigger_slug = (
payload.get("triggerSlug")
or payload.get("type")
or (payload.get("data", {}) or {}).get("triggerSlug")
or (payload.get("data", {}) or {}).get("type")
)
# Basic parsed-field logging (no secrets)
try:
logger.info(
"Composio parsed fields",
webhook_id=wid,
trigger_slug=trigger_slug,
composio_trigger_id=composio_trigger_id,
provider_event_id=provider_event_id,
payload_keys=list(payload.keys()) if isinstance(payload, dict) else [],
)
except Exception:
pass
client = await db.client
# Fetch all active WEBHOOK triggers and filter by provider 'composio'
# If neither id nor slug present, ack 200 to avoid Composio retries
if not (composio_trigger_id or trigger_slug):
logger.warning("No trigger id or slug; acking 200")
return JSONResponse(content={"success": True, "matched_triggers": 0})
try:
res = await client.table("agent_triggers").select("*").eq("trigger_type", "webhook").eq("is_active", True).execute()
rows = res.data or []
except Exception as e:
logger.error(f"Error fetching agent_triggers: {e}")
rows = []
matched = []
for row in rows:
cfg = row.get("config") or {}
if not isinstance(cfg, dict):
continue
prov = cfg.get("provider_id") or row.get("provider_id")
if prov != "composio":
logger.debug("Composio skip non-provider", trigger_id=row.get("trigger_id"), provider_id=prov)
continue
# ONLY match by exact composio_trigger_id - no slug fallback
cfg_tid = cfg.get("composio_trigger_id")
if composio_trigger_id and cfg_tid == composio_trigger_id:
logger.info(
"Composio EXACT ID MATCH",
trigger_id=row.get("trigger_id"),
cfg_composio_trigger_id=cfg_tid,
payload_composio_trigger_id=composio_trigger_id,
is_active=row.get("is_active")
)
matched.append(row)
continue
else:
logger.info(
"Composio ID mismatch",
trigger_id=row.get("trigger_id"),
cfg_composio_trigger_id=cfg_tid,
payload_composio_trigger_id=composio_trigger_id,
match_found=False,
is_active=row.get("is_active")
)
if not matched:
logger.error(
f"No exact ID match found for Composio trigger {composio_trigger_id}",
payload_id=composio_trigger_id,
total_triggers=len(rows),
matched_count=len(matched)
)
return JSONResponse(content={"success": True, "matched_triggers": 0})
trigger_service = get_trigger_service(db)
execution_service = get_execution_service(db)
executed = 0
for row in matched:
trigger_id = row.get("trigger_id")
if not trigger_id:
continue
result = await trigger_service.process_trigger_event(trigger_id, payload)
if result.success and (result.should_execute_agent or result.should_execute_workflow):
trigger = await trigger_service.get_trigger(trigger_id)
if not trigger:
continue
ctx = {
"payload": payload,
"trigger_slug": trigger_slug,
"webhook_id": wid,
}
event = TriggerEvent(
trigger_id=trigger_id,
agent_id=trigger.agent_id,
trigger_type=TriggerType.EVENT,
raw_data=payload,
context=ctx,
)
await execution_service.execute_trigger_result(
agent_id=trigger.agent_id,
trigger_result=result,
trigger_event=event,
)
executed += 1
return JSONResponse(content={
"success": True,
"matched_triggers": len(matched),
"executed": executed,
})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error handling Composio webhook: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": "Internal server error"})
@router.get("/health")
async def health_check() -> Dict[str, str]:
try:
from .client import ComposioClient
ComposioClient.get_client()
return {"status": "healthy"}
except Exception as e:
logger.error(f"Health check failed: {e}")
raise HTTPException(status_code=503, detail=str(e))