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