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)
|