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

179 lines
6.9 KiB
Python

import logging
import httpx
from yuxi.channel.extensions.clickup.format import sanitize_for_clickup
from yuxi.channel.extensions.clickup.types import OutboundResult
logger = logging.getLogger(__name__)
API_V3_BASE = "https://api.clickup.com/api/v3"
class ClickUpOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit: int = 40000
def __init__(self, config_adapter=None):
self._config_adapter = config_adapter
@staticmethod
def _headers(api_token: str) -> dict:
return {
"Authorization": api_token,
"Content-Type": "application/json",
}
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> OutboundResult:
account = await self._resolve_account(account_id)
if not account:
logger.error("ClickUp send_text: account not resolved")
return OutboundResult(success=False, error="account_not_resolved")
api_token = account.get("api_token", "")
workspace_id = account.get("workspace_id", "")
if not api_token or not workspace_id:
logger.error("ClickUp send_text: account not configured")
return OutboundResult(success=False, error="account_not_configured")
formatted = sanitize_for_clickup(content)
if thread_id:
from yuxi.channel.extensions.clickup.threading import send_reply
return await send_reply(workspace_id, thread_id, formatted, api_token)
url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels/{target_id}/messages"
payload: dict = {
"type": "message",
"content": formatted,
"content_format": "text/md",
}
if reply_to_id and not thread_id:
payload["reply_to"] = reply_to_id
async with httpx.AsyncClient(timeout=30.0) as client:
try:
resp = await client.post(url, json=payload, headers=self._headers(api_token))
if resp.status_code == 201:
data = resp.json()
msg_id = data.get("id", "")
logger.debug("ClickUp send_text OK: msg_id=%s", msg_id)
return OutboundResult(success=True, message_id=msg_id)
else:
error_text = resp.text[:500]
logger.error("ClickUp send_text failed: status=%d body=%s", resp.status_code, error_text)
return OutboundResult(success=False, error=f"http_{resp.status_code}", detail=error_text)
except Exception as e:
logger.exception("ClickUp send_text exception: %s", e)
return OutboundResult(success=False, error="exception", detail=str(e))
async def edit_message(
self,
message_id: str,
content: str,
account_id: str | None = None,
) -> OutboundResult:
account = await self._resolve_account(account_id)
if not account:
return OutboundResult(success=False, error="account_not_resolved")
api_token = account.get("api_token", "")
workspace_id = account.get("workspace_id", "")
if not api_token or not workspace_id:
return OutboundResult(success=False, error="account_not_configured")
url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}"
formatted = sanitize_for_clickup(content)
payload = {
"content": formatted,
"content_format": "text/md",
}
async with httpx.AsyncClient(timeout=15.0) as client:
try:
resp = await client.patch(url, json=payload, headers=self._headers(api_token))
if resp.status_code == 200:
return OutboundResult(success=True, message_id=message_id)
else:
error_text = resp.text[:500]
logger.error("ClickUp edit_message failed: status=%d body=%s", resp.status_code, error_text)
return OutboundResult(success=False, error=f"http_{resp.status_code}", detail=error_text)
except Exception as e:
logger.exception("ClickUp edit_message exception: %s", e)
return OutboundResult(success=False, error="exception", detail=str(e))
async def delete_message(
self,
message_id: str,
account_id: str | None = None,
) -> OutboundResult:
account = await self._resolve_account(account_id)
if not account:
return OutboundResult(success=False, error="account_not_resolved")
api_token = account.get("api_token", "")
workspace_id = account.get("workspace_id", "")
if not api_token or not workspace_id:
return OutboundResult(success=False, error="account_not_configured")
url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}"
async with httpx.AsyncClient(timeout=15.0) as client:
try:
resp = await client.delete(url, headers=self._headers(api_token))
if resp.status_code == 204:
return OutboundResult(success=True, message_id=message_id)
else:
error_text = resp.text[:500]
logger.error("ClickUp delete_message failed: status=%d", resp.status_code)
return OutboundResult(success=False, error=f"http_{resp.status_code}", detail=error_text)
except Exception as e:
logger.exception("ClickUp delete_message exception: %s", e)
return OutboundResult(success=False, error="exception", detail=str(e))
async def _resolve_account(self, account_id: str | None) -> dict | None:
if self._config_adapter is None:
from yuxi.channel.extensions.clickup.config import ClickUpConfigAdapter
self._config_adapter = ClickUpConfigAdapter()
aid = account_id or self._config_adapter.default_account_id({})
return await self._config_adapter.resolve_account(aid)
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
while len(text) > limit:
chunks.append(text[:limit])
text = text[limit:]
if text:
chunks.append(text)
return chunks
def sanitize_text(self, text: str, payload: object) -> str:
return sanitize_for_clickup(text)
def resolve_target(
self,
to: str | None = None,
*,
config: dict | None = None,
allow_from: list[str] | None = None,
account_id: str | None = None,
mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "target required"
return True, to