该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能: 1. 基础的@提及提取与格式化能力 2. 账号配对与会话管理 3. 消息流与流式回复支持 4. 重试限流与消息去重 5. 富文本格式转换与内容 sanitize 6. 安全策略与配置校验 7. Webhook接收与自动轮询回退 8. 消息收发、编辑、删除与回复 9. 频道与私信管理、反应功能 10. 完整的状态监控与健康检查
178 lines
6.2 KiB
Python
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)
|