该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能: 1. 基础的@提及提取与格式化能力 2. 账号配对与会话管理 3. 消息流与流式回复支持 4. 重试限流与消息去重 5. 富文本格式转换与内容 sanitize 6. 安全策略与配置校验 7. Webhook接收与自动轮询回退 8. 消息收发、编辑、删除与回复 9. 频道与私信管理、反应功能 10. 完整的状态监控与健康检查
256 lines
8.4 KiB
Python
256 lines
8.4 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.clickup.dedupe import MessageDeduplicator
|
|
from yuxi.channel.extensions.clickup.types import ClickUpAccount
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
API_V3_BASE = "https://api.clickup.com/api/v3"
|
|
|
|
|
|
class ClickUpPolling:
|
|
def __init__(self):
|
|
self._running = False
|
|
self._task: asyncio.Task | None = None
|
|
self._last_polled_at: float = 0.0
|
|
self._deduplicator = MessageDeduplicator(max_size=10000, ttl_seconds=300)
|
|
self._cursor: str | None = None
|
|
|
|
async def start(
|
|
self,
|
|
account: ClickUpAccount,
|
|
queue: asyncio.Queue,
|
|
abort_event: asyncio.Event,
|
|
) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
self._running = True
|
|
self._last_polled_at = time.time()
|
|
self._task = asyncio.create_task(self._polling_loop(account, queue, abort_event))
|
|
logger.info("clickup polling started for account %s, interval=%.1fs", account.account_id, account.poll_interval)
|
|
|
|
async def stop(self) -> None:
|
|
self._running = False
|
|
if self._task:
|
|
self._task.cancel()
|
|
try:
|
|
await self._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._task = None
|
|
logger.info("clickup polling stopped")
|
|
|
|
async def _polling_loop(
|
|
self,
|
|
account: ClickUpAccount,
|
|
queue: asyncio.Queue,
|
|
abort_event: asyncio.Event,
|
|
) -> None:
|
|
backoff = 1
|
|
max_backoff = 300
|
|
interval = account.poll_interval
|
|
|
|
while self._running and not abort_event.is_set():
|
|
try:
|
|
new_messages = await self._fetch_recent_messages(account)
|
|
for msg in new_messages:
|
|
if queue.qsize() < queue.maxsize:
|
|
await queue.put(msg)
|
|
backoff = 1
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception:
|
|
logger.exception("clickup polling error, backoff=%ds", backoff)
|
|
await asyncio.sleep(backoff)
|
|
backoff = min(backoff * 2, max_backoff)
|
|
continue
|
|
|
|
try:
|
|
await asyncio.wait_for(abort_event.wait(), timeout=interval)
|
|
break
|
|
except TimeoutError:
|
|
continue
|
|
|
|
async def _fetch_recent_messages(self, account: ClickUpAccount) -> list[dict]:
|
|
headers = {"Authorization": account.api_token}
|
|
results: list[dict] = []
|
|
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
channel_messages = await self._poll_channel_messages(account, client, headers)
|
|
results.extend(channel_messages)
|
|
|
|
dm_messages = await self._poll_dm_messages(account, client, headers)
|
|
results.extend(dm_messages)
|
|
|
|
self._last_polled_at = time.time()
|
|
return results
|
|
|
|
async def _poll_channel_messages(
|
|
self,
|
|
account: ClickUpAccount,
|
|
client: httpx.AsyncClient,
|
|
headers: dict,
|
|
) -> list[dict]:
|
|
try:
|
|
channels = await self._list_channels(account, client, headers)
|
|
except Exception:
|
|
logger.exception("clickup polling: failed to list channels")
|
|
return []
|
|
|
|
results: list[dict] = []
|
|
for channel in channels:
|
|
channel_id = channel.get("id", "")
|
|
if not channel_id:
|
|
continue
|
|
try:
|
|
messages = await self._fetch_channel_messages(account, client, headers, channel_id)
|
|
results.extend(messages)
|
|
except Exception:
|
|
logger.exception("clickup polling: failed to fetch messages for channel %s", channel_id)
|
|
continue
|
|
|
|
return results
|
|
|
|
async def _poll_dm_messages(
|
|
self,
|
|
account: ClickUpAccount,
|
|
client: httpx.AsyncClient,
|
|
headers: dict,
|
|
) -> list[dict]:
|
|
url = f"{API_V3_BASE}/workspaces/{account.workspace_id}/chat/dm"
|
|
try:
|
|
resp = await client.get(url, headers=headers)
|
|
if resp.status_code != 200:
|
|
return []
|
|
data = resp.json()
|
|
dms = data.get("channels", [])
|
|
except Exception:
|
|
logger.exception("clickup polling: failed to list dms")
|
|
return []
|
|
|
|
results: list[dict] = []
|
|
for dm in dms:
|
|
dm_id = dm.get("id", "")
|
|
if not dm_id:
|
|
continue
|
|
try:
|
|
messages = await self._fetch_dm_messages(account, client, headers, dm_id)
|
|
for msg in messages:
|
|
msg["chat_type"] = "direct"
|
|
results.extend(messages)
|
|
except Exception:
|
|
logger.exception("clickup polling: failed to fetch messages for dm %s", dm_id)
|
|
continue
|
|
|
|
return results
|
|
|
|
async def _list_channels(
|
|
self,
|
|
account: ClickUpAccount,
|
|
client: httpx.AsyncClient,
|
|
headers: dict,
|
|
) -> list[dict]:
|
|
url = f"{API_V3_BASE}/workspaces/{account.workspace_id}/chat/channels"
|
|
resp = await client.get(url, headers=headers)
|
|
if resp.status_code != 200:
|
|
logger.warning("clickup polling: list channels failed status=%d", resp.status_code)
|
|
return []
|
|
data = resp.json()
|
|
return data.get("channels", [])
|
|
|
|
async def _fetch_channel_messages(
|
|
self,
|
|
account: ClickUpAccount,
|
|
client: httpx.AsyncClient,
|
|
headers: dict,
|
|
channel_id: str,
|
|
) -> list[dict]:
|
|
url = f"{API_V3_BASE}/workspaces/{account.workspace_id}/chat/channels/{channel_id}/messages"
|
|
return await self._fetch_messages_with_cursor(account, client, headers, url, channel_id, "group")
|
|
|
|
async def _fetch_dm_messages(
|
|
self,
|
|
account: ClickUpAccount,
|
|
client: httpx.AsyncClient,
|
|
headers: dict,
|
|
dm_id: str,
|
|
) -> list[dict]:
|
|
url = f"{API_V3_BASE}/workspaces/{account.workspace_id}/chat/dm/{dm_id}/messages"
|
|
return await self._fetch_messages_with_cursor(account, client, headers, url, dm_id, "direct")
|
|
|
|
async def _fetch_messages_with_cursor(
|
|
self,
|
|
account: ClickUpAccount,
|
|
client: httpx.AsyncClient,
|
|
headers: dict,
|
|
url: str,
|
|
chat_id: str,
|
|
chat_type: str,
|
|
) -> list[dict]:
|
|
results: list[dict] = []
|
|
cursor: str | None = None
|
|
seen_ids: set = set()
|
|
|
|
while True:
|
|
params: dict = {"limit": 50}
|
|
if cursor:
|
|
params["cursor"] = cursor
|
|
|
|
resp = await client.get(url, params=params, headers=headers)
|
|
if resp.status_code != 200:
|
|
break
|
|
|
|
data = resp.json()
|
|
raw_messages = data.get("messages", [])
|
|
hit_old = False
|
|
|
|
for msg in raw_messages:
|
|
msg_id = msg.get("id", "")
|
|
if not msg_id or msg_id in seen_ids:
|
|
continue
|
|
seen_ids.add(msg_id)
|
|
if self._deduplicator.is_duplicate(msg_id):
|
|
continue
|
|
|
|
created_ts = msg.get("date_created", "")
|
|
if created_ts:
|
|
try:
|
|
created_at = float(created_ts) / 1000.0 if len(str(created_ts)) > 10 else float(created_ts)
|
|
except (ValueError, TypeError):
|
|
created_at = 0.0
|
|
if created_at < self._last_polled_at - 5:
|
|
hit_old = True
|
|
break
|
|
|
|
user = msg.get("user", {})
|
|
unified = {
|
|
"channel": "clickup",
|
|
"msg_id": msg_id,
|
|
"chat_id": chat_id,
|
|
"chat_type": chat_type,
|
|
"sender": {
|
|
"id": str(user.get("id", "")),
|
|
"name": user.get("name", ""),
|
|
},
|
|
"text": msg.get("content", ""),
|
|
"content_format": msg.get("content_format", "text/md"),
|
|
"reply_to_id": msg.get("reply_to_id"),
|
|
"timestamp": created_ts if isinstance(created_ts, str) else None,
|
|
"raw": msg,
|
|
}
|
|
results.append(unified)
|
|
|
|
if hit_old:
|
|
break
|
|
|
|
cursor = data.get("cursor")
|
|
if not cursor:
|
|
break
|
|
|
|
return results
|