72 lines
2.2 KiB
Python
72 lines
2.2 KiB
Python
|
|
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)
|