ForcePilot/backend/package/yuxi/channel/extensions/clickup/gateway.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

178 lines
6.2 KiB
Python

import asyncio
import logging
import time
from yuxi.channel.extensions.clickup.dedupe import MessageDeduplicator
from yuxi.channel.extensions.clickup.polling import ClickUpPolling
from yuxi.channel.extensions.clickup.types import ClickUpAccount, InboundClickUpMessage
logger = logging.getLogger(__name__)
_active_gateway: 'ClickUpGateway | None' = None
def _get_webhook_queue() -> asyncio.Queue | None:
if _active_gateway is not None:
return _active_gateway._queue
return None
class ClickUpGateway:
def __init__(self):
self._running = False
self._last_message_at: float | None = None
self._tasks: list[asyncio.Task] = []
self._queue: asyncio.Queue | None = None
self._deduplicator = MessageDeduplicator(max_size=10000, ttl_seconds=300)
self._polling = ClickUpPolling()
self._abort_event: asyncio.Event | None = None
async def start(self, ctx) -> object:
global _active_gateway
account = self._resolve_account(ctx)
if not account.is_configured:
logger.warning("clickup account %s not configured, skipping start", account.account_id)
return {"running": False, "reason": "not-configured"}
self._queue = asyncio.Queue(maxsize=1000)
self._running = True
_active_gateway = self
task = asyncio.create_task(self._webhook_processing_loop(account))
self._tasks.append(task)
if account.poll_fallback_enabled:
self._abort_event = asyncio.Event()
await self._polling.start(account, self._queue, self._abort_event)
logger.info("clickup gateway started for account %s", account.account_id)
return {"running": True, "account_id": account.account_id}
async def stop(self, ctx) -> None:
global _active_gateway
self._running = False
if self._abort_event is not None:
self._abort_event.set()
self._abort_event = None
await self._polling.stop()
for task in self._tasks:
task.cancel()
self._tasks.clear()
self._queue = None
_active_gateway = None
logger.info("clickup gateway stopped")
async def _webhook_processing_loop(self, account: ClickUpAccount):
while self._running and self._queue is not None:
try:
payload = await asyncio.wait_for(self._queue.get(), timeout=1.0)
await self._process_payload(payload, account)
except TimeoutError:
continue
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error processing ClickUp webhook payload")
async def _process_payload(self, payload: dict, account: ClickUpAccount):
msg = self._parse_webhook_payload(payload)
if msg is None:
logger.debug("ClickUp webhook: unable to parse message, skipping")
return
if self._is_self_message(msg, account):
logger.debug("ClickUp self-message ignored: %s", msg.message_id)
return
if self._deduplicator.is_duplicate(msg.message_id):
logger.debug("ClickUp duplicate message ignored: %s", msg.message_id)
return
unified = self._to_unified_message(msg)
if unified is None:
return
if self._queue is not None:
self._last_message_at = time.monotonic()
await self._queue.put(unified)
logger.debug("ClickUp message enqueued: sender=%s chat=%s", msg.user_id, msg.chat_id)
def is_duplicate(self, key: str) -> bool:
return self._deduplicator.is_duplicate(key)
def mark_seen(self, key: str) -> None:
self._deduplicator.is_duplicate(key)
@staticmethod
def _parse_webhook_payload(payload: dict) -> InboundClickUpMessage | None:
try:
msg_id = payload.get("id", "")
if not msg_id:
return None
chat = payload.get("channel", {})
chat_id = chat.get("id", "") if isinstance(chat, dict) else str(chat)
user = payload.get("user", {})
user_id = str(user.get("id", "")) if isinstance(user, dict) else str(user)
content = payload.get("content", "")
content_format = payload.get("content_format", "text/md")
chat_type = "direct" if payload.get("chat_type") == "DM" else "group"
return InboundClickUpMessage(
message_id=msg_id,
chat_id=chat_id,
chat_type=chat_type,
user_id=user_id,
user_name=user.get("name", "") if isinstance(user, dict) else "",
content=content,
content_format=content_format,
reply_to_id=payload.get("reply_to_id"),
raw_payload=payload,
)
except Exception:
logger.exception("Failed to parse ClickUp webhook payload")
return None
@staticmethod
def _is_self_message(msg: InboundClickUpMessage, account: ClickUpAccount) -> bool:
return False
def _to_unified_message(self, msg: InboundClickUpMessage) -> dict | None:
try:
return {
"channel": "clickup",
"msg_id": msg.message_id,
"chat_id": msg.chat_id,
"chat_type": msg.chat_type,
"sender": {"id": msg.user_id, "name": msg.user_name},
"text": msg.content,
"content_format": msg.content_format,
"reply_to_id": msg.reply_to_id,
"timestamp": msg.created_at.isoformat() if msg.created_at else None,
"raw": msg.raw_payload,
}
except Exception:
logger.exception("Failed to convert to unified message")
return None
@staticmethod
def _resolve_account(ctx) -> ClickUpAccount:
from yuxi.channel.extensions.clickup.config import ClickUpConfigAdapter
config = getattr(ctx, "config", {}) if ctx else {}
account_id = getattr(ctx, "account_id", "default") if ctx else "default"
accounts = config.get("accounts", {})
raw = accounts.get(account_id, {})
adapter = ClickUpConfigAdapter()
return adapter._build_account(account_id, raw)