feat(clickup): 新增ClickUp聊天渠道插件完整实现

该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能:
1. 基础的@提及提取与格式化能力
2. 账号配对与会话管理
3. 消息流与流式回复支持
4. 重试限流与消息去重
5. 富文本格式转换与内容 sanitize
6. 安全策略与配置校验
7. Webhook接收与自动轮询回退
8. 消息收发、编辑、删除与回复
9. 频道与私信管理、反应功能
10. 完整的状态监控与健康检查
This commit is contained in:
Kris 2026-05-21 10:43:19 +08:00
parent bb85da1ca2
commit a79fc8dd7b
21 changed files with 3153 additions and 0 deletions

View File

@ -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())

File diff suppressed because it is too large Load Diff

View File

@ -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 Tokenpk_ 前缀)。在 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", ""))

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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}"

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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"
}

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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": []}

View File

@ -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

View File

@ -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)

View File

@ -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}")