1+ import os
2+ import time
3+ import requests
4+ from typing import Any , Dict , Iterable , List , Optional , Union
5+
6+ # Prefer absolute import to avoid relative import issues
7+ from codebots .bots ._bot import BaseBot
8+
9+ __all__ = ["ClickUpBot" ]
10+
11+
12+ class ClickUpBot (BaseBot ):
13+ """Bot to interact with ClickUp API v2 using a personal API token.
14+
15+ Token handling:
16+ - Uses environment variable CLICKUPBOT_BOT_TOKEN if set (recommended for CI/CD).
17+ - Falls back to ~/.tokens/clickup.json with {"bot_token": "..."} for local dev.
18+
19+ Parameters
20+ ----------
21+ config_file : str, optional
22+ Path to a JSON token file. Defaults to ~/.tokens/clickup.json
23+ base_url : str, optional
24+ Override ClickUp API base URL. Defaults to https://api.clickup.com/api/v2
25+ timeout : float, optional
26+ HTTP request timeout in seconds (default 30.0)
27+ """
28+
29+ def __init__ (
30+ self ,
31+ config_file : Optional [str ] = None ,
32+ base_url : str = "https://api.clickup.com/api/v2" ,
33+ timeout : float = 30.0 ,
34+ ) -> None :
35+ if not config_file :
36+ from .. import TOKENS
37+ config_file = os .path .join (TOKENS , "clickup.json" )
38+ super ().__init__ (config_file )
39+
40+ token = getattr (self , "bot_token" , None )
41+ if not token :
42+ raise ValueError (
43+ "ClickUp token not found. Set CLICKUPBOT_BOT_TOKEN or provide a token file."
44+ )
45+
46+ self .base_url = base_url .rstrip ("/" )
47+ self .timeout = timeout
48+ self ._session = requests .Session ()
49+ # ClickUp expects the token directly in the Authorization header (no 'Bearer' prefix)
50+ self ._session .headers .update (
51+ {
52+ "Authorization" : token ,
53+ "Content-Type" : "application/json" ,
54+ "Accept" : "application/json" ,
55+ }
56+ )
57+
58+ @property
59+ def session (self ) -> requests .Session :
60+ return self ._session
61+
62+ # ---------------------------
63+ # Low-level HTTP helper
64+ # ---------------------------
65+ def _request (
66+ self ,
67+ method : str ,
68+ path : str ,
69+ params : Optional [Dict [str , Any ]] = None ,
70+ json : Optional [Dict [str , Any ]] = None ,
71+ max_retries : int = 3 ,
72+ ) -> Dict [str , Any ]:
73+ """Issue an HTTP request with basic retry for 429/5xx."""
74+ url = f"{ self .base_url } /{ path .lstrip ('/' )} "
75+ attempt = 0
76+ while True :
77+ attempt += 1
78+ resp = self .session .request (
79+ method = method .upper (),
80+ url = url ,
81+ params = params ,
82+ json = json ,
83+ timeout = self .timeout ,
84+ )
85+
86+ # Handle rate limits and transient errors
87+ if resp .status_code in (429 , 500 , 502 , 503 , 504 ) and attempt <= max_retries :
88+ retry_after = resp .headers .get ("Retry-After" )
89+ delay = float (retry_after ) if retry_after else min (2 ** attempt , 10 )
90+ time .sleep (delay )
91+ continue
92+
93+ # Raise for other non-success responses
94+ if not (200 <= resp .status_code < 300 ):
95+ try :
96+ detail = resp .json ()
97+ except Exception :
98+ detail = resp .text
99+ raise RuntimeError (
100+ f"ClickUp API error { resp .status_code } { resp .reason } at { url } : { detail } "
101+ )
102+
103+ # Return JSON payload
104+ try :
105+ return resp .json ()
106+ except ValueError :
107+ return {}
108+
109+ # ---------------------------
110+ # Team / Space / Folder / List
111+ # ---------------------------
112+ def get_teams (self ) -> List [Dict [str , Any ]]:
113+ """Return all teams the token has access to."""
114+ data = self ._request ("GET" , "/team" )
115+ return data .get ("teams" , [])
116+
117+ def find_team_id (self , name : str ) -> Optional [str ]:
118+ """Find a team id by its name."""
119+ for team in self .get_teams ():
120+ if team .get ("name" ) == name :
121+ return str (team .get ("id" ))
122+ return None
123+
124+ def get_spaces (self , team_id : Union [int , str ], archived : bool = False ) -> List [Dict [str , Any ]]:
125+ data = self ._request ("GET" , f"/team/{ team_id } /space" , params = {"archived" : str (archived ).lower ()})
126+ return data .get ("spaces" , [])
127+
128+ def get_folders (self , space_id : Union [int , str ], archived : bool = False ) -> List [Dict [str , Any ]]:
129+ data = self ._request ("GET" , f"/space/{ space_id } /folder" , params = {"archived" : str (archived ).lower ()})
130+ return data .get ("folders" , [])
131+
132+ def get_lists (self , folder_id : Union [int , str ], archived : bool = False ) -> List [Dict [str , Any ]]:
133+ data = self ._request ("GET" , f"/folder/{ folder_id } /list" , params = {"archived" : str (archived ).lower ()})
134+ return data .get ("lists" , [])
135+
136+ # ---------------------------
137+ # Tasks
138+ # ---------------------------
139+ def list_tasks (
140+ self ,
141+ list_id : Union [int , str ],
142+ page : Optional [int ] = None ,
143+ archived : bool = False ,
144+ include_subtasks : Optional [bool ] = None ,
145+ ) -> List [Dict [str , Any ]]:
146+ params : Dict [str , Any ] = {"archived" : str (archived ).lower ()}
147+ if page is not None :
148+ params ["page" ] = page
149+ if include_subtasks is not None :
150+ params ["subtasks" ] = str (include_subtasks ).lower ()
151+ data = self ._request ("GET" , f"/list/{ list_id } /task" , params = params )
152+ return data .get ("tasks" , [])
153+
154+ def get_task (self , task_id : Union [int , str ]) -> Dict [str , Any ]:
155+ return self ._request ("GET" , f"/task/{ task_id } " )
156+
157+ def create_task (
158+ self ,
159+ list_id : Union [int , str ],
160+ name : str ,
161+ description : Optional [str ] = None ,
162+ status : Optional [str ] = None ,
163+ assignees : Optional [Iterable [Union [int , str ]]] = None ,
164+ tags : Optional [Iterable [str ]] = None ,
165+ priority : Optional [int ] = None ,
166+ due_date : Optional [int ] = None , # Unix ms
167+ due_date_time : Optional [bool ] = None ,
168+ start_date : Optional [int ] = None , # Unix ms
169+ start_date_time : Optional [bool ] = None ,
170+ notify_all : Optional [bool ] = None ,
171+ parent : Optional [str ] = None ,
172+ time_estimate : Optional [int ] = None ,
173+ custom_fields : Optional [List [Dict [str , Any ]]] = None ,
174+ extra : Optional [Dict [str , Any ]] = None ,
175+ ) -> Dict [str , Any ]:
176+ """Create a task in a list. Supply additional API fields via `extra` if needed."""
177+ payload : Dict [str , Any ] = {"name" : name }
178+ if description is not None :
179+ payload ["description" ] = description
180+ if status is not None :
181+ payload ["status" ] = status
182+ if assignees is not None :
183+ payload ["assignees" ] = [int (a ) for a in assignees ]
184+ if tags is not None :
185+ payload ["tags" ] = list (tags )
186+ if priority is not None :
187+ payload ["priority" ] = int (priority )
188+ if due_date is not None :
189+ payload ["due_date" ] = int (due_date )
190+ if due_date_time is not None :
191+ payload ["due_date_time" ] = bool (due_date_time )
192+ if start_date is not None :
193+ payload ["start_date" ] = int (start_date )
194+ if start_date_time is not None :
195+ payload ["start_date_time" ] = bool (start_date_time )
196+ if notify_all is not None :
197+ payload ["notify_all" ] = bool (notify_all )
198+ if parent is not None :
199+ payload ["parent" ] = str (parent )
200+ if time_estimate is not None :
201+ payload ["time_estimate" ] = int (time_estimate )
202+ if custom_fields is not None :
203+ payload ["custom_fields" ] = custom_fields
204+ if extra :
205+ payload .update (extra )
206+
207+ return self ._request ("POST" , f"/list/{ list_id } /task" , json = payload )
208+
209+ def update_task (
210+ self ,
211+ task_id : Union [int , str ],
212+ fields : Dict [str , Any ],
213+ ) -> Dict [str , Any ]:
214+ """Update a task. Provide API fields in `fields` (e.g., {'status': 'in progress'})."""
215+ return self ._request ("PUT" , f"/task/{ task_id } " , json = fields )
216+
217+ def add_comment (
218+ self ,
219+ task_id : Union [int , str ],
220+ comment_text : str ,
221+ assignee_id : Optional [Union [int , str ]] = None ,
222+ notify_all : bool = False ,
223+ ) -> Dict [str , Any ]:
224+ """Add a comment to a task."""
225+ payload : Dict [str , Any ] = {"comment_text" : comment_text , "notify_all" : bool (notify_all )}
226+ if assignee_id is not None :
227+ payload ["assignee" ] = int (assignee_id )
228+ return self ._request ("POST" , f"/task/{ task_id } /comment" , json = payload )
229+
230+
231+ # Debug
232+ if __name__ == "__main__" :
233+ # Example usage:
234+ # export CLICKUPBOT_BOT_TOKEN="your-token"
235+ bot = ClickUpBot ()
236+ teams = bot .get_teams ()
237+ print (f"Teams: { [t .get ('name' ) for t in teams ]} " )
0 commit comments