import hashlib import hmac import json import logging from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse from yuxi.channel.extensions.clickup.config import _env_or_config logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/clickup", tags=["clickup"]) @router.post("/automation") async def receive_automation(request: Request): headers = request.headers body = await request.body() if not body: logger.warning("ClickUp webhook received empty body") return JSONResponse({"status": "ok"}) if not _verify_webhook_secret(headers, body): logger.warning("ClickUp webhook secret verification failed") raise HTTPException(status_code=403, detail="Invalid webhook secret") try: payload = json.loads(body) except json.JSONDecodeError: logger.error("ClickUp webhook: invalid JSON payload") return JSONResponse({"status": "error", "reason": "invalid-json"}, status_code=400) logger.debug("ClickUp webhook received: %s", json.dumps(payload, indent=2, ensure_ascii=False)) from yuxi.channel.extensions.clickup.gateway import _get_webhook_queue queue = _get_webhook_queue() if queue is not None: await queue.put(payload) return JSONResponse({"status": "ok"}) def _verify_webhook_secret(headers, raw_body: bytes | None = None) -> bool: expected = _env_or_config("webhook_secret") if not expected: logger.warning("CLICKUP_WEBHOOK_SECRET not set, skipping webhook secret verification") return True signature_header = headers.get("X-Signature") if signature_header and raw_body is not None: return _verify_signature(raw_body, expected, signature_header) provided = headers.get("Authorization", "").replace("Bearer ", "").strip() if not provided: provided = headers.get("X-ClickUp-Secret", "").strip() return hmac.compare_digest(provided, expected) def _verify_signature(raw_body: bytes, secret: str, signature_header: str | None) -> bool: if not signature_header: return False expected = hmac.new( secret.encode("utf-8"), raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature_header)