ForcePilot/backend/package/yuxi/channel/extensions/clickup/webhook.py
Kris a79fc8dd7b feat(clickup): 新增ClickUp聊天渠道插件完整实现
该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能:
1. 基础的@提及提取与格式化能力
2. 账号配对与会话管理
3. 消息流与流式回复支持
4. 重试限流与消息去重
5. 富文本格式转换与内容 sanitize
6. 安全策略与配置校验
7. Webhook接收与自动轮询回退
8. 消息收发、编辑、删除与回复
9. 频道与私信管理、反应功能
10. 完整的状态监控与健康检查
2026-05-21 10:43:19 +08:00

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)