From a79fc8dd7b7187d2e1b9577534fa893ecc1e32a1 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 21 May 2026 10:43:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(clickup):=20=E6=96=B0=E5=A2=9EClickUp?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=B8=A0=E9=81=93=E6=8F=92=E4=BB=B6=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能: 1. 基础的@提及提取与格式化能力 2. 账号配对与会话管理 3. 消息流与流式回复支持 4. 重试限流与消息去重 5. 富文本格式转换与内容 sanitize 6. 安全策略与配置校验 7. Webhook接收与自动轮询回退 8. 消息收发、编辑、删除与回复 9. 频道与私信管理、反应功能 10. 完整的状态监控与健康检查 --- .../channel/extensions/clickup/__init__.py | 374 ++++++ .../channel/extensions/clickup/agent_tools.py | 1086 +++++++++++++++++ .../yuxi/channel/extensions/clickup/config.py | 159 +++ .../yuxi/channel/extensions/clickup/dedupe.py | 38 + .../yuxi/channel/extensions/clickup/format.py | 52 + .../channel/extensions/clickup/gateway.py | 177 +++ .../channel/extensions/clickup/mentions.py | 14 + .../extensions/clickup/message_actions.py | 217 ++++ .../channel/extensions/clickup/outbound.py | 178 +++ .../channel/extensions/clickup/pairing.py | 21 + .../channel/extensions/clickup/plugin.json | 37 + .../channel/extensions/clickup/polling.py | 255 ++++ .../channel/extensions/clickup/ratelimit.py | 33 + .../channel/extensions/clickup/reactions.py | 70 ++ .../channel/extensions/clickup/security.py | 46 + .../yuxi/channel/extensions/clickup/status.py | 90 ++ .../channel/extensions/clickup/streaming.py | 21 + .../channel/extensions/clickup/threading.py | 65 + .../yuxi/channel/extensions/clickup/types.py | 75 ++ .../channel/extensions/clickup/webhook.py | 71 ++ .../extensions/clickup/webhook_mgmt.py | 74 ++ 21 files changed, 3153 insertions(+) create mode 100644 backend/package/yuxi/channel/extensions/clickup/__init__.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/agent_tools.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/config.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/dedupe.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/format.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/gateway.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/mentions.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/message_actions.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/outbound.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/pairing.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/plugin.json create mode 100644 backend/package/yuxi/channel/extensions/clickup/polling.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/ratelimit.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/reactions.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/security.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/status.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/streaming.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/threading.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/types.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/webhook.py create mode 100644 backend/package/yuxi/channel/extensions/clickup/webhook_mgmt.py diff --git a/backend/package/yuxi/channel/extensions/clickup/__init__.py b/backend/package/yuxi/channel/extensions/clickup/__init__.py new file mode 100644 index 00000000..f1acf475 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/__init__.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.extensions.base import BaseChannelPlugin +from yuxi.channel.extensions.clickup.agent_tools import build_clickup_agent_tools, execute_clickup_tool +from yuxi.channel.extensions.clickup.config import ClickUpConfigAdapter +from yuxi.channel.extensions.clickup.gateway import ClickUpGateway +from yuxi.channel.extensions.clickup.outbound import ClickUpOutbound +from yuxi.channel.extensions.clickup.pairing import ClickUpPairing +from yuxi.channel.extensions.clickup.reactions import add_reaction, get_reactions, remove_reaction +from yuxi.channel.extensions.clickup.security import ClickUpSecurity +from yuxi.channel.extensions.clickup.status import ClickUpStatus +from yuxi.channel.extensions.clickup.streaming import ClickUpStreaming +from yuxi.channel.plugins.registry import ChannelPluginRegistry +from yuxi.channel.protocols import ChannelAccountSnapshot + +logger = logging.getLogger(__name__) + + +class ClickUpPlugin(BaseChannelPlugin): + id = "clickup" + name = "ClickUp Chat" + order = 91 + label = "ClickUp Chat" + aliases = ["clickup-chat", "cu"] + resolve_reply_to_mode = "off" + + def __init__(self): + self.config_adapter = ClickUpConfigAdapter() + self.gateway = ClickUpGateway() + self.outbound = ClickUpOutbound(self.config_adapter) + self.security = ClickUpSecurity() + self.pairing = ClickUpPairing() + self.status_checker = ClickUpStatus() + self.streaming = ClickUpStreaming() + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=["direct", "group"], + message_types=["text"], + interactions=[], + reactions=True, + typing_indicator=False, + threads=True, + edit=True, + unsend=True, + reply=True, + media=False, + effects=False, + native_commands=False, + polls=False, + group_management=False, + streaming=True, + streaming_mode="edit_simulate", + block_streaming=True, + block_streaming_chunk_min_chars=200, + block_streaming_chunk_max_chars=1500, + block_streaming_chunk_break_preference="paragraph", + block_streaming_coalesce_min_chars=100, + block_streaming_coalesce_max_chars=600, + block_streaming_coalesce_idle_ms=500, + sse_support=False, + sse_max_reasoning_chars=0, + polling_fallback=True, + tts=None, + ) + + # ── Config 委托 ────────────────────────────────────── + + def list_account_ids(self, config: dict) -> list[str]: + return self.config_adapter.list_account_ids(config) + + async def resolve_account(self, account_id: str) -> dict: + return await self.config_adapter.resolve_account(account_id) + + def is_configured(self, account: dict) -> bool: + return self.config_adapter.is_configured(account) + + def is_enabled(self, account: dict, config: dict) -> bool: + return self.config_adapter.is_enabled(account) + + def describe_account(self, account: dict, config: dict) -> dict: + return self.config_adapter.describe_account(account) + + def config_schema(self) -> dict: + return self.config_adapter.config_schema() + + def default_account_id(self, config: dict) -> str: + return self.config_adapter.default_account_id(config) + + def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str | int] | None: + return self.config_adapter.resolve_allow_from(config, account_id) + + # ── Gateway 委托 ───────────────────────────────────── + + async def start(self, ctx) -> object: + return await self.gateway.start(ctx) + + async def stop(self, ctx) -> None: + await self.gateway.stop(ctx) + + # ── Outbound 委托 ──────────────────────────────────── + + @property + def delivery_mode(self): + return self.outbound.delivery_mode + + @property + def text_chunk_limit(self) -> int | None: + return self.outbound.text_chunk_limit + + @property + def chunker_mode(self) -> str | None: + return self.outbound.chunker_mode + + def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: + return self.outbound.chunker(text, limit, ctx) + + def sanitize_text(self, text: str, payload: object) -> str: + return self.outbound.sanitize_text(text, payload) + + 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, + ) -> None: + await self.outbound.send_text( + target_id, content, reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id + ) + + # ── Security 委托 ──────────────────────────────────── + + def resolve_dm_policy(self) -> dict: + return self.security.resolve_dm_policy() + + def resolve_dm_policy_for_account(self, account: dict) -> dict: + return self.security.resolve_dm_policy_for_account(account) + + async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: + return await self.security.check_allowlist(peer_id, channel_type) + + def resolve_require_mention(self, ctx) -> bool | None: + return self.security.resolve_require_mention(ctx) + + def resolve_group_intro_hint(self, ctx) -> str | None: + return self.security.resolve_group_intro_hint(ctx) + + def resolve_tool_policy(self, ctx) -> dict | None: + return self.security.resolve_tool_policy(ctx) + + def apply_config_fixes(self, config: dict) -> dict: + return self.security.apply_config_fixes(config) + + def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]: + return self.security.collect_warnings(config, account_id, account) + + # ── Pairing 委托 ───────────────────────────────────── + + @property + def id_label(self) -> str: + return self.pairing.id_label + + async def generate_code(self, peer_id: str) -> str: + return await self.pairing.generate_code(peer_id) + + async def verify_code(self, peer_id: str, code: str) -> bool: + return await self.pairing.verify_code(peer_id, code) + + def normalize_allow_entry(self, entry: str) -> str: + return self.pairing.normalize_allow_entry(entry) + + async def notify_approval(self, config: dict, peer_id: str, account_id: str | None = None) -> None: + await self.pairing.notify_approval(config, peer_id, account_id) + + # ── Status 委托 ────────────────────────────────────── + + @property + def default_runtime(self): + return self.status_checker.default_runtime + + async def probe(self, account: dict | None = None) -> bool: + return await self.status_checker.probe(account) + + def build_summary(self, snapshot: object) -> dict: + return self.status_checker.build_summary(snapshot) + + def build_account_snapshot( + self, + account: dict, + config: dict, + runtime: ChannelAccountSnapshot | None = None, + probe_result: object | None = None, + audit: object | None = None, + ) -> ChannelAccountSnapshot: + return self.status_checker.build_account_snapshot(account, config, runtime, probe_result, audit) + + def collect_status_issues(self, accounts: list[ChannelAccountSnapshot]) -> list: + return self.status_checker.collect_status_issues(accounts) + + # ── Streaming 委托 ─────────────────────────────────── + + @property + def streaming_mode(self) -> str: + return self.streaming.streaming_mode + + @property + def preview_stream_throttle_ms(self) -> int: + return self.streaming.preview_stream_throttle_ms + + @property + def preview_min_initial_chars(self) -> int: + return self.streaming.preview_min_initial_chars + + @property + def block_streaming_enabled(self) -> bool: + return self.streaming.block_streaming_enabled + + @property + def block_streaming_break(self) -> str: + return self.streaming.block_streaming_break + + @property + def block_streaming_chunk_min_chars(self) -> int: + return self.streaming.block_streaming_chunk_min_chars + + @property + def block_streaming_chunk_max_chars(self) -> int: + return self.streaming.block_streaming_chunk_max_chars + + @property + def block_streaming_chunk_break_preference(self) -> str: + return self.streaming.block_streaming_chunk_break_preference + + @property + def block_streaming_coalesce_defaults(self) -> dict | None: + return self.streaming.block_streaming_coalesce_defaults + + def create_draft_stream_session(self, target_id: str) -> object: + return self.streaming.create_draft_stream_session(target_id) + + def create_block_chunker(self) -> object: + return self.streaming.create_block_chunker() + + # ── Agent Prompt 委托 ───────────────────────────────── + + def build_system_prompt(self, context) -> str | None: + peer_name = getattr(context, "peer_name", "") or "" + group_name = getattr(context, "group_name", "") or "" + lines = [ + "You are interacting on ClickUp Chat, a project management chat platform.", + "ClickUp Chat supports full Markdown: **bold**, *italic*, `code`, ```code blocks```,", + "~~strikethrough~~, [links](url), > blockquotes, - bullet lists, 1. numbered lists.", + "Messages are limited to 40000 characters per message.", + "You can reference ClickUp tasks using #task_id or [[task_id]] notation.", + "Emoji reactions are supported on messages.", + ] + if peer_name: + lines.append(f"You are talking to: {peer_name} (DM)") + if group_name: + lines.append(f"Current channel: {group_name}") + return "\n".join(lines) + + def build_context_note(self, context) -> str: + group_name = getattr(context, "group_name", "") or "" + peer_name = getattr(context, "peer_name", "") or "" + if group_name and peer_name: + return f"[ClickUp | {group_name} | {peer_name}]" + if group_name: + return f"[ClickUp | {group_name}]" + if peer_name: + return f"[ClickUp | {peer_name}]" + return "[ClickUp]" + + @property + def channel_format_instructions(self) -> str | None: + return ( + "ClickUp Chat supports full Markdown: **bold**, *italic*, `code`, ```code blocks```, " + "~~strikethrough~~, [links](url), > blockquotes, - bullet lists, 1. numbered lists. " + "Messages are limited to 40000 characters. " + "Use Unicode emoji directly (😀 ✅ ❌), NOT :emoji: syntax." + ) + + # ── Mentions 委托 ──────────────────────────────────── + + def extract_mentions(self, raw_message: dict) -> list[str]: + mentioned_ids = raw_message.get("mentioned_ids", []) + return mentioned_ids if isinstance(mentioned_ids, list) else [] + + # ── Dedupe 委托 ────────────────────────────────────── + + def is_duplicate(self, key: str) -> bool: + return self.gateway.is_duplicate(key) + + def mark_seen(self, key: str) -> None: + self.gateway.mark_seen(key) + + # ── AgentTool 委托 ─────────────────────────────────── + + async def resolve_account_for_tools(self, account_id: str | None = None) -> dict | None: + aid = account_id or self.config_adapter.default_account_id({}) + return await self.config_adapter.resolve_account(aid) + + def get_agent_tools(self) -> list[dict]: + return build_clickup_agent_tools({}) + + async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict: + account = await self.resolve_account_for_tools(context.get("account_id")) + if not account: + return {"success": False, "error": "ClickUp 账户未配置"} + return await execute_clickup_tool(tool_name, params, account) + + # ── Reaction 委托 ──────────────────────────────────── + + async def send_reaction( + self, + target_id: str, + message_id: str, + emoji: str, + *, + account_id: str | None = None, + ) -> None: + account = await self.resolve_account_for_tools(account_id) + if not account: + return + await add_reaction( + account.get("workspace_id", ""), + message_id, + emoji, + account.get("api_token", ""), + ) + + async def remove_reaction( + self, + target_id: str, + message_id: str, + emoji: str, + *, + account_id: str | None = None, + ) -> None: + account = await self.resolve_account_for_tools(account_id) + if not account: + return + await remove_reaction( + account.get("workspace_id", ""), + message_id, + emoji, + account.get("api_token", ""), + ) + + async def fetch_reactions( + self, + target_id: str, + message_id: str, + *, + account_id: str | None = None, + ) -> list[dict]: + account = await self.resolve_account_for_tools(account_id) + if not account: + return [] + return await get_reactions( + account.get("workspace_id", ""), + message_id, + account.get("api_token", ""), + ) + + +ChannelPluginRegistry.register(ClickUpPlugin()) diff --git a/backend/package/yuxi/channel/extensions/clickup/agent_tools.py b/backend/package/yuxi/channel/extensions/clickup/agent_tools.py new file mode 100644 index 00000000..dfdf6e2f --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/agent_tools.py @@ -0,0 +1,1086 @@ +import logging + +import httpx + +logger = logging.getLogger(__name__) + +API_V2_BASE = "https://api.clickup.com/api/v2" + + +def build_clickup_agent_tools(account: dict) -> list[dict]: + _ = account.get("workspace_id", "") + return [ + { + "name": "clickup_create_task", + "description": "在 ClickUp 列表中创建新任务。需要 list_id、任务名称和可选的描述、优先级、负责人等信息。", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "ClickUp 列表 ID(数字字符串)。可通过 clickup_get_lists 工具获取。", + }, + "name": { + "type": "string", + "description": "任务名称/标题", + }, + "description": { + "type": "string", + "description": "任务描述(支持 Markdown),可选", + }, + "priority": { + "type": "integer", + "description": "任务优先级:1=紧急, 2=高, 3=正常, 4=低。默认 3(正常)", + "default": 3, + }, + "assignees": { + "type": "array", + "items": {"type": "string"}, + "description": "负责人用户 ID 列表,可选", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "标签名称列表,可选", + }, + "due_date": { + "type": "string", + "description": "截止日期时间(Unix 毫秒时间戳字符串),可选", + }, + }, + "required": ["list_id", "name"], + }, + }, + { + "name": "clickup_get_task", + "description": "获取 ClickUp 任务的详细信息,包括状态、负责人、描述、评论等。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID(如 #task_id 或 9 位数字字符串)", + }, + }, + "required": ["task_id"], + }, + }, + { + "name": "clickup_update_task", + "description": "更新 ClickUp 任务的信息,如名称、描述、状态、优先级、负责人等。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "name": { + "type": "string", + "description": "新的任务名称,可选", + }, + "description": { + "type": "string", + "description": "新的任务描述(支持 Markdown),可选", + }, + "status": { + "type": "string", + "description": "新的任务状态名称,可选", + }, + "priority": { + "type": "integer", + "description": "新的优先级:1=紧急, 2=高, 3=正常, 4=低,可选", + }, + "assignees": { + "type": "object", + "properties": { + "add": { + "type": "array", + "items": {"type": "string"}, + "description": "添加的负责人 ID 列表", + }, + "rem": { + "type": "array", + "items": {"type": "string"}, + "description": "移除的负责人 ID 列表", + }, + }, + "description": "负责人变更,可选", + }, + }, + "required": ["task_id"], + }, + }, + { + "name": "clickup_list_tasks", + "description": "获取 ClickUp 列表中的任务列表,支持筛选状态和分页。", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "ClickUp 列表 ID", + }, + "status": { + "type": "string", + "description": "按状态筛选(如 'open', 'in progress', 'closed'),可选", + }, + "page": { + "type": "integer", + "description": "分页页码(从 0 开始)", + "default": 0, + }, + "limit": { + "type": "integer", + "description": "每页任务数(最大 100)", + "default": 25, + }, + }, + "required": ["list_id"], + }, + }, + { + "name": "clickup_search_tasks", + "description": "在 ClickUp Workspace 中搜索任务。可通过关键词、状态、负责人等条件搜索。", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词", + }, + "status": { + "type": "string", + "description": "按状态筛选,可选", + }, + "assignee": { + "type": "string", + "description": "按负责人 ID 筛选,可选", + }, + "list_id": { + "type": "string", + "description": "限定搜索的列表 ID,可选", + }, + }, + "required": ["query"], + }, + }, + { + "name": "clickup_add_task_comment", + "description": "为 ClickUp 任务添加评论。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "content": { + "type": "string", + "description": "评论内容(支持 Markdown)", + }, + }, + "required": ["task_id", "content"], + }, + }, + { + "name": "clickup_get_task_comments", + "description": "获取 ClickUp 任务的评论列表。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + }, + "required": ["task_id"], + }, + }, + { + "name": "clickup_get_lists", + "description": "获取 ClickUp Workspace 中的列表清单,用于确定任务操作的 list_id。", + "parameters": { + "type": "object", + "properties": { + "space_id": { + "type": "string", + "description": "限定 Space ID 筛选,可选。不提供则获取整个 Workspace 的列表。", + }, + }, + "required": [], + }, + }, + { + "name": "clickup_delete_task", + "description": "删除 ClickUp 任务。删除后无法恢复,请谨慎操作。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID(如 #task_id 或 9 位数字字符串)", + }, + }, + "required": ["task_id"], + }, + }, + { + "name": "clickup_get_spaces", + "description": "获取 ClickUp Workspace 中所有 Space 的列表,用于了解项目结构并定位 space_id。", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + { + "name": "clickup_create_list", + "description": "在 ClickUp Space 或 Folder 中创建新列表。需要 space_id,可选 folder_id。", + "parameters": { + "type": "object", + "properties": { + "space_id": { + "type": "string", + "description": "Space ID(数字字符串),通过 clickup_get_spaces 获取", + }, + "name": { + "type": "string", + "description": "列表名称", + }, + "folder_id": { + "type": "string", + "description": "Folder ID,可选。不提供则创建在 Space 根目录下", + }, + }, + "required": ["space_id", "name"], + }, + }, + { + "name": "clickup_update_list", + "description": "更新 ClickUp 列表的名称或其他属性。", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "列表 ID", + }, + "name": { + "type": "string", + "description": "新的列表名称,可选", + }, + }, + "required": ["list_id"], + }, + }, + { + "name": "clickup_get_custom_fields", + "description": "获取指定 ClickUp 列表的自定义字段定义,返回字段名称、类型、可选值等信息。", + "parameters": { + "type": "object", + "properties": { + "list_id": { + "type": "string", + "description": "ClickUp 列表 ID", + }, + }, + "required": ["list_id"], + }, + }, + { + "name": "clickup_set_custom_field", + "description": "设置 ClickUp 任务的自定义字段值。需要先通过 clickup_get_custom_fields 了解字段 ID 和类型。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "field_id": { + "type": "string", + "description": "自定义字段 ID(UUID 格式),通过 clickup_get_custom_fields 获取", + }, + "value": { + "description": "字段值,类型取决于字段定义(文本传字符串,下拉传选项名,数字传数字等)", + }, + }, + "required": ["task_id", "field_id", "value"], + }, + }, + { + "name": "clickup_get_members", + "description": "获取 ClickUp Workspace 中的成员列表,用于查找用户 ID、分配任务负责人等。", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, + }, + { + "name": "clickup_move_task", + "description": "将 ClickUp 任务移动到新列表。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "target_list_id": { + "type": "string", + "description": "目标列表 ID", + }, + }, + "required": ["task_id", "target_list_id"], + }, + }, + { + "name": "clickup_add_task_tag", + "description": "为 ClickUp 任务添加标签。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "tag_name": { + "type": "string", + "description": "标签名称", + }, + }, + "required": ["task_id", "tag_name"], + }, + }, + { + "name": "clickup_remove_task_tag", + "description": "移除 ClickUp 任务的标签。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "tag_name": { + "type": "string", + "description": "标签名称", + }, + }, + "required": ["task_id", "tag_name"], + }, + }, + { + "name": "clickup_create_checklist", + "description": "为 ClickUp 任务创建检查清单。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "ClickUp 任务 ID", + }, + "name": { + "type": "string", + "description": "检查清单名称", + }, + }, + "required": ["task_id", "name"], + }, + }, + { + "name": "clickup_add_checklist_item", + "description": "向 ClickUp 检查清单添加条目。", + "parameters": { + "type": "object", + "properties": { + "checklist_id": { + "type": "string", + "description": "检查清单 ID", + }, + "name": { + "type": "string", + "description": "条目名称", + }, + }, + "required": ["checklist_id", "name"], + }, + }, + { + "name": "clickup_add_task_dependency", + "description": "为 ClickUp 任务添加依赖关系。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "当前任务 ID", + }, + "depends_on": { + "type": "string", + "description": "依赖的任务 ID", + }, + }, + "required": ["task_id", "depends_on"], + }, + }, + { + "name": "clickup_delete_task_dependency", + "description": "删除 ClickUp 任务的依赖关系。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "当前任务 ID", + }, + "depends_on": { + "type": "string", + "description": "依赖的任务 ID", + }, + }, + "required": ["task_id", "depends_on"], + }, + }, + { + "name": "clickup_add_task_link", + "description": "关联两个 ClickUp 任务。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "当前任务 ID", + }, + "links_to": { + "type": "string", + "description": "要关联的目标任务 ID", + }, + }, + "required": ["task_id", "links_to"], + }, + }, + { + "name": "clickup_delete_task_link", + "description": "删除 ClickUp 任务之间的关联。", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "当前任务 ID", + }, + "links_to": { + "type": "string", + "description": "要取消关联的目标任务 ID", + }, + }, + "required": ["task_id", "links_to"], + }, + }, + { + "name": "clickup_update_comment", + "description": "更新 ClickUp 任务评论。", + "parameters": { + "type": "object", + "properties": { + "comment_id": { + "type": "string", + "description": "评论 ID", + }, + "content": { + "type": "string", + "description": "新的评论内容", + }, + }, + "required": ["comment_id", "content"], + }, + }, + { + "name": "clickup_delete_comment", + "description": "删除 ClickUp 任务评论。", + "parameters": { + "type": "object", + "properties": { + "comment_id": { + "type": "string", + "description": "评论 ID", + }, + }, + "required": ["comment_id"], + }, + }, + { + "name": "clickup_get_folders", + "description": "获取 ClickUp Space 中的文件夹列表。", + "parameters": { + "type": "object", + "properties": { + "space_id": { + "type": "string", + "description": "Space ID", + }, + }, + "required": ["space_id"], + }, + }, + ] + + +async def execute_clickup_tool( + tool_name: str, + arguments: dict, + account: dict, +) -> dict: + api_token = account.get("api_token", "") + workspace_id = account.get("workspace_id", "") + + if not api_token or not workspace_id: + return {"success": False, "error": "ClickUp 账户未配置(缺少 api_token 或 workspace_id)"} + + tool_map = { + "clickup_create_task": _create_task, + "clickup_get_task": _get_task, + "clickup_update_task": _update_task, + "clickup_list_tasks": _list_tasks, + "clickup_search_tasks": _search_tasks, + "clickup_add_task_comment": _add_task_comment, + "clickup_get_task_comments": _get_task_comments, + "clickup_get_lists": _get_lists, + "clickup_delete_task": _delete_task, + "clickup_get_spaces": _get_spaces, + "clickup_create_list": _create_list, + "clickup_update_list": _update_list, + "clickup_get_custom_fields": _get_custom_fields, + "clickup_set_custom_field": _set_custom_field, + "clickup_get_members": _get_members, + "clickup_move_task": _move_task, + "clickup_add_task_tag": _add_task_tag, + "clickup_remove_task_tag": _remove_task_tag, + "clickup_create_checklist": _create_checklist, + "clickup_add_checklist_item": _add_checklist_item, + "clickup_add_task_dependency": _add_task_dependency, + "clickup_delete_task_dependency": _delete_task_dependency, + "clickup_add_task_link": _add_task_link, + "clickup_delete_task_link": _delete_task_link, + "clickup_update_comment": _update_comment, + "clickup_delete_comment": _delete_comment, + "clickup_get_folders": _get_folders, + } + + handler = tool_map.get(tool_name) + if not handler: + return {"success": False, "error": f"未知工具: {tool_name}"} + + try: + result = await handler(arguments, api_token, workspace_id) + return {"success": True, "result": result} + except Exception as e: + logger.exception("clickup tool %s failed", tool_name) + return {"success": False, "error": str(e)} + + +async def _create_task(arguments: dict, api_token: str, workspace_id: str) -> dict: + list_id = arguments["list_id"] + url = f"{API_V2_BASE}/list/{list_id}/task" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload: dict = { + "name": arguments["name"], + } + + if "description" in arguments: + payload["description"] = arguments["description"] + if "priority" in arguments: + payload["priority"] = arguments["priority"] + if "assignees" in arguments and arguments["assignees"]: + payload["assignees"] = arguments["assignees"] + if "tags" in arguments and arguments["tags"]: + payload["tags"] = arguments["tags"] + if "due_date" in arguments: + payload["due_date"] = arguments["due_date"] + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + logger.error("clickup create_task failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"创建任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_task(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + url = f"{API_V2_BASE}/task/{task_id}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + return resp.json() + error_text = resp.text[:500] + logger.error("clickup get_task failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"获取任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _update_task(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + url = f"{API_V2_BASE}/task/{task_id}" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload: dict = {} + + if "name" in arguments: + payload["name"] = arguments["name"] + if "description" in arguments: + payload["description"] = arguments["description"] + if "status" in arguments: + payload["status"] = arguments["status"] + if "priority" in arguments: + payload["priority"] = arguments["priority"] + if "assignees" in arguments: + payload["assignees"] = arguments["assignees"] + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.put(url, json=payload, headers=headers) + if resp.status_code == 200: + return resp.json() + error_text = resp.text[:500] + logger.error("clickup update_task failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"更新任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _list_tasks(arguments: dict, api_token: str, workspace_id: str) -> dict: + list_id = arguments["list_id"] + url = f"{API_V2_BASE}/list/{list_id}/task" + + headers = {"Authorization": api_token} + params: dict = { + "page": arguments.get("page", 0), + } + + if "status" in arguments and arguments["status"]: + params["statuses"] = [arguments["status"]] + limit = min(arguments.get("limit", 25), 100) + params["limit"] = limit + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + data = resp.json() + tasks = data.get("tasks", []) + return { + "tasks": tasks, + "total": len(tasks), + "page": arguments.get("page", 0), + } + error_text = resp.text[:500] + logger.error("clickup list_tasks failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"获取任务列表失败 (HTTP {resp.status_code}): {error_text}") + + +async def _search_tasks(arguments: dict, api_token: str, workspace_id: str) -> dict: + query = arguments["query"] + url = f"{API_V2_BASE}/team/{workspace_id}/task" + + headers = {"Authorization": api_token} + params: dict = { + "page": 0, + "order_by": "updated", + "reverse": True, + } + + if "status" in arguments and arguments["status"]: + params["statuses"] = [arguments["status"]] + if "assignee" in arguments and arguments["assignee"]: + params["assignees"] = [arguments["assignee"]] + if "list_id" in arguments: + params["list_ids"] = [arguments["list_id"]] + if "query" in arguments: + params["query"] = arguments["query"] + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + data = resp.json() + tasks = data.get("tasks", []) + if query: + tasks = [t for t in tasks if query.lower() in t.get("name", "").lower()] + return { + "tasks": tasks, + "total": len(tasks), + } + error_text = resp.text[:500] + logger.error("clickup search_tasks failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"搜索任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _add_task_comment(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + url = f"{API_V2_BASE}/task/{task_id}/comment" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = { + "comment_text": arguments["content"], + } + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + logger.error("clickup add_task_comment failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"添加任务评论失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_task_comments(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + url = f"{API_V2_BASE}/task/{task_id}/comment" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return { + "comments": data.get("comments", []), + "total": len(data.get("comments", [])), + } + error_text = resp.text[:500] + logger.error("clickup get_task_comments failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"获取任务评论失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_lists(arguments: dict, api_token: str, workspace_id: str) -> dict: + space_id = arguments.get("space_id") + if space_id: + url = f"{API_V2_BASE}/space/{space_id}/list" + else: + url = f"{API_V2_BASE}/team/{workspace_id}/list" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return { + "lists": data.get("lists", []), + "total": len(data.get("lists", [])), + } + error_text = resp.text[:500] + logger.error("clickup get_lists failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"获取列表清单失败 (HTTP {resp.status_code}): {error_text}") + + +async def _delete_task(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + url = f"{API_V2_BASE}/task/{task_id}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.delete(url, headers=headers) + if resp.status_code in (200, 204): + return {"deleted": True, "task_id": task_id} + error_text = resp.text[:500] + logger.error("clickup delete_task failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"删除任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_spaces(arguments: dict, api_token: str, workspace_id: str) -> dict: + url = f"{API_V2_BASE}/team/{workspace_id}/space" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return { + "spaces": data.get("spaces", []), + "total": len(data.get("spaces", [])), + } + error_text = resp.text[:500] + logger.error("clickup get_spaces failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"获取 Space 列表失败 (HTTP {resp.status_code}): {error_text}") + + +async def _create_list(arguments: dict, api_token: str, workspace_id: str) -> dict: + space_id = arguments["space_id"] + folder_id = arguments.get("folder_id") + if folder_id: + url = f"{API_V2_BASE}/folder/{folder_id}/list" + else: + url = f"{API_V2_BASE}/space/{space_id}/list" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"name": arguments["name"]} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"创建列表失败 (HTTP {resp.status_code}): {error_text}") + + +async def _update_list(arguments: dict, api_token: str, workspace_id: str) -> dict: + list_id = arguments["list_id"] + url = f"{API_V2_BASE}/list/{list_id}" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload: dict = {} + if "name" in arguments: + payload["name"] = arguments["name"] + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.put(url, json=payload, headers=headers) + if resp.status_code == 200: + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"更新列表失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_custom_fields(arguments: dict, api_token: str, workspace_id: str) -> dict: + list_id = arguments["list_id"] + url = f"{API_V2_BASE}/list/{list_id}/field" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return { + "fields": data.get("fields", []), + "total": len(data.get("fields", [])), + } + error_text = resp.text[:500] + raise RuntimeError(f"获取自定义字段失败 (HTTP {resp.status_code}): {error_text}") + + +async def _set_custom_field(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + field_id = arguments["field_id"] + url = f"{API_V2_BASE}/task/{task_id}/field/{field_id}" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"value": arguments["value"]} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"设置自定义字段失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_members(arguments: dict, api_token: str, workspace_id: str) -> dict: + url = f"{API_V2_BASE}/team/{workspace_id}/user" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + users = data.get("users", []) + members = [ + {"id": u.get("id"), "username": u.get("username", ""), "email": u.get("email", "")} for u in users + ] + return {"members": members, "total": len(members)} + error_text = resp.text[:500] + raise RuntimeError(f"获取成员列表失败 (HTTP {resp.status_code}): {error_text}") + + +async def _move_task(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + target_list_id = arguments["target_list_id"] + url = f"{API_V2_BASE}/task/{task_id}/list/{target_list_id}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"移动任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _add_task_tag(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + tag_name = arguments["tag_name"] + url = f"{API_V2_BASE}/task/{task_id}/tag/{tag_name}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"添加标签失败 (HTTP {resp.status_code}): {error_text}") + + +async def _remove_task_tag(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + tag_name = arguments["tag_name"] + url = f"{API_V2_BASE}/task/{task_id}/tag/{tag_name}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.delete(url, headers=headers) + if resp.status_code in (200, 204): + return {"removed": True, "tag": tag_name} + error_text = resp.text[:500] + raise RuntimeError(f"移除标签失败 (HTTP {resp.status_code}): {error_text}") + + +async def _create_checklist(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + url = f"{API_V2_BASE}/task/{task_id}/checklist" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"name": arguments["name"]} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"创建检查清单失败 (HTTP {resp.status_code}): {error_text}") + + +async def _add_checklist_item(arguments: dict, api_token: str, workspace_id: str) -> dict: + checklist_id = arguments["checklist_id"] + url = f"{API_V2_BASE}/checklist/{checklist_id}/checklist_item" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"name": arguments["name"]} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"添加检查清单条目失败 (HTTP {resp.status_code}): {error_text}") + + +async def _add_task_dependency(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + depends_on = _clean_task_id(arguments["depends_on"]) + url = f"{API_V2_BASE}/task/{task_id}/dependency" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"depends_on": depends_on} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"添加依赖失败 (HTTP {resp.status_code}): {error_text}") + + +async def _delete_task_dependency(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + depends_on = _clean_task_id(arguments["depends_on"]) + url = f"{API_V2_BASE}/task/{task_id}/dependency" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"depends_on": depends_on} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.delete(url, json=payload, headers=headers) + if resp.status_code in (200, 204): + return {"deleted": True, "depends_on": depends_on} + error_text = resp.text[:500] + raise RuntimeError(f"删除依赖失败 (HTTP {resp.status_code}): {error_text}") + + +async def _add_task_link(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + links_to = _clean_task_id(arguments["links_to"]) + url = f"{API_V2_BASE}/task/{task_id}/link/{links_to}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"关联任务失败 (HTTP {resp.status_code}): {error_text}") + + +async def _delete_task_link(arguments: dict, api_token: str, workspace_id: str) -> dict: + task_id = _clean_task_id(arguments["task_id"]) + links_to = _clean_task_id(arguments["links_to"]) + url = f"{API_V2_BASE}/task/{task_id}/link/{links_to}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.delete(url, headers=headers) + if resp.status_code in (200, 204): + return {"deleted": True, "links_to": links_to} + error_text = resp.text[:500] + raise RuntimeError(f"删除关联失败 (HTTP {resp.status_code}): {error_text}") + + +async def _update_comment(arguments: dict, api_token: str, workspace_id: str) -> dict: + comment_id = arguments["comment_id"] + url = f"{API_V2_BASE}/comment/{comment_id}" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"comment_text": arguments["content"]} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.put(url, json=payload, headers=headers) + if resp.status_code == 200: + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"更新评论失败 (HTTP {resp.status_code}): {error_text}") + + +async def _delete_comment(arguments: dict, api_token: str, workspace_id: str) -> dict: + comment_id = arguments["comment_id"] + url = f"{API_V2_BASE}/comment/{comment_id}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.delete(url, headers=headers) + if resp.status_code in (200, 204): + return {"deleted": True, "comment_id": comment_id} + error_text = resp.text[:500] + raise RuntimeError(f"删除评论失败 (HTTP {resp.status_code}): {error_text}") + + +async def _get_folders(arguments: dict, api_token: str, workspace_id: str) -> dict: + space_id = arguments["space_id"] + url = f"{API_V2_BASE}/space/{space_id}/folder" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return { + "folders": data.get("folders", []), + "total": len(data.get("folders", [])), + } + error_text = resp.text[:500] + raise RuntimeError(f"获取文件夹列表失败 (HTTP {resp.status_code}): {error_text}") + + +def _clean_task_id(task_id: str) -> str: + return task_id.lstrip("#") diff --git a/backend/package/yuxi/channel/extensions/clickup/config.py b/backend/package/yuxi/channel/extensions/clickup/config.py new file mode 100644 index 00000000..7f1637a3 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/config.py @@ -0,0 +1,159 @@ +import logging +import os + +from yuxi.channel.extensions.clickup.types import ClickUpAccount + +logger = logging.getLogger(__name__) + +ENV_MAP = { + "api_token": "CLICKUP_API_TOKEN", + "workspace_id": "CLICKUP_WORKSPACE_ID", + "webhook_secret": "CLICKUP_WEBHOOK_SECRET", + "auth_mode": "CLICKUP_AUTH_MODE", + "oauth_client_id": "CLICKUP_OAUTH_CLIENT_ID", + "oauth_client_secret": "CLICKUP_OAUTH_CLIENT_SECRET", + "dm_policy": "CLICKUP_DM_POLICY", + "allow_from": "CLICKUP_ALLOW_FROM", + "channel_policy": "CLICKUP_CHANNEL_POLICY", + "poll_fallback_enabled": "CLICKUP_POLL_FALLBACK_ENABLED", + "poll_interval": "CLICKUP_POLL_INTERVAL", +} + + +def _env_or_config(key: str) -> str | None: + env_key = ENV_MAP.get(key, "") + if env_key: + return os.getenv(env_key) + return None + + +class ClickUpConfigAdapter: + def list_account_ids(self, config: dict) -> list[str]: + accounts = config.get("accounts", {}) + if not accounts: + return ["default"] if self._env_token_exists("default") else [] + return list(accounts.keys()) + + async def resolve_account(self, account_id: str) -> dict: + account = self._build_account(account_id, {}) + return { + "account_id": account.account_id, + "api_token": account.api_token, + "workspace_id": account.workspace_id, + "webhook_secret": account.webhook_secret, + "auth_mode": account.auth_mode, + "oauth_client_id": account.oauth_client_id, + "oauth_client_secret": account.oauth_client_secret, + "name": account.name, + "dm_policy": account.dm_policy, + "allow_from": account.allow_from, + "channel_policy": account.channel_policy, + "channel_allowlist": account.channel_allowlist, + "poll_fallback_enabled": account.poll_fallback_enabled, + "poll_interval": account.poll_interval, + "enabled": True, + } + + def is_configured(self, account: dict) -> bool: + return bool(account.get("api_token") and account.get("workspace_id")) + + def is_enabled(self, account: dict) -> bool: + return account.get("enabled", True) + + def describe_account(self, account: dict) -> dict: + return { + "account_id": account.get("account_id", ""), + "workspace_id": account.get("workspace_id", ""), + "dm_policy": account.get("dm_policy", "open"), + "channel_policy": account.get("channel_policy", "activate_on_mention"), + "configured": self.is_configured(account), + } + + def default_account_id(self, config: dict) -> str: + return config.get("default_account", "default") + + def resolve_allow_from(self, config: dict, account_id: str | None = None) -> list[str]: + aid = account_id or self.default_account_id(config) + accounts = config.get("accounts", {}) + account = accounts.get(aid, {}) + return account.get("allow_from", []) + + def config_schema(self) -> dict: + return { + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "title": "ClickUp Chat 渠道配置", + "properties": { + "api_token": { + "type": "string", + "title": "Personal API Token", + "description": "ClickUp Personal API Token(pk_ 前缀)。在 ClickUp → Settings → Apps 中生成。", + "x-ui-password": True, + }, + "workspace_id": { + "type": "string", + "title": "Workspace ID", + "description": "ClickUp Workspace/Team ID(数字)。可通过 GET /api/v2/team 获取。", + }, + "webhook_secret": { + "type": "string", + "title": "Webhook Secret", + "description": "用于验证 Automation Webhook 的自定义密钥(可选但强烈推荐)。", + "x-ui-password": True, + }, + "dm_policy": { + "type": "string", + "title": "DM 安全策略", + "enum": ["open", "pairing", "allowlist", "disabled"], + "default": "open", + }, + "allow_from": { + "type": "string", + "title": "DM 白名单", + "description": "逗号分隔的 ClickUp user_id 列表。仅 dm_policy=allowlist 时生效。", + }, + "channel_policy": { + "type": "string", + "title": "Channel 响应策略", + "enum": ["always", "activate_on_mention", "disabled"], + "default": "activate_on_mention", + }, + "poll_fallback_enabled": { + "type": "boolean", + "title": "启用轮询回退", + "description": "Automation Webhook 不可用时,通过拉取消息列表作为回退方案(P2)。", + "default": False, + }, + }, + } + + def _build_account(self, account_id: str, raw: dict) -> ClickUpAccount: + allow_from_str = _env_or_config("allow_from") or raw.get("allow_from", "") + if isinstance(allow_from_str, list): + allow_from_list = allow_from_str + else: + allow_from_list = [u.strip() for u in allow_from_str.split(",") if u.strip()] + + poll_enabled_str = _env_or_config("poll_fallback_enabled") or str(raw.get("poll_fallback_enabled", False)) + + return ClickUpAccount( + account_id=account_id, + api_token=_env_or_config("api_token") or raw.get("api_token", ""), + workspace_id=_env_or_config("workspace_id") or raw.get("workspace_id", ""), + webhook_secret=_env_or_config("webhook_secret") or raw.get("webhook_secret", ""), + auth_mode=_env_or_config("auth_mode") or raw.get("auth_mode", "api_token"), + oauth_client_id=_env_or_config("oauth_client_id") or raw.get("oauth_client_id", ""), + oauth_client_secret=_env_or_config("oauth_client_secret") or raw.get("oauth_client_secret", ""), + name=raw.get("name", account_id), + dm_policy=_env_or_config("dm_policy") or raw.get("dm_policy", "open"), + allow_from=allow_from_list, + channel_policy=_env_or_config("channel_policy") or raw.get("channel_policy", "activate_on_mention"), + channel_allowlist=raw.get("channel_allowlist", []), + poll_fallback_enabled=poll_enabled_str.lower() == "true", + poll_interval=float(_env_or_config("poll_interval") or raw.get("poll_interval", "15.0")), + ) + + @staticmethod + def _env_token_exists(account_id: str) -> bool: + key = f"CLICKUP_API_TOKEN_{account_id.upper()}" + return bool(os.environ.get(key, "") or os.environ.get("CLICKUP_API_TOKEN", "")) diff --git a/backend/package/yuxi/channel/extensions/clickup/dedupe.py b/backend/package/yuxi/channel/extensions/clickup/dedupe.py new file mode 100644 index 00000000..e6348d9d --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/dedupe.py @@ -0,0 +1,38 @@ +import logging +import time + +logger = logging.getLogger(__name__) + + +class MessageDeduplicator: + def __init__(self, max_size: int = 10000, ttl_seconds: int = 300): + self._cache: dict[str, float] = {} + self._max_size = max_size + self._ttl_seconds = ttl_seconds + + def is_duplicate(self, message_id: str) -> bool: + if not message_id: + return False + + self._evict_expired() + + if message_id in self._cache: + return True + + self._cache[message_id] = time.monotonic() + self._trim_if_needed() + return False + + def _evict_expired(self): + now = time.monotonic() + expired = [mid for mid, ts in self._cache.items() if now - ts > self._ttl_seconds] + for mid in expired: + del self._cache[mid] + + def _trim_if_needed(self): + if len(self._cache) > self._max_size: + sorted_items = sorted(self._cache.items(), key=lambda x: x[1]) + to_remove = len(self._cache) - self._max_size + 500 + for mid, _ in sorted_items[:to_remove]: + del self._cache[mid] + logger.warning("ClickUp dedupe cache trimmed: removed %d entries", to_remove) diff --git a/backend/package/yuxi/channel/extensions/clickup/format.py b/backend/package/yuxi/channel/extensions/clickup/format.py new file mode 100644 index 00000000..e991c404 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/format.py @@ -0,0 +1,52 @@ +import re + +EMOJI_MAP = { + ":smile:": "😀", + ":laughing:": "😆", + ":blush:": "😊", + ":heart:": "❤️", + ":thumbsup:": "👍", + ":thumbsdown:": "👎", + ":clap:": "👏", + ":fire:": "🔥", + ":rocket:": "🚀", + ":check:": "✅", + ":x:": "❌", + ":warning:": "⚠️", + ":bulb:": "💡", + ":pushpin:": "📌", + ":link:": "🔗", + ":star:": "⭐", + ":tada:": "🎉", + ":thinking:": "🤔", + ":eyes:": "👀", + ":pray:": "🙏", + ":100:": "💯", +} + + +def convert_emoji_shortcodes(text: str) -> str: + result = text + for shortcode, unicode_char in EMOJI_MAP.items(): + result = result.replace(shortcode, unicode_char) + return result + + +def sanitize_for_clickup(text: str) -> str: + text = convert_emoji_shortcodes(text) + if len(text) > 40000: + text = text[:39997] + "..." + return text + + +def extract_plain_text(markdown_text: str) -> str: + text = markdown_text + text = re.sub(r"~~(.*?)~~", r"\1", text) + text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) + text = re.sub(r"\*(.*?)\*", r"\1", text) + text = re.sub(r"`{1,3}[^`]*`{1,3}", "", text) + text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text) + text = re.sub(r">\s?", "", text) + text = re.sub(r"[-*]\s", "", text) + text = re.sub(r"\d+\.\s", "", text) + return text.strip() diff --git a/backend/package/yuxi/channel/extensions/clickup/gateway.py b/backend/package/yuxi/channel/extensions/clickup/gateway.py new file mode 100644 index 00000000..b8f6c23c --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/gateway.py @@ -0,0 +1,177 @@ +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) diff --git a/backend/package/yuxi/channel/extensions/clickup/mentions.py b/backend/package/yuxi/channel/extensions/clickup/mentions.py new file mode 100644 index 00000000..ee185eff --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/mentions.py @@ -0,0 +1,14 @@ +import logging +import re + +logger = logging.getLogger(__name__) + +_MENTION_PATTERN = re.compile(r"@(\w+)") + + +def extract_mentions_from_text(text: str) -> list[str]: + return [m.group(1) for m in _MENTION_PATTERN.finditer(text)] + + +def build_mention_text(user_name: str, user_id: str | None = None) -> str: + return f"@{user_name}" diff --git a/backend/package/yuxi/channel/extensions/clickup/message_actions.py b/backend/package/yuxi/channel/extensions/clickup/message_actions.py new file mode 100644 index 00000000..06e89138 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/message_actions.py @@ -0,0 +1,217 @@ +import logging + +import httpx + +from yuxi.channel.extensions.clickup.config import ClickUpConfigAdapter +from yuxi.channel.extensions.clickup.types import OutboundResult + +logger = logging.getLogger(__name__) + +API_V3_BASE = "https://api.clickup.com/api/v3" + + +async def get_message( + workspace_id: str, + message_id: str, + api_token: str, +) -> dict | None: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + return resp.json() + logger.error("ClickUp get_message failed: status=%d", resp.status_code) + return None + except Exception as e: + logger.exception("ClickUp get_message exception: %s", e) + return None + + +async def list_channel_messages( + workspace_id: str, + channel_id: str, + api_token: str, + limit: int = 50, + cursor: str | None = None, +) -> dict: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels/{channel_id}/messages" + params: dict = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + return resp.json() + logger.error("ClickUp list_channel_messages failed: status=%d", resp.status_code) + return {"messages": []} + except Exception as e: + logger.exception("ClickUp list_channel_messages exception: %s", e) + return {"messages": []} + + +async def list_dm_messages( + workspace_id: str, + dm_id: str, + api_token: str, + limit: int = 50, + cursor: str | None = None, +) -> dict: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/dm/{dm_id}/messages" + params: dict = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + return resp.json() + logger.error("ClickUp list_dm_messages failed: status=%d", resp.status_code) + return {"messages": []} + except Exception as e: + logger.exception("ClickUp list_dm_messages exception: %s", e) + return {"messages": []} + + +async def send_channel_message( + workspace_id: str, + channel_id: str, + content: str, + api_token: str, + reply_to_id: str | None = None, +) -> OutboundResult: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels/{channel_id}/messages" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload: dict = { + "type": "message", + "content": content[:40000], + "content_format": "text/md", + } + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code == 201: + data = resp.json() + return OutboundResult(success=True, message_id=data.get("id", "")) + error_text = resp.text[:500] + logger.error("ClickUp send_channel_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 send_channel_message exception: %s", e) + return OutboundResult(success=False, error="exception", detail=str(e)) + + +async def list_channels( + workspace_id: str, + api_token: str, + cursor: str | None = None, +) -> dict: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels" + params: dict = {} + if cursor: + params["cursor"] = cursor + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + return resp.json() + logger.error("ClickUp list_channels failed: status=%d", resp.status_code) + return {"channels": []} + except Exception as e: + logger.exception("ClickUp list_channels exception: %s", e) + return {"channels": []} + + +async def create_channel( + workspace_id: str, + api_token: str, + name: str, + *, + visibility: str = "public", + member_ids: list[str] | None = None, +) -> dict: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels" + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload: dict = { + "name": name, + "visibility": visibility, + } + if member_ids: + payload["members"] = member_ids + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + logger.error("ClickUp create_channel failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"创建频道失败 (HTTP {resp.status_code}): {error_text}") + except Exception: + logger.exception("ClickUp create_channel exception") + raise + + +async def get_channel_members( + workspace_id: str, + api_token: str, + channel_id: str, +) -> list[dict]: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/channels/{channel_id}/members" + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return data.get("members", []) + error_text = resp.text[:500] + logger.error("ClickUp get_channel_members failed: status=%d", resp.status_code) + raise RuntimeError(f"获取频道成员失败 (HTTP {resp.status_code}): {error_text}") + except Exception: + logger.exception("ClickUp get_channel_members exception") + raise + + +async def create_dm( + workspace_id: str, + api_token: str, + member_ids: list[str], +) -> dict: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/dm" + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"members": member_ids} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + logger.error("ClickUp create_dm failed: status=%d body=%s", resp.status_code, error_text) + raise RuntimeError(f"创建私信失败 (HTTP {resp.status_code}): {error_text}") + except Exception: + logger.exception("ClickUp create_dm exception") + raise + + +async def resolve_account(account_id: str | None = None) -> dict | None: + adapter = ClickUpConfigAdapter() + aid = account_id or adapter.default_account_id({}) + return await adapter.resolve_account(aid) diff --git a/backend/package/yuxi/channel/extensions/clickup/outbound.py b/backend/package/yuxi/channel/extensions/clickup/outbound.py new file mode 100644 index 00000000..31bd5683 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/outbound.py @@ -0,0 +1,178 @@ +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 diff --git a/backend/package/yuxi/channel/extensions/clickup/pairing.py b/backend/package/yuxi/channel/extensions/clickup/pairing.py new file mode 100644 index 00000000..ddb69dc5 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/pairing.py @@ -0,0 +1,21 @@ +import logging +import secrets + +logger = logging.getLogger(__name__) + + +class ClickUpPairing: + id_label = "clickupUserId" + + async def generate_code(self, peer_id: str) -> str: + code = secrets.randbelow(1_000_000) + return f"{code:06d}" + + async def verify_code(self, peer_id: str, code: str) -> bool: + return True + + def normalize_allow_entry(self, entry: str) -> str: + return entry.strip() + + async def notify_approval(self, config: dict, peer_id: str, account_id: str | None = None) -> None: + logger.info("clickup pairing approved for peer %s", peer_id) diff --git a/backend/package/yuxi/channel/extensions/clickup/plugin.json b/backend/package/yuxi/channel/extensions/clickup/plugin.json new file mode 100644 index 00000000..5152821d --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/plugin.json @@ -0,0 +1,37 @@ +{ + "id": "clickup", + "name": "ClickUp Chat", + "version": "0.1.0", + "label": "ClickUp Chat", + "aliases": ["clickup-chat", "cu"], + "description": "ClickUp Chat 渠道插件,支持项目管理上下文中的 AI 助手交互", + "author": "ForcePilot Team", + "order": 91, + "dependencies": ["httpx"], + "capabilities": { + "chat_types": ["direct", "group"], + "message_types": ["text"], + "interactions": [], + "reactions": true, + "streaming": true, + "streaming_mode": "edit_simulate", + "block_streaming": true, + "block_streaming_chunk_min_chars": 200, + "block_streaming_chunk_max_chars": 1500, + "block_streaming_chunk_break_preference": "paragraph", + "block_streaming_coalesce_min_chars": 100, + "block_streaming_coalesce_max_chars": 600, + "block_streaming_coalesce_idle_ms": 500, + "threads": true, + "reply": true, + "edit": true, + "unsend": true, + "media": false, + "native_commands": false, + "polls": false, + "group_management": false, + "polling_fallback": true + }, + "enabled": false, + "python_requires": ">=3.12" +} diff --git a/backend/package/yuxi/channel/extensions/clickup/polling.py b/backend/package/yuxi/channel/extensions/clickup/polling.py new file mode 100644 index 00000000..ed3ee3a4 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/polling.py @@ -0,0 +1,255 @@ +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 diff --git a/backend/package/yuxi/channel/extensions/clickup/ratelimit.py b/backend/package/yuxi/channel/extensions/clickup/ratelimit.py new file mode 100644 index 00000000..ec408b1f --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/ratelimit.py @@ -0,0 +1,33 @@ +import asyncio +import logging + +import httpx + +logger = logging.getLogger(__name__) + +MAX_RETRIES = 3 +BASE_DELAY = 2.0 + + +async def retry_with_backoff(coro_factory, max_retries: int = MAX_RETRIES): + for attempt in range(max_retries + 1): + try: + resp = await coro_factory() + if resp.status_code == 429: + retry_after = resp.headers.get("Retry-After", str(BASE_DELAY * (2**attempt))) + wait = float(retry_after) + logger.warning("clickup rate limited (429), retry after %.1fs, attempt %d", wait, attempt + 1) + await asyncio.sleep(wait) + continue + return resp + except httpx.HTTPStatusError: + if attempt < max_retries: + await asyncio.sleep(BASE_DELAY * (2**attempt)) + else: + raise + except Exception: + if attempt < max_retries: + await asyncio.sleep(BASE_DELAY * (2**attempt)) + else: + raise + raise RuntimeError("clickup rate limit exceeded after max retries") diff --git a/backend/package/yuxi/channel/extensions/clickup/reactions.py b/backend/package/yuxi/channel/extensions/clickup/reactions.py new file mode 100644 index 00000000..983552fc --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/reactions.py @@ -0,0 +1,70 @@ +import logging + +import httpx + +logger = logging.getLogger(__name__) + +API_V3_BASE = "https://api.clickup.com/api/v3" + + +async def add_reaction( + workspace_id: str, + message_id: str, + reaction: str, + api_token: str, +) -> bool: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}/reactions" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = {"reaction": reaction.lower()} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code == 201: + return True + logger.error("ClickUp add_reaction failed: status=%d", resp.status_code) + return False + except Exception as e: + logger.exception("ClickUp add_reaction exception: %s", e) + return False + + +async def get_reactions( + workspace_id: str, + message_id: str, + api_token: str, +) -> list[dict]: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}/reactions" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return data.get("reactions", []) + return [] + except Exception as e: + logger.exception("ClickUp get_reactions exception: %s", e) + return [] + + +async def remove_reaction( + workspace_id: str, + message_id: str, + reaction: str, + api_token: str, +) -> bool: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{message_id}/reactions/{reaction.lower()}" + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.delete(url, headers=headers) + return resp.status_code == 204 + except Exception as e: + logger.exception("ClickUp remove_reaction exception: %s", e) + return False diff --git a/backend/package/yuxi/channel/extensions/clickup/security.py b/backend/package/yuxi/channel/extensions/clickup/security.py new file mode 100644 index 00000000..aa10ae17 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/security.py @@ -0,0 +1,46 @@ +import logging + +logger = logging.getLogger(__name__) + + +class ClickUpSecurity: + def resolve_dm_policy(self) -> dict: + return {"mode": "open", "allow_from": []} + + def resolve_dm_policy_for_account(self, account: dict) -> dict: + mode = account.get("dm_policy", "open") + return {"mode": mode, "allow_from": account.get("allow_from", [])} + + async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: + return True + + def resolve_require_mention(self, ctx) -> bool | None: + config = getattr(ctx, "config", {}) if ctx else {} + channel_id = getattr(ctx, "channel_id", None) if ctx else None + + if channel_id: + channels = config.get("channels", {}) + channel_cfg = channels.get(channel_id, {}) + if "require_mention" in channel_cfg: + return channel_cfg["require_mention"] + + channel_policy = config.get("channel_policy", "activate_on_mention") + return channel_policy == "activate_on_mention" + + def resolve_group_intro_hint(self, ctx) -> str | None: + return None + + def resolve_tool_policy(self, ctx) -> dict | None: + return None + + def apply_config_fixes(self, config: dict) -> dict: + return config + + def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]: + warnings = [] + channel_policy = account.get("channel_policy", "") if account else config.get("channel_policy", "") + + if channel_policy == "always" and not config.get("channels"): + warnings.append("channelPolicy is 'always' without channels allowlist") + + return warnings diff --git a/backend/package/yuxi/channel/extensions/clickup/status.py b/backend/package/yuxi/channel/extensions/clickup/status.py new file mode 100644 index 00000000..d6147a9d --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/status.py @@ -0,0 +1,90 @@ +import logging + +import httpx + +from yuxi.channel.protocols import ChannelAccountSnapshot + +logger = logging.getLogger(__name__) + + +class ClickUpStatus: + default_runtime = None + + async def probe(self, account: dict | None = None) -> bool: + if not account: + return False + + api_token = account.get("api_token", "") + if not api_token: + return False + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get( + "https://api.clickup.com/api/v2/team", + headers=headers, + ) + if resp.status_code == 200: + teams = resp.json().get("teams", []) + logger.info("ClickUp probe OK: %d workspace(s) accessible", len(teams)) + return True + else: + logger.warning("ClickUp probe failed: status=%d", resp.status_code) + return False + except Exception as e: + logger.warning("ClickUp probe exception: %s", e) + return False + + def build_summary(self, snapshot: object) -> dict: + return { + "account_id": getattr(snapshot, "account_id", ""), + "status": "ok" if getattr(snapshot, "probe_ok", False) else "error", + } + + def build_account_snapshot( + self, + account: dict, + config: dict, + runtime: ChannelAccountSnapshot | None = None, + probe_result: object | None = None, + audit: object | None = None, + ) -> ChannelAccountSnapshot: + return ChannelAccountSnapshot( + account_id=account.get("account_id", "default"), + name=account.get("name", ""), + enabled=account.get("enabled", True), + configured=bool(account.get("api_token") and account.get("workspace_id")), + status_state="linked" if account.get("api_token") else "not-linked", + running=getattr(runtime, "running", False) if runtime else False, + connected=getattr(runtime, "connected", False) if runtime else False, + last_message_at=getattr(runtime, "last_message_at", None) if runtime else None, + health_state="ok" if probe_result else "unknown", + dm_policy=account.get("dm_policy", "open"), + ) + + def collect_status_issues(self, accounts: list[ChannelAccountSnapshot]) -> list: + issues = [] + for acct in accounts: + if not getattr(acct, "configured", True): + issues.append( + { + "channel": "clickup", + "account_id": acct.account_id, + "kind": "not-configured", + "message": "ClickUp API Token 或 Workspace ID 未配置", + "fix": "设置 CLICKUP_API_TOKEN 和 CLICKUP_WORKSPACE_ID 环境变量", + } + ) + elif not getattr(acct, "connected", True): + issues.append( + { + "channel": "clickup", + "account_id": acct.account_id, + "kind": "not-connected", + "message": "ClickUp gateway 未运行", + "fix": "检查 webhook 端点和 gateway 状态", + } + ) + return issues diff --git a/backend/package/yuxi/channel/extensions/clickup/streaming.py b/backend/package/yuxi/channel/extensions/clickup/streaming.py new file mode 100644 index 00000000..67c15765 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/streaming.py @@ -0,0 +1,21 @@ +class ClickUpStreaming: + streaming_mode = "edit_simulate" + preview_stream_throttle_ms = 200 + preview_min_initial_chars = 20 + + block_streaming_enabled = True + block_streaming_break = "text_end" + block_streaming_chunk_min_chars = 200 + block_streaming_chunk_max_chars = 1500 + block_streaming_chunk_break_preference = "paragraph" + block_streaming_coalesce_defaults = { + "min_chars": 100, + "max_chars": 600, + "idle_ms": 500, + } + + def create_draft_stream_session(self, target_id: str) -> object: + return {"target_id": target_id, "mode": "edit_simulate"} + + def create_block_chunker(self) -> object: + return {"mode": "length", "min_chars": 200, "max_chars": 1500} diff --git a/backend/package/yuxi/channel/extensions/clickup/threading.py b/backend/package/yuxi/channel/extensions/clickup/threading.py new file mode 100644 index 00000000..9f4736dc --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/threading.py @@ -0,0 +1,65 @@ +import logging + +import httpx + +from yuxi.channel.extensions.clickup.types import OutboundResult + +logger = logging.getLogger(__name__) + +API_V3_BASE = "https://api.clickup.com/api/v3" + + +async def send_reply( + workspace_id: str, + parent_message_id: str, + content: str, + api_token: str, +) -> OutboundResult: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{parent_message_id}/replies" + + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload = { + "type": "message", + "content": content[:40000], + "content_format": "text/md", + } + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code == 201: + data = resp.json() + return OutboundResult(success=True, message_id=data.get("id", "")) + else: + error_text = resp.text[:500] + logger.error("ClickUp send_reply 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 send_reply exception: %s", e) + return OutboundResult(success=False, error="exception", detail=str(e)) + + +async def get_replies( + workspace_id: str, + parent_message_id: str, + api_token: str, + limit: int = 50, + cursor: str | None = None, +) -> dict: + url = f"{API_V3_BASE}/workspaces/{workspace_id}/chat/messages/{parent_message_id}/replies" + params = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + try: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + return resp.json() + logger.error("ClickUp get_replies failed: status=%d", resp.status_code) + return {"replies": []} + except Exception as e: + logger.exception("ClickUp get_replies exception: %s", e) + return {"replies": []} diff --git a/backend/package/yuxi/channel/extensions/clickup/types.py b/backend/package/yuxi/channel/extensions/clickup/types.py new file mode 100644 index 00000000..938dfaa4 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/types.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class ClickUpAccount: + account_id: str = "default" + api_token: str = "" + workspace_id: str = "" + webhook_secret: str = "" + auth_mode: str = "api_token" + oauth_client_id: str = "" + oauth_client_secret: str = "" + name: str = "" + + dm_policy: str = "open" + allow_from: list[str] = field(default_factory=list) + channel_policy: str = "activate_on_mention" + channel_allowlist: list[str] = field(default_factory=list) + + poll_fallback_enabled: bool = False + poll_interval: float = 15.0 + + @property + def is_configured(self) -> bool: + if self.auth_mode == "api_token": + return bool(self.api_token and self.workspace_id) + return bool(self.oauth_client_id and self.oauth_client_secret and self.workspace_id) + + @property + def auth_header(self) -> dict: + return {"Authorization": self.api_token} + + @property + def api_v3_base(self) -> str: + return f"https://api.clickup.com/api/v3/workspaces/{self.workspace_id}" + + +@dataclass +class InboundClickUpMessage: + message_id: str + chat_id: str + chat_type: str + user_id: str + user_name: str + content: str + content_format: str = "text/md" + reply_to_id: str | None = None + mentioned_user_ids: list[str] = field(default_factory=list) + attachments: list[dict] = field(default_factory=list) + created_at: datetime | None = None + updated_at: datetime | None = None + raw_payload: dict | None = None + + +@dataclass +class OutboundResult: + success: bool + message_id: str = "" + error: str = "" + detail: str = "" + status_code: int = 0 + + +@dataclass +class ClickUpChannel: + channel_id: str + name: str = "" + channel_type: str = "" + visibility: str = "PUBLIC" + description: str = "" + topic: str = "" + member_count: int = 0 diff --git a/backend/package/yuxi/channel/extensions/clickup/webhook.py b/backend/package/yuxi/channel/extensions/clickup/webhook.py new file mode 100644 index 00000000..fd828ed1 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/webhook.py @@ -0,0 +1,71 @@ +import hashlib +import hmac +import json +import logging + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +from yuxi.channel.extensions.clickup.config import _env_or_config + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/webhook/clickup", tags=["clickup"]) + + +@router.post("/automation") +async def receive_automation(request: Request): + headers = request.headers + body = await request.body() + + if not body: + logger.warning("ClickUp webhook received empty body") + return JSONResponse({"status": "ok"}) + + if not _verify_webhook_secret(headers, body): + logger.warning("ClickUp webhook secret verification failed") + raise HTTPException(status_code=403, detail="Invalid webhook secret") + + try: + payload = json.loads(body) + except json.JSONDecodeError: + logger.error("ClickUp webhook: invalid JSON payload") + return JSONResponse({"status": "error", "reason": "invalid-json"}, status_code=400) + + logger.debug("ClickUp webhook received: %s", json.dumps(payload, indent=2, ensure_ascii=False)) + + from yuxi.channel.extensions.clickup.gateway import _get_webhook_queue + + queue = _get_webhook_queue() + if queue is not None: + await queue.put(payload) + + return JSONResponse({"status": "ok"}) + + +def _verify_webhook_secret(headers, raw_body: bytes | None = None) -> bool: + expected = _env_or_config("webhook_secret") + if not expected: + logger.warning("CLICKUP_WEBHOOK_SECRET not set, skipping webhook secret verification") + return True + + signature_header = headers.get("X-Signature") + if signature_header and raw_body is not None: + return _verify_signature(raw_body, expected, signature_header) + + provided = headers.get("Authorization", "").replace("Bearer ", "").strip() + if not provided: + provided = headers.get("X-ClickUp-Secret", "").strip() + + return hmac.compare_digest(provided, expected) + + +def _verify_signature(raw_body: bytes, secret: str, signature_header: str | None) -> bool: + if not signature_header: + return False + expected = hmac.new( + secret.encode("utf-8"), + raw_body, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature_header) diff --git a/backend/package/yuxi/channel/extensions/clickup/webhook_mgmt.py b/backend/package/yuxi/channel/extensions/clickup/webhook_mgmt.py new file mode 100644 index 00000000..e5068711 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/clickup/webhook_mgmt.py @@ -0,0 +1,74 @@ +import logging + +import httpx + +logger = logging.getLogger(__name__) +API_V2_BASE = "https://api.clickup.com/api/v2" + + +async def create_webhook( + workspace_id: str, + api_token: str, + endpoint_url: str, + events: list[str], + *, + space_id: str | None = None, + folder_id: str | None = None, + list_id: str | None = None, +) -> dict: + url = f"{API_V2_BASE}/team/{workspace_id}/webhook" + headers = {"Authorization": api_token, "Content-Type": "application/json"} + payload: dict = { + "endpoint": endpoint_url, + "events": events, + } + if space_id: + payload["space_id"] = space_id + if folder_id: + payload["folder_id"] = folder_id + if list_id: + payload["list_id"] = list_id + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code in (200, 201): + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"创建 webhook 失败 (HTTP {resp.status_code}): {error_text}") + + +async def list_webhooks(workspace_id: str, api_token: str) -> list[dict]: + url = f"{API_V2_BASE}/team/{workspace_id}/webhook" + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + data = resp.json() + return data.get("webhooks", []) + error_text = resp.text[:500] + raise RuntimeError(f"获取 webhook 列表失败 (HTTP {resp.status_code}): {error_text}") + + +async def update_webhook(webhook_id: str, api_token: str, **updates) -> dict: + url = f"{API_V2_BASE}/webhook/{webhook_id}" + headers = {"Authorization": api_token, "Content-Type": "application/json"} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.put(url, json=updates, headers=headers) + if resp.status_code == 200: + return resp.json() + error_text = resp.text[:500] + raise RuntimeError(f"更新 webhook 失败 (HTTP {resp.status_code}): {error_text}") + + +async def delete_webhook(webhook_id: str, api_token: str) -> dict: + url = f"{API_V2_BASE}/webhook/{webhook_id}" + headers = {"Authorization": api_token} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.delete(url, headers=headers) + if resp.status_code in (200, 204): + return {"deleted": True, "webhook_id": webhook_id} + error_text = resp.text[:500] + raise RuntimeError(f"删除 webhook 失败 (HTTP {resp.status_code}): {error_text}")