platform-core/modules/connectors/connectorProviderClickup.py
ValueOn AG bc7c6fe27c
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 13s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
elimination of technical issues (imports)
2026-06-06 00:32:45 +02:00

396 lines
15 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# 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)