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)