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

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