Skip to content

Commit 2e94184

Browse files
committed
integrations: Add ClickUp integration script.
This script is also intended to be downloaded and run locally on the user terminal. So, urlopen is used instead of the usual requests library to avoid dependency. Unlike zulip_trello.py, this script will have to use some input() to gather some user input instead of argsparse because some datas are only available while the script is running. This script can be run multiple times to re-configure the ClickUp integration.
1 parent 20ccb22 commit 2e94184

File tree

3 files changed

+386
-0
lines changed

3 files changed

+386
-0
lines changed

zulip/integrations/clickup/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# A script that automates setting up a webhook with ClickUp
2+
3+
Usage :
4+
5+
1. Make sure you have all of the relevant ClickUp credentials before
6+
executing the script:
7+
- The ClickUp Team ID
8+
- The ClickUp Client ID
9+
- The ClickUp Client Secret
10+
11+
2. Execute the script :
12+
13+
$ python zulip_clickup.py --clickup-team-id <clickup_team_id> \
14+
--clickup-client-id <clickup_board_name> \
15+
--clickup-client-secret <clickup_board_id> \
16+
17+
For more information, please see Zulip's documentation on how to set up
18+
a ClickUp integration [here](https://zulip.com/integrations/doc/clickup).

zulip/integrations/clickup/__init__.py

Whitespace-only changes.
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
#!/usr/bin/env python3 # noqa: EXE001
2+
#
3+
# A ClickUp integration script for Zulip.
4+
5+
import argparse
6+
import json
7+
import os
8+
import re
9+
import sys
10+
import time
11+
import urllib.request
12+
import webbrowser
13+
from typing import Any, Callable, ClassVar, Dict, List, Tuple, Union
14+
from urllib.parse import parse_qs, urlparse
15+
from urllib.request import Request, urlopen
16+
17+
18+
def clear_terminal_and_sleep(sleep_duration: int = 3) -> Callable[[Any], Callable[..., Any]]:
19+
"""
20+
Decorator to clear the terminal and sleep for a specified duration before and after the execution of the decorated function.
21+
"""
22+
cmd = "cls" if os.name == "nt" else "clear"
23+
24+
def decorator(func: Any) -> Any:
25+
def wrapper(*args: Any, **kwargs: Any) -> Any:
26+
os.system(cmd) # noqa: S605
27+
result = func(*args, **kwargs)
28+
time.sleep(sleep_duration)
29+
os.system(cmd) # noqa: S605
30+
return result
31+
32+
return wrapper
33+
34+
return decorator
35+
36+
37+
def process_url(input_url: str, base_url: str) -> str:
38+
"""
39+
Makes sure the input URL is the same the users zulip app URL.
40+
Returns the authorization code from the URL query
41+
"""
42+
parsed_input_url = urlparse(input_url)
43+
parsed_base_url = urlparse(base_url)
44+
45+
same_domain: bool = parsed_input_url.netloc == parsed_base_url.netloc
46+
auth_code = parse_qs(parsed_input_url.query).get("code")
47+
48+
if same_domain and auth_code:
49+
return auth_code[0]
50+
else:
51+
print("Unable to fetch the auth code. exiting")
52+
sys.exit(1)
53+
54+
55+
class ClickUpAPI:
56+
def __init__(
57+
self,
58+
client_id: str,
59+
client_secret: str,
60+
team_id: str,
61+
) -> None:
62+
self.client_id: str = client_id
63+
self.client_secret: str = client_secret
64+
self.team_id: str = team_id
65+
self.API_KEY: str = ""
66+
67+
# To avoid dependency, urlopen is used instead of requests library
68+
# since the script is inteded to be downloaded and run locally
69+
70+
def get_access_token(self, auth_code: str) -> str:
71+
"""
72+
POST request to retrieve ClickUp's API KEY
73+
74+
https://clickup.com/api/clickupreference/operation/GetAccessToken/
75+
"""
76+
77+
query: Dict[str, str] = {
78+
"client_id": self.client_id,
79+
"client_secret": self.client_secret,
80+
"code": auth_code,
81+
}
82+
encoded_data = urllib.parse.urlencode(query).encode("utf-8")
83+
84+
with urlopen("https://api.clickup.com/api/v2/oauth/token", data=encoded_data) as response:
85+
if response.status != 200:
86+
print(f"Error getting access token: {response.status}")
87+
sys.exit(1)
88+
data: Dict[str, str] = json.loads(response.read().decode("utf-8"))
89+
api_key = data.get("access_token")
90+
if api_key:
91+
return api_key
92+
else:
93+
print("Unable to fetch the API key. exiting")
94+
sys.exit(1)
95+
96+
def create_webhook(self, end_point: str, events: List[str]) -> Dict[str, Any]:
97+
"""
98+
POST request to create ClickUp webhooks
99+
100+
https://clickup.com/api/clickupreference/operation/CreateWebhook/
101+
"""
102+
url: str = f"https://api.clickup.com/api/v2/team/{self.team_id}/webhook"
103+
104+
payload: Dict[str, Union[str, List[str]]] = {
105+
"endpoint": end_point,
106+
"events": events,
107+
}
108+
encoded_payload = json.dumps(payload).encode("utf-8")
109+
110+
headers: Dict[str, str] = {
111+
"Content-Type": "application/json",
112+
"Authorization": self.API_KEY,
113+
}
114+
115+
req = Request(url, data=encoded_payload, headers=headers, method="POST") # noqa: S310
116+
with urlopen(req) as response: # noqa: S310
117+
if response.status != 200:
118+
print(f"Error creating webhook: {response.status}")
119+
sys.exit(1)
120+
data: Dict[str, Any] = json.loads(response.read().decode("utf-8"))
121+
122+
return data
123+
124+
def get_webhooks(self) -> Dict[str, Any]:
125+
"""
126+
GET request to retrieve ClickUp webhooks
127+
128+
https://clickup.com/api/clickupreference/operation/GetWebhooks/
129+
"""
130+
url: str = f"https://api.clickup.com/api/v2/team/{self.team_id}/webhook"
131+
132+
headers: Dict[str, str] = {"Authorization": self.API_KEY}
133+
134+
req = Request(url, headers=headers, method="GET") # noqa: S310
135+
with urlopen(req) as response: # noqa: S310
136+
if response.getcode() != 200:
137+
print(f"Error getting webhooks: {response.getcode()}")
138+
sys.exit(1)
139+
data: Dict[str, Any] = json.loads(response.read().decode("utf-8"))
140+
141+
return data
142+
143+
def delete_webhook(self, webhook_id: str) -> None:
144+
"""
145+
DELETE request to delete a ClickUp webhook
146+
147+
https://clickup.com/api/clickupreference/operation/DeleteWebhook/
148+
"""
149+
url: str = f"https://api.clickup.com/api/v2/webhook/{webhook_id}"
150+
151+
headers: Dict[str, str] = {"Authorization": self.API_KEY}
152+
153+
req = Request(url, headers=headers, method="DELETE") # noqa: S310
154+
with urlopen(req) as response: # noqa: S310
155+
if response.getcode() != 200:
156+
print(f"Error deleting webhook: {response.getcode()}")
157+
sys.exit(1)
158+
159+
160+
class ZulipClickUpIntegration(ClickUpAPI):
161+
EVENT_CHOICES: ClassVar[Dict[str, Tuple[str, ...]]] = {
162+
"1": ("taskCreated", "taskUpdated", "taskDeleted"),
163+
"2": ("listCreated", "listUpdated", "listDeleted"),
164+
"3": ("folderCreated", "folderUpdated", "folderDeleted"),
165+
"4": ("spaceCreated", "spaceUpdated", "spaceDeleted"),
166+
"5": ("goalCreated", "goalUpdated", "goalDeleted"),
167+
}
168+
169+
def __init__(
170+
self,
171+
client_id: str,
172+
client_secret: str,
173+
team_id: str,
174+
) -> None:
175+
super().__init__(client_id, client_secret, team_id)
176+
177+
@clear_terminal_and_sleep(1)
178+
def query_for_integration_url(self) -> None:
179+
print(
180+
"""
181+
STEP 1
182+
----
183+
Please enter the integration URL you've just generated
184+
from your Zulip app settings.
185+
186+
It should look similar to this:
187+
e.g. http://YourZulipApp.com/api/v1/external/clickup?api_key=TJ9DnIiNqt51bpfyPll5n2uT4iYxMBW9
188+
"""
189+
)
190+
while True:
191+
input_url: str = input("INTEGRATION URL: ")
192+
if input_url:
193+
break
194+
self.zulip_integration_url = input_url
195+
196+
@clear_terminal_and_sleep(4)
197+
def authorize_clickup_workspace(self) -> None:
198+
print(
199+
"""
200+
STEP 2
201+
----
202+
ClickUp authorization page will open in your browser.
203+
Please authorize your workspace(s).
204+
205+
Click 'Connect Workspace' on the page to proceed...
206+
"""
207+
)
208+
parsed_url = urlparse(self.zulip_integration_url)
209+
base_url: str = f"{parsed_url.scheme}://{parsed_url.netloc}"
210+
url: str = f"https://app.clickup.com/api?client_id={self.client_id}&redirect_uri={base_url}"
211+
time.sleep(1)
212+
webbrowser.open(url)
213+
214+
@clear_terminal_and_sleep(1)
215+
def query_for_authorization_code(self) -> str:
216+
print(
217+
"""
218+
STEP 3
219+
----
220+
After you've authorized your workspace,
221+
you should be redirected to your home URL.
222+
Please copy your home URL and paste it below.
223+
It should contain a code, and look similar to this:
224+
225+
e.g. https://YourZulipDomain.com/?code=332KKA3321NNAK3MADS
226+
"""
227+
)
228+
input_url: str = input("YOUR HOME URL: ")
229+
230+
auth_code: str = process_url(input_url=input_url, base_url=self.zulip_integration_url)
231+
232+
return auth_code
233+
234+
@clear_terminal_and_sleep(1)
235+
def query_for_notification_events(self) -> List[str]:
236+
print(
237+
"""
238+
STEP 4
239+
----
240+
Please select which ClickUp event notification(s) you'd
241+
like to receive in your Zulip app.
242+
EVENT CODES:
243+
1 = task
244+
2 = list
245+
3 = folder
246+
4 = space
247+
5 = goals
248+
249+
Here's an example input if you intend to only receive notifications
250+
related to task, list and folder: 1,2,3
251+
"""
252+
)
253+
querying_user_input: bool = True
254+
selected_events: List[str] = []
255+
256+
while querying_user_input:
257+
input_codes: str = input("EVENT CODE(s): ")
258+
user_input: List[str] = re.split(",", input_codes)
259+
260+
input_is_valid: bool = len(user_input) > 0
261+
exhausted_options: List[str] = []
262+
263+
for event_code in user_input:
264+
if event_code in self.EVENT_CHOICES and event_code not in exhausted_options:
265+
selected_events += self.EVENT_CHOICES[event_code]
266+
exhausted_options.append(event_code)
267+
else:
268+
input_is_valid = False
269+
270+
if not input_is_valid:
271+
print("Please enter a valid set of options and only select each option once")
272+
273+
querying_user_input = not input_is_valid
274+
275+
return selected_events
276+
277+
def delete_old_webhooks(self) -> None:
278+
"""
279+
Checks for existing webhooks, and deletes them if found.
280+
"""
281+
data: Dict[str, Any] = self.get_webhooks()
282+
for webhook in data["webhooks"]:
283+
zulip_url_domain = urlparse(self.zulip_integration_url).netloc
284+
registered_webhook_domain = urlparse(webhook["endpoint"]).netloc
285+
286+
if zulip_url_domain in registered_webhook_domain:
287+
self.delete_webhook(webhook["id"])
288+
289+
def run(self) -> None:
290+
self.query_for_integration_url()
291+
self.authorize_clickup_workspace()
292+
auth_code: str = self.query_for_authorization_code()
293+
self.API_KEY: str = self.get_access_token(auth_code)
294+
events_payload: List[str] = self.query_for_notification_events()
295+
self.delete_old_webhooks()
296+
297+
zulip_webhook_url = (
298+
self.zulip_integration_url
299+
+ "&clickup_api_key="
300+
+ self.API_KEY
301+
+ "&team_id="
302+
+ self.team_id
303+
)
304+
create_webhook_resp: Dict[str, Any] = self.create_webhook(
305+
events=events_payload, end_point=zulip_webhook_url
306+
)
307+
308+
success_msg = """
309+
SUCCESS: Registered your zulip app to ClickUp webhook!
310+
webhook_id: {webhook_id}
311+
312+
You may delete this script or run it again to reconfigure
313+
your integration.
314+
""".format(webhook_id=create_webhook_resp["id"])
315+
316+
print(success_msg)
317+
318+
319+
def main() -> None:
320+
description = """
321+
zulip_clickup.py is a handy little script that allows Zulip users to
322+
quickly set up a ClickUp webhook.
323+
324+
Note: The ClickUp webhook instructions available on your Zulip server
325+
may be outdated. Please make sure you follow the updated instructions
326+
at <https://zulip.com/integrations/doc/clickup>.
327+
"""
328+
329+
parser = argparse.ArgumentParser(description=description)
330+
331+
parser.add_argument(
332+
"--clickup-team-id",
333+
required=True,
334+
help=(
335+
"Your team_id is the numbers immediately following the base ClickUp URL"
336+
"https://app.clickup.com/25567147/home"
337+
"For instance, the team_id for the URL above would be 25567147"
338+
),
339+
)
340+
341+
parser.add_argument(
342+
"--clickup-client-id",
343+
required=True,
344+
help=(
345+
"Visit https://clickup.com/api/developer-portal/authentication/#step-1-create-an-oauth-app"
346+
"and follow 'Step 1: Create an OAuth app' to generate client_id & client_secret."
347+
),
348+
)
349+
parser.add_argument(
350+
"--clickup-client-secret",
351+
required=True,
352+
help=(
353+
"Visit https://clickup.com/api/developer-portal/authentication/#step-1-create-an-oauth-app"
354+
"and follow 'Step 1: Create an OAuth app' to generate client_id & client_secret."
355+
),
356+
)
357+
358+
options = parser.parse_args()
359+
zulip_clickup_integration = ZulipClickUpIntegration(
360+
options.clickup_client_id,
361+
options.clickup_client_secret,
362+
options.clickup_team_id,
363+
)
364+
zulip_clickup_integration.run()
365+
366+
367+
if __name__ == "__main__":
368+
main()

0 commit comments

Comments
 (0)