gateway/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py

223 lines
8.7 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp API service (OAuth or personal token via UserConnection)."""
import json
import logging
import asyncio
from typing import Any, Callable, Dict, List, Optional, Union
import aiohttp
logger = logging.getLogger(__name__)
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
def clickup_authorization_header(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 ClickupService:
"""ClickUp REST API v2 — teams, hierarchy, lists as tables (tasks + custom fields)."""
def __init__(self, context, get_service: Callable[[str], Any]):
self._context = context
self._get_service = get_service
self.accessToken: Optional[str] = None
def setAccessTokenFromConnection(self, userConnection) -> bool:
"""Load OAuth/personal token from SecurityService for this UserConnection."""
try:
if not userConnection:
logger.error("UserConnection is required to set access token")
return False
if isinstance(userConnection, dict):
connection_id = userConnection.get("id")
else:
connection_id = getattr(userConnection, "id", None)
if not connection_id:
logger.error("UserConnection must have an 'id' field")
return False
security = self._get_service("security")
if not security:
logger.error("Security service not available for token access")
return False
token = security.getFreshToken(connection_id)
if not token:
logger.error(f"No token found for connection {connection_id}")
return False
self.accessToken = token.tokenAccess
return True
except Exception as e:
logger.error(f"Error setting ClickUp access token: {e}")
return False
def setAccessToken(self, token: str) -> None:
"""Set token directly (e.g. connector adapter)."""
self.accessToken = token
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. Call setAccessTokenFromConnection first."}
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
headers: Dict[str, str] = {
"Authorization": clickup_authorization_header(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:
# 404 on GET is common (wrong id / preview) — avoid ERROR noise in logs
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 requestRaw(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
) -> Union[Dict[str, Any], List[Any], None]:
"""Escape hatch: call any v2 path under /api/v2 (path without leading /api/v2)."""
return await self._request(method, path, params=params, json_body=json_body)
# --- Teams / user ---
async def getAuthorizedUser(self) -> Dict[str, Any]:
return await self._request("GET", "/user")
async def getAuthorizedTeams(self) -> Dict[str, Any]:
return await self._request("GET", "/team")
async def getTeam(self, team_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/team/{team_id}")
# --- Hierarchy ---
async def getSpaces(self, team_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/team/{team_id}/space")
async def getSpace(self, space_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{space_id}")
async def getFolders(self, space_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{space_id}/folder")
async def getFolder(self, folder_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/folder/{folder_id}")
async def getListsInFolder(self, folder_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/folder/{folder_id}/list")
async def getFolderlessLists(self, space_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{space_id}/list")
async def getList(self, list_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/list/{list_id}")
async def getListFields(self, list_id: str) -> Dict[str, Any]:
return await self._request("GET", f"/list/{list_id}/field")
# --- Tasks (rows) ---
async def getTasksInList(
self,
list_id: str,
*,
page: int = 0,
include_closed: bool = False,
subtasks: bool = True,
) -> Dict[str, Any]:
params: Dict[str, Any] = {
"page": page,
"subtasks": str(subtasks).lower(),
"include_closed": str(include_closed).lower(),
}
return await self._request("GET", f"/list/{list_id}/task", params=params)
async def getTask(self, task_id: str, *, include_subtasks: bool = True) -> Dict[str, Any]:
params = {"include_subtasks": str(include_subtasks).lower()}
return await self._request("GET", f"/task/{task_id}", params=params)
async def createTask(self, list_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
return await self._request("POST", f"/list/{list_id}/task", json_body=body)
async def updateTask(self, task_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
return await self._request("PUT", f"/task/{task_id}", json_body=body)
async def deleteTask(self, task_id: str) -> Dict[str, Any]:
return await self._request("DELETE", f"/task/{task_id}")
async def searchTeamTasks(
self,
team_id: str,
*,
query: str,
page: int = 0,
) -> Dict[str, Any]:
"""Search tasks in a workspace (team)."""
params = {"query": query, "page": page}
return await self._request("GET", f"/team/{team_id}/task", params=params)
async def uploadTaskAttachment(self, task_id: str, file_bytes: bytes, file_name: str) -> Dict[str, Any]:
"""Upload a file attachment to a task (multipart)."""
if not self.accessToken:
return {"error": "Access token is not set."}
url = f"{_CLICKUP_API_BASE}/task/{task_id}/attachment"
headers = {"Authorization": clickup_authorization_header(self.accessToken)}
data = aiohttp.FormData()
data.add_field(
"attachment",
file_bytes,
filename=file_name,
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=data) 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)}