396 lines
15 KiB
Python
396 lines
15 KiB
Python
# Copyright (c) 2026 PowerOn AG
|
|
# All rights reserved.
|
|
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
|
|
|
|
Path convention (leading slash, no trailing slash except root):
|
|
/ — authorized workspaces (teams)
|
|
/team/{teamId} — spaces in the workspace
|
|
/team/{teamId}/space/{spaceId} — folders + folderless lists
|
|
/team/{teamId}/space/{spaceId}/folder/{folderId} — lists in folder
|
|
/team/{teamId}/list/{listId} — tasks in list (rows)
|
|
/team/{teamId}/list/{listId}/task/{taskId} — single task (download = JSON)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
import aiohttp
|
|
|
|
from modules.connectors.connectorProviderBase import (
|
|
ProviderConnector,
|
|
ServiceAdapter,
|
|
DownloadResult,
|
|
)
|
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
|
|
|
|
_CU_TEAM = "team"
|
|
_CU_SPACE = "space"
|
|
_CU_FOLDER = "folder"
|
|
_CU_LIST = "list"
|
|
_CU_TASK = "task"
|
|
|
|
|
|
def _norm(path: str) -> str:
|
|
p = (path or "").strip() or "/"
|
|
if not p.startswith("/"):
|
|
p = "/" + p
|
|
if p != "/" and p.endswith("/"):
|
|
p = p.rstrip("/")
|
|
return p
|
|
|
|
|
|
def clickupAuthorizationHeader(token: str) -> str:
|
|
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
|
|
t = (token or "").strip()
|
|
if t.startswith("pk_"):
|
|
return t
|
|
return f"Bearer {t}"
|
|
|
|
|
|
class ClickupApiClient:
|
|
"""Low-level ClickUp REST API v2 client. Pure HTTP — no service dependencies."""
|
|
|
|
def __init__(self, accessToken: str):
|
|
self.accessToken = accessToken
|
|
|
|
async def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
json_body: Optional[Dict[str, Any]] = None,
|
|
data: Optional[aiohttp.FormData] = None,
|
|
) -> Union[Dict[str, Any], List[Any], bytes, None]:
|
|
if not self.accessToken:
|
|
return {"error": "Access token is not set."}
|
|
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
|
|
headers: Dict[str, str] = {
|
|
"Authorization": clickupAuthorizationHeader(self.accessToken),
|
|
}
|
|
if json_body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
timeout = aiohttp.ClientTimeout(total=60)
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
kwargs: Dict[str, Any] = {"headers": headers, "params": params}
|
|
if json_body is not None:
|
|
kwargs["json"] = json_body
|
|
if data is not None:
|
|
kwargs["data"] = data
|
|
async with session.request(method.upper(), url, **kwargs) as resp:
|
|
if resp.status == 204:
|
|
return {}
|
|
text = await resp.text()
|
|
if resp.status >= 400:
|
|
log = logger.warning if resp.status == 404 else logger.error
|
|
log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}")
|
|
return {"error": f"HTTP {resp.status}", "body": text}
|
|
if not text:
|
|
return {}
|
|
try:
|
|
return json.loads(text)
|
|
except Exception:
|
|
return {"raw": text}
|
|
except asyncio.TimeoutError:
|
|
return {"error": f"ClickUp API timeout: {path}"}
|
|
except Exception as e:
|
|
logger.error(f"ClickUp API error: {e}")
|
|
return {"error": str(e)}
|
|
|
|
async def getAuthorizedTeams(self) -> Dict[str, Any]:
|
|
return await self._request("GET", "/team")
|
|
|
|
async def getSpaces(self, teamId: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/team/{teamId}/space")
|
|
|
|
async def getFolders(self, spaceId: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/space/{spaceId}/folder")
|
|
|
|
async def getFolderlessLists(self, spaceId: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/space/{spaceId}/list")
|
|
|
|
async def getListsInFolder(self, folderId: str) -> Dict[str, Any]:
|
|
return await self._request("GET", f"/folder/{folderId}/list")
|
|
|
|
async def getTasksInList(self, listId: str, *, page: int = 0) -> Dict[str, Any]:
|
|
params: Dict[str, Any] = {"page": page, "subtasks": "true", "include_closed": "false"}
|
|
return await self._request("GET", f"/list/{listId}/task", params=params)
|
|
|
|
async def getTask(self, taskId: str) -> Dict[str, Any]:
|
|
params = {"include_subtasks": "true"}
|
|
return await self._request("GET", f"/task/{taskId}", params=params)
|
|
|
|
async def searchTeamTasks(self, teamId: str, *, query: str, page: int = 0) -> Dict[str, Any]:
|
|
params = {"query": query, "page": page}
|
|
return await self._request("GET", f"/team/{teamId}/task", params=params)
|
|
|
|
async def uploadTaskAttachment(self, taskId: str, fileBytes: bytes, fileName: str) -> Dict[str, Any]:
|
|
if not self.accessToken:
|
|
return {"error": "Access token is not set."}
|
|
url = f"{_CLICKUP_API_BASE}/task/{taskId}/attachment"
|
|
headers = {"Authorization": clickupAuthorizationHeader(self.accessToken)}
|
|
formData = aiohttp.FormData()
|
|
formData.add_field("attachment", fileBytes, filename=fileName, content_type="application/octet-stream")
|
|
timeout = aiohttp.ClientTimeout(total=120)
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.post(url, headers=headers, data=formData) as resp:
|
|
text = await resp.text()
|
|
if resp.status >= 400:
|
|
return {"error": f"HTTP {resp.status}", "body": text}
|
|
return json.loads(text) if text else {}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
class ClickupListsAdapter(ServiceAdapter):
|
|
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
|
|
|
|
def __init__(self, access_token: str):
|
|
self._token = access_token
|
|
self._svc = ClickupApiClient(access_token)
|
|
|
|
async def browse(
|
|
self,
|
|
path: str,
|
|
filter: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
) -> List[ExternalEntry]:
|
|
p = _norm(path)
|
|
out: List[ExternalEntry] = []
|
|
|
|
if p == "/":
|
|
data = await self._svc.getAuthorizedTeams()
|
|
if isinstance(data, dict) and data.get("error"):
|
|
logger.warning(f"ClickUp browse root: {data.get('error')}")
|
|
return []
|
|
teams = data.get("teams", []) if isinstance(data, dict) else []
|
|
for t in teams:
|
|
tid = str(t.get("id", ""))
|
|
name = t.get("name") or tid
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{tid}",
|
|
isFolder=True,
|
|
metadata={"cuType": _CU_TEAM, "id": tid, "raw": t},
|
|
)
|
|
)
|
|
return out
|
|
|
|
m = re.match(r"^/team/([^/]+)$", p)
|
|
if m:
|
|
team_id = m.group(1)
|
|
data = await self._svc.getSpaces(team_id)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
return []
|
|
spaces = data.get("spaces", []) if isinstance(data, dict) else []
|
|
for s in spaces:
|
|
sid = str(s.get("id", ""))
|
|
name = s.get("name") or sid
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{team_id}/space/{sid}",
|
|
isFolder=True,
|
|
metadata={"cuType": _CU_SPACE, "id": sid, "raw": s},
|
|
)
|
|
)
|
|
return out
|
|
|
|
m = re.match(r"^/team/([^/]+)/space/([^/]+)$", p)
|
|
if m:
|
|
team_id, space_id = m.group(1), m.group(2)
|
|
folders_r = await self._svc.getFolders(space_id)
|
|
lists_r = await self._svc.getFolderlessLists(space_id)
|
|
if isinstance(folders_r, dict) and not folders_r.get("error"):
|
|
for f in folders_r.get("folders", []) or []:
|
|
fid = str(f.get("id", ""))
|
|
name = f.get("name") or fid
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{team_id}/space/{space_id}/folder/{fid}",
|
|
isFolder=True,
|
|
metadata={"cuType": _CU_FOLDER, "id": fid, "raw": f},
|
|
)
|
|
)
|
|
if isinstance(lists_r, dict) and not lists_r.get("error"):
|
|
for lst in lists_r.get("lists", []) or []:
|
|
lid = str(lst.get("id", ""))
|
|
name = lst.get("name") or lid
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{team_id}/list/{lid}",
|
|
isFolder=True,
|
|
metadata={"cuType": _CU_LIST, "id": lid, "raw": lst},
|
|
)
|
|
)
|
|
return out
|
|
|
|
m = re.match(r"^/team/([^/]+)/space/([^/]+)/folder/([^/]+)$", p)
|
|
if m:
|
|
team_id, _space_id, folder_id = m.group(1), m.group(2), m.group(3)
|
|
data = await self._svc.getListsInFolder(folder_id)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
return []
|
|
for lst in data.get("lists", []) or []:
|
|
lid = str(lst.get("id", ""))
|
|
name = lst.get("name") or lid
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{team_id}/list/{lid}",
|
|
isFolder=True,
|
|
metadata={"cuType": _CU_LIST, "id": lid, "raw": lst},
|
|
)
|
|
)
|
|
return out
|
|
|
|
m = re.match(r"^/team/([^/]+)/list/([^/]+)$", p)
|
|
if m:
|
|
team_id, list_id = m.group(1), m.group(2)
|
|
page = 0
|
|
while True:
|
|
data = await self._svc.getTasksInList(list_id, page=page)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
break
|
|
tasks = data.get("tasks", []) if isinstance(data, dict) else []
|
|
for task in tasks:
|
|
tid = str(task.get("id", ""))
|
|
name = task.get("name") or tid
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{team_id}/list/{list_id}/task/{tid}",
|
|
isFolder=False,
|
|
metadata={
|
|
"cuType": _CU_TASK,
|
|
"id": tid,
|
|
"task": task,
|
|
},
|
|
)
|
|
)
|
|
if len(tasks) < 100:
|
|
break
|
|
if limit is not None and len(out) >= int(limit):
|
|
break
|
|
page += 1
|
|
if limit is not None:
|
|
out = out[: max(1, int(limit))]
|
|
return out
|
|
|
|
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
|
|
if m:
|
|
team_id, list_id, task_id = m.group(1), m.group(2), m.group(3)
|
|
out.append(
|
|
ExternalEntry(
|
|
name=f"task-{task_id}.json",
|
|
path=p,
|
|
isFolder=False,
|
|
metadata={"cuType": _CU_TASK, "id": task_id, "listId": list_id, "teamId": team_id},
|
|
)
|
|
)
|
|
return out
|
|
|
|
logger.warning(f"ClickUp browse: unsupported path {p}")
|
|
return []
|
|
|
|
async def download(self, path: str) -> Any:
|
|
p = _norm(path)
|
|
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
|
|
if not m:
|
|
return b""
|
|
task_id = m.group(3)
|
|
data = await self._svc.getTask(task_id)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
return json.dumps(data).encode("utf-8")
|
|
returnedId = data.get("id", "") if isinstance(data, dict) else ""
|
|
if returnedId and returnedId != task_id:
|
|
logger.warning(f"ClickUp download: requested task_id={task_id} but API returned id={returnedId}")
|
|
payload = json.dumps(data, indent=2).encode("utf-8")
|
|
return DownloadResult(data=payload, fileName=f"task-{task_id}.json", mimeType="application/json")
|
|
|
|
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
|
"""Upload attachment to a task. Path must be .../list/{listId}/task/{taskId}."""
|
|
p = _norm(path)
|
|
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
|
|
if not m:
|
|
return {"error": "Path must be /team/{teamId}/list/{listId}/task/{taskId} for upload"}
|
|
task_id = m.group(3)
|
|
return await self._svc.uploadTaskAttachment(task_id, data, fileName)
|
|
|
|
async def search(
|
|
self,
|
|
query: str,
|
|
path: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
) -> List[ExternalEntry]:
|
|
base = _norm(path or "/")
|
|
team_id: Optional[str] = None
|
|
mt = re.match(r"^/team/([^/]+)", base)
|
|
if mt:
|
|
team_id = mt.group(1)
|
|
if not team_id:
|
|
teams = await self._svc.getAuthorizedTeams()
|
|
if not isinstance(teams, dict) or teams.get("error"):
|
|
return []
|
|
tl = teams.get("teams") or []
|
|
if not tl:
|
|
return []
|
|
team_id = str(tl[0].get("id", ""))
|
|
|
|
out: List[ExternalEntry] = []
|
|
page = 0
|
|
while True:
|
|
data = await self._svc.searchTeamTasks(team_id, query=query, page=page)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
break
|
|
tasks = data.get("tasks", []) if isinstance(data, dict) else []
|
|
for task in tasks:
|
|
tid = str(task.get("id", ""))
|
|
name = task.get("name") or tid
|
|
list_obj = task.get("list") or {}
|
|
lid = str(list_obj.get("id", "")) if list_obj else ""
|
|
if not lid:
|
|
continue
|
|
out.append(
|
|
ExternalEntry(
|
|
name=name,
|
|
path=f"/team/{team_id}/list/{lid}/task/{tid}",
|
|
isFolder=False,
|
|
metadata={"cuType": _CU_TASK, "id": tid, "task": task},
|
|
)
|
|
)
|
|
if len(tasks) < 25:
|
|
break
|
|
if limit is not None and len(out) >= int(limit):
|
|
break
|
|
page += 1
|
|
if limit is not None:
|
|
out = out[: max(1, int(limit))]
|
|
return out
|
|
|
|
|
|
class ClickupConnector(ProviderConnector):
|
|
"""One ClickUp connection → clickup virtual file service."""
|
|
|
|
def getAvailableServices(self) -> List[str]:
|
|
return ["clickup"]
|
|
|
|
def getServiceAdapter(self, service: str) -> ServiceAdapter:
|
|
if service != "clickup":
|
|
raise ValueError(f"ClickUp only supports 'clickup' service, got '{service}'")
|
|
return ClickupListsAdapter(self.accessToken)
|