该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能: 1. 基础的@提及提取与格式化能力 2. 账号配对与会话管理 3. 消息流与流式回复支持 4. 重试限流与消息去重 5. 富文本格式转换与内容 sanitize 6. 安全策略与配置校验 7. Webhook接收与自动轮询回退 8. 消息收发、编辑、删除与回复 9. 频道与私信管理、反应功能 10. 完整的状态监控与健康检查
179 lines
6.9 KiB
Python
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
|