Skip to content

Commit 48020ab

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 48020ab

File tree

3 files changed

+383
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)