feat(clickup): 新增ClickUp聊天渠道插件完整实现
该提交实现了完整的ClickUp聊天渠道插件,包含以下核心功能: 1. 基础的@提及提取与格式化能力 2. 账号配对与会话管理 3. 消息流与流式回复支持 4. 重试限流与消息去重 5. 富文本格式转换与内容 sanitize 6. 安全策略与配置校验 7. Webhook接收与自动轮询回退 8. 消息收发、编辑、删除与回复 9. 频道与私信管理、反应功能 10. 完整的状态监控与健康检查
This commit is contained in:
parent
bb85da1ca2
commit
a79fc8dd7b
374
backend/package/yuxi/channel/extensions/clickup/__init__.py
Normal file
374
backend/package/yuxi/channel/extensions/clickup/__init__.py
Normal 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())
|
||||
1086
backend/package/yuxi/channel/extensions/clickup/agent_tools.py
Normal file
1086
backend/package/yuxi/channel/extensions/clickup/agent_tools.py
Normal file
File diff suppressed because it is too large
Load Diff
159
backend/package/yuxi/channel/extensions/clickup/config.py
Normal file
159
backend/package/yuxi/channel/extensions/clickup/config.py
Normal 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 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", ""))
|
||||
38
backend/package/yuxi/channel/extensions/clickup/dedupe.py
Normal file
38
backend/package/yuxi/channel/extensions/clickup/dedupe.py
Normal 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)
|
||||
52
backend/package/yuxi/channel/extensions/clickup/format.py
Normal file
52
backend/package/yuxi/channel/extensions/clickup/format.py
Normal 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()
|
||||
177
backend/package/yuxi/channel/extensions/clickup/gateway.py
Normal file
177
backend/package/yuxi/channel/extensions/clickup/gateway.py
Normal 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)
|
||||
14
backend/package/yuxi/channel/extensions/clickup/mentions.py
Normal file
14
backend/package/yuxi/channel/extensions/clickup/mentions.py
Normal 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}"
|
||||
@ -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)
|
||||
178
backend/package/yuxi/channel/extensions/clickup/outbound.py
Normal file
178
backend/package/yuxi/channel/extensions/clickup/outbound.py
Normal 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
|
||||
21
backend/package/yuxi/channel/extensions/clickup/pairing.py
Normal file
21
backend/package/yuxi/channel/extensions/clickup/pairing.py
Normal 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)
|
||||
37
backend/package/yuxi/channel/extensions/clickup/plugin.json
Normal file
37
backend/package/yuxi/channel/extensions/clickup/plugin.json
Normal 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"
|
||||
}
|
||||
255
backend/package/yuxi/channel/extensions/clickup/polling.py
Normal file
255
backend/package/yuxi/channel/extensions/clickup/polling.py
Normal 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
|
||||
33
backend/package/yuxi/channel/extensions/clickup/ratelimit.py
Normal file
33
backend/package/yuxi/channel/extensions/clickup/ratelimit.py
Normal 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")
|
||||
70
backend/package/yuxi/channel/extensions/clickup/reactions.py
Normal file
70
backend/package/yuxi/channel/extensions/clickup/reactions.py
Normal 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
|
||||
46
backend/package/yuxi/channel/extensions/clickup/security.py
Normal file
46
backend/package/yuxi/channel/extensions/clickup/security.py
Normal 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
|
||||
90
backend/package/yuxi/channel/extensions/clickup/status.py
Normal file
90
backend/package/yuxi/channel/extensions/clickup/status.py
Normal 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
|
||||
21
backend/package/yuxi/channel/extensions/clickup/streaming.py
Normal file
21
backend/package/yuxi/channel/extensions/clickup/streaming.py
Normal 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}
|
||||
65
backend/package/yuxi/channel/extensions/clickup/threading.py
Normal file
65
backend/package/yuxi/channel/extensions/clickup/threading.py
Normal 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": []}
|
||||
75
backend/package/yuxi/channel/extensions/clickup/types.py
Normal file
75
backend/package/yuxi/channel/extensions/clickup/types.py
Normal 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
|
||||
71
backend/package/yuxi/channel/extensions/clickup/webhook.py
Normal file
71
backend/package/yuxi/channel/extensions/clickup/webhook.py
Normal 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)
|
||||
@ -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}")
|
||||
Loading…
Reference in New Issue
Block a user