diff --git a/static/images/integrations/bot_avatars/clickup.png b/static/images/integrations/bot_avatars/clickup.png
new file mode 100644
index 00000000000000..39197b44d32309
Binary files /dev/null and b/static/images/integrations/bot_avatars/clickup.png differ
diff --git a/static/images/integrations/clickup/001.png b/static/images/integrations/clickup/001.png
new file mode 100644
index 00000000000000..c2f51c0c0982e5
Binary files /dev/null and b/static/images/integrations/clickup/001.png differ
diff --git a/static/images/integrations/logos/clickup.svg b/static/images/integrations/logos/clickup.svg
new file mode 100644
index 00000000000000..18f875d5cc8500
--- /dev/null
+++ b/static/images/integrations/logos/clickup.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py
index aa0c61d7597aed..3514fc794c049a 100644
--- a/zerver/lib/integrations.py
+++ b/zerver/lib/integrations.py
@@ -379,6 +379,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
WebhookIntegration("buildbot", ["continuous-integration"], display_name="Buildbot"),
WebhookIntegration("canarytoken", ["monitoring"], display_name="Thinkst Canarytokens"),
WebhookIntegration("circleci", ["continuous-integration"], display_name="CircleCI"),
+ WebhookIntegration("clickup", ["project-management"], display_name="ClickUp"),
WebhookIntegration("clubhouse", ["project-management"]),
WebhookIntegration("codeship", ["continuous-integration", "deployment"]),
WebhookIntegration("crashlytics", ["monitoring"]),
@@ -730,6 +731,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
ScreenshotConfig("bitbucket_job_completed.json", image_name="001.png"),
ScreenshotConfig("github_job_completed.json", image_name="002.png"),
],
+ "clickup": [ScreenshotConfig("task_moved.json")],
"clubhouse": [ScreenshotConfig("story_create.json")],
"codeship": [ScreenshotConfig("error_build.json")],
"crashlytics": [ScreenshotConfig("issue_message.json")],
diff --git a/zerver/webhooks/clickup/__init__.py b/zerver/webhooks/clickup/__init__.py
new file mode 100644
index 00000000000000..8b137891791fe9
--- /dev/null
+++ b/zerver/webhooks/clickup/__init__.py
@@ -0,0 +1 @@
+
diff --git a/zerver/webhooks/clickup/callback_fixtures/get_folder.json b/zerver/webhooks/clickup/callback_fixtures/get_folder.json
new file mode 100644
index 00000000000000..f646c9fdac0520
--- /dev/null
+++ b/zerver/webhooks/clickup/callback_fixtures/get_folder.json
@@ -0,0 +1,14 @@
+{
+ "id": "457",
+ "name": "Lord Foldemort",
+ "orderindex": 0,
+ "override_statuses": false,
+ "hidden": false,
+ "space": {
+ "id": "789",
+ "name": "Space Name",
+ "access": true
+ },
+ "task_count": "0",
+ "lists": []
+}
diff --git a/zerver/webhooks/clickup/callback_fixtures/get_goal.json b/zerver/webhooks/clickup/callback_fixtures/get_goal.json
new file mode 100644
index 00000000000000..733317c1e2be02
--- /dev/null
+++ b/zerver/webhooks/clickup/callback_fixtures/get_goal.json
@@ -0,0 +1,33 @@
+{
+ "goal": {
+ "id": "e53a033c-900e-462d-a849-4a216b06d930",
+ "name": "hat-trick",
+ "team_id": "512",
+ "date_created": "1568044355026",
+ "start_date": null,
+ "due_date": "1568036964079",
+ "description": "Updated Goal Description",
+ "private": false,
+ "archived": false,
+ "creator": 183,
+ "color": "#32a852",
+ "pretty_id": "6",
+ "multiple_owners": true,
+ "folder_id": null,
+ "members": [],
+ "owners": [
+ {
+ "id": 182,
+ "username": "Pieter CK",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "PK",
+ "profilePicture": "https://attachments-public.clickup.com/profilePictures/182_abc.jpg"
+ }
+ ],
+ "key_results": [],
+ "percent_completed": 0,
+ "history": [],
+ "pretty_url": "https://app.clickup.com/512/goals/6"
+ }
+}
diff --git a/zerver/webhooks/clickup/callback_fixtures/get_list.json b/zerver/webhooks/clickup/callback_fixtures/get_list.json
new file mode 100644
index 00000000000000..1fa3309a5f295b
--- /dev/null
+++ b/zerver/webhooks/clickup/callback_fixtures/get_list.json
@@ -0,0 +1,49 @@
+{
+ "id": "124",
+ "name": "Listener",
+ "orderindex": 1,
+ "content": "Updated List Content",
+ "status": {
+ "status": "red",
+ "color": "#e50000",
+ "hide_label": true
+ },
+ "priority": {
+ "priority": "high",
+ "color": "#f50000"
+ },
+ "assignee": null,
+ "due_date": "1567780450202",
+ "due_date_time": true,
+ "start_date": null,
+ "start_date_time": null,
+ "folder": {
+ "id": "456",
+ "name": "Folder Name",
+ "hidden": false,
+ "access": true
+ },
+ "space": {
+ "id": "789",
+ "name": "Space Name",
+ "access": true
+ },
+ "inbound_address": "add.task.124.ac725f.31518a6a-05bb-4997-92a6-1dcfe2f527ca@tasks.clickup.com",
+ "archived": false,
+ "override_statuses": false,
+ "statuses": [
+ {
+ "status": "to do",
+ "orderindex": 0,
+ "color": "#d3d3d3",
+ "type": "open"
+ },
+ {
+ "status": "complete",
+ "orderindex": 1,
+ "color": "#6bc950",
+ "type": "closed"
+ }
+ ],
+ "permission_level": "create"
+}
diff --git a/zerver/webhooks/clickup/callback_fixtures/get_space.json b/zerver/webhooks/clickup/callback_fixtures/get_space.json
new file mode 100644
index 00000000000000..d19af504b23fb4
--- /dev/null
+++ b/zerver/webhooks/clickup/callback_fixtures/get_space.json
@@ -0,0 +1,52 @@
+{
+ "id": "790",
+ "name": "the Milky Way",
+ "private": false,
+ "statuses": [
+ {
+ "status": "to do",
+ "type": "open",
+ "orderindex": 0,
+ "color": "#d3d3d3"
+ },
+ {
+ "status": "complete",
+ "type": "closed",
+ "orderindex": 1,
+ "color": "#6bc950"
+ }
+ ],
+ "multiple_assignees": false,
+ "features": {
+ "due_dates": {
+ "enabled": false,
+ "start_date": false,
+ "remap_due_dates": false,
+ "remap_closed_due_date": false
+ },
+ "time_tracking": {
+ "enabled": false
+ },
+ "tags": {
+ "enabled": false
+ },
+ "time_estimates": {
+ "enabled": false
+ },
+ "checklists": {
+ "enabled": true
+ },
+ "custom_fields": {
+ "enabled": true
+ },
+ "remap_dependencies": {
+ "enabled": false
+ },
+ "dependency_warning": {
+ "enabled": false
+ },
+ "portfolios": {
+ "enabled": false
+ }
+ }
+}
diff --git a/zerver/webhooks/clickup/callback_fixtures/get_task.json b/zerver/webhooks/clickup/callback_fixtures/get_task.json
new file mode 100644
index 00000000000000..146db98e6ad868
--- /dev/null
+++ b/zerver/webhooks/clickup/callback_fixtures/get_task.json
@@ -0,0 +1,63 @@
+{
+ "id": "string",
+ "custom_id": "string",
+ "custom_item_id": 0,
+ "name": "Tanswer",
+ "text_content": "string",
+ "description": "string",
+ "status": {
+ "status": "in progress",
+ "color": "#d3d3d3",
+ "orderindex": 1,
+ "type": "custom"
+ },
+ "orderindex": "string",
+ "date_created": "string",
+ "date_updated": "string",
+ "date_closed": "string",
+ "creator": {
+ "id": 183,
+ "username": "Pieter CK",
+ "color": "#827718",
+ "profilePicture": "https://attachments-public.clickup.com/profilePictures/183_abc.jpg"
+ },
+ "assignees": ["string"],
+ "checklists": ["string"],
+ "tags": ["string"],
+ "parent": "string",
+ "priority": "string",
+ "due_date": "string",
+ "start_date": "string",
+ "time_estimate": "string",
+ "time_spent": "string",
+ "custom_fields": [
+ {
+ "id": "string",
+ "name": "string",
+ "type": "string",
+ "type_config": {},
+ "date_created": "string",
+ "hide_from_guests": true,
+ "value": {
+ "id": 183,
+ "username": "Pieter CK",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "PK",
+ "profilePicture": null
+ },
+ "required": true
+ }
+ ],
+ "list": {
+ "id": "123"
+ },
+ "folder": {
+ "id": "456"
+ },
+ "space": {
+ "id": "789"
+ },
+ "url": "https://app.clickup.com/XXXXXXXX/home",
+ "markdown_description": "string"
+}
diff --git a/zerver/webhooks/clickup/doc.md b/zerver/webhooks/clickup/doc.md
new file mode 100644
index 00000000000000..01d9606b9c383c
--- /dev/null
+++ b/zerver/webhooks/clickup/doc.md
@@ -0,0 +1,88 @@
+# Zulip ClickUp integration
+
+Get Zulip notifications for your ClickUp space!
+
+!!! tip ""
+
+ [Zapier](./zapier) is usually a simpler way to integrate ClickUp
+ with Zulip.
+
+{start_tabs}
+
+1. {!create-channel.md!}
+
+1. {!create-an-incoming-webhook.md!}
+
+1. {!generate-webhook-url-basic.md!}
+
+1. Collect your ClickUp **Team ID** by going to your ClickUp home view.
+ The URL should look like `https://app.clickup.com//home`.
+ Note down the ``.
+
+1. Collect your ClickUp **Client ID** and **Client Secret** by following these steps:
+
+ - Go to your [ClickUp API menu][1] and click **Create an App**.
+
+ - You will be prompted for **Redirect URL(s)**, enter the URL for your Zulip organization.
+ e.g., `{{ zulip_url }}`.
+
+ - Note down the **Client ID** and **Client Secret**
+
+1. You're now going to need to run a ClickUp configuration script from a
+ computer (any computer) connected to the internet. It won't make any
+ changes to the computer.
+
+ Make sure you have a working copy of Python. If you're running
+ macOS or Linux, you very likely already do. If you're running
+ Windows you may or may not. If you don't have Python, follow the
+ installation instructions [here][2].
+
+ !!! tip ""
+
+ You do not need the latest version of Python; anything 2.7 or
+ higher will do.
+
+1. Download [zulip-clickup.py][3].
+
+ !!! tip ""
+
+ Ctrl + s or Cmd + s
+ on that page should work in most browsers.
+
+1. Run the `zulip-clickup.py` script in a terminal, after replacing the all caps
+ arguments with the values collected above.
+
+ ```
+ python zulip-clickup.py --clickup-team-id CLICKUP_TEAM_ID \
+ --clickup-client-id CLICKUP_CLIENT_ID \
+ --clickup-client-secret CLICKUP_CLIENT_SECRET \
+ --zulip-webhook-url "ZULIP_WEBHOOK_URL"
+ ```
+
+ !!! warn ""
+
+ **Note**: Make sure that you wrap the webhook URL generated above
+ in quotes when supplying it on the command-line, as shown above.
+
+1. Follow the instructions in the terminal and keep an eye on your browser as you
+ will be redirected to a ClickUp authorization page.
+
+{end_tabs}
+
+{!congrats.md!}
+
+![](/static/images/integrations/clickup/001.png)
+
+### Related documentation
+
+- [Zapier ClickUp integration][4]
+
+{!webhooks-url-specification.md!}
+
+[1]: https://app.clickup.com/settings/team/clickup-api
+
+[2]: https://realpython.com/installing-python/
+
+[3]: https://raw.githubusercontent.com/zulip/python-zulip-api/main/zulip/integrations/clickup/zulip_clickup.py
+
+[4]: https://zapier.com/apps/clickup/integrations#zap-template-list
diff --git a/zerver/webhooks/clickup/fixtures/folder_created.json b/zerver/webhooks/clickup/fixtures/folder_created.json
new file mode 100644
index 00000000000000..69ca7103079cc7
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/folder_created.json
@@ -0,0 +1,5 @@
+{
+ "event": "folderCreated",
+ "folder_id": "96772212",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/folder_deleted.json b/zerver/webhooks/clickup/fixtures/folder_deleted.json
new file mode 100644
index 00000000000000..19671f01194d3c
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/folder_deleted.json
@@ -0,0 +1,5 @@
+{
+ "event": "folderDeleted",
+ "folder_id": "96772212",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/folder_updated.json b/zerver/webhooks/clickup/fixtures/folder_updated.json
new file mode 100644
index 00000000000000..d1b697320b4cfc
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/folder_updated.json
@@ -0,0 +1,5 @@
+{
+ "event": "folderUpdated",
+ "folder_id": "96772212",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/goal_created.json b/zerver/webhooks/clickup/fixtures/goal_created.json
new file mode 100644
index 00000000000000..7f8e5ce8d4a3f7
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/goal_created.json
@@ -0,0 +1,5 @@
+{
+ "event": "goalCreated",
+ "goal_id": "a23e5a3d-74b5-44c2-ab53-917ebe85045a",
+ "webhook_id": "d5eddb2d-db2b-49e9-87d4-bc6cfbe2313b"
+}
diff --git a/zerver/webhooks/clickup/fixtures/goal_deleted.json b/zerver/webhooks/clickup/fixtures/goal_deleted.json
new file mode 100644
index 00000000000000..626f0e7bd739e0
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/goal_deleted.json
@@ -0,0 +1,5 @@
+{
+ "event": "goalDeleted",
+ "goal_id": "a23e5a3d-74b5-44c2-ab53-917ebe85045a",
+ "webhook_id": "d5eddb2d-db2b-49e9-87d4-bc6cfbe2313b"
+}
diff --git a/zerver/webhooks/clickup/fixtures/goal_updated.json b/zerver/webhooks/clickup/fixtures/goal_updated.json
new file mode 100644
index 00000000000000..97888fe9cba496
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/goal_updated.json
@@ -0,0 +1,5 @@
+{
+ "event": "goalUpdated",
+ "goal_id": "a23e5a3d-74b5-44c2-ab53-917ebe85045a",
+ "webhook_id": "d5eddb2d-db2b-49e9-87d4-bc6cfbe2313b"
+}
diff --git a/zerver/webhooks/clickup/fixtures/list_created.json b/zerver/webhooks/clickup/fixtures/list_created.json
new file mode 100644
index 00000000000000..290b670327574d
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/list_created.json
@@ -0,0 +1,5 @@
+{
+ "event": "listCreated",
+ "list_id": "901601848935",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/list_deleted.json b/zerver/webhooks/clickup/fixtures/list_deleted.json
new file mode 100644
index 00000000000000..6f29a35e66265b
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/list_deleted.json
@@ -0,0 +1,5 @@
+{
+ "event": "listDeleted",
+ "list_id": "901601848935",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/list_updated.json b/zerver/webhooks/clickup/fixtures/list_updated.json
new file mode 100644
index 00000000000000..6abe0b0566b012
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/list_updated.json
@@ -0,0 +1,26 @@
+{
+ "event": "listUpdated",
+ "history_items": [
+ {
+ "id": "8a2f82db-7718-4fdb-9493-4849e67f009d",
+ "type": 6,
+ "date": "1642740510345",
+ "field": "name",
+ "parent_id": "162641285",
+ "data": {},
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "P",
+ "profilePicture": null
+ },
+ "before": "webhook payloads 2",
+ "after": "Webhook payloads round 2"
+ }
+ ],
+ "list_id": "901601848935",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json b/zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json
new file mode 100644
index 00000000000000..e4bbdee602d67d
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/payload_with_spammy_field.json
@@ -0,0 +1,33 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800797048554170804",
+ "type": 1,
+ "date": "1642736652800",
+ "field": "tag",
+ "parent_id": "162641062",
+ "data": {},
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "John",
+ "email": "john@company.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": null,
+ "after": [
+ {
+ "name": "def",
+ "tag_fg": "#FF4081",
+ "tag_bg": "#FF4081",
+ "creator": 2770032
+ }
+ ]
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/space_created.json b/zerver/webhooks/clickup/fixtures/space_created.json
new file mode 100644
index 00000000000000..331c82832b1a7f
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/space_created.json
@@ -0,0 +1,5 @@
+{
+ "event": "spaceCreated",
+ "space_id": "90160869743",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/space_deleted.json b/zerver/webhooks/clickup/fixtures/space_deleted.json
new file mode 100644
index 00000000000000..c5d95f29a0b945
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/space_deleted.json
@@ -0,0 +1,5 @@
+{
+ "event": "spaceDeleted",
+ "space_id": "90160869743",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/space_updated.json b/zerver/webhooks/clickup/fixtures/space_updated.json
new file mode 100644
index 00000000000000..53d9e36468a77b
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/space_updated.json
@@ -0,0 +1,5 @@
+{
+ "event": "spaceUpdated",
+ "space_id": "90160869743",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/task_created.json b/zerver/webhooks/clickup/fixtures/task_created.json
new file mode 100644
index 00000000000000..b2ad1abfa2d540
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_created.json
@@ -0,0 +1,57 @@
+{
+ "event": "taskCreated",
+ "history_items": [
+ {
+ "id": "2800763136717140857",
+ "type": 1,
+ "date": "1642734631523",
+ "field": "status",
+ "parent_id": "162641062",
+ "data": {
+ "status_type": "open"
+ },
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "pieterceka123@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": {
+ "status": null,
+ "color": "#000000",
+ "type": "removed",
+ "orderindex": -1
+ },
+ "after": {
+ "status": "to do",
+ "color": "#f9d900",
+ "orderindex": 0,
+ "type": "open"
+ }
+ },
+ {
+ "id": "2800763136700363640",
+ "type": 1,
+ "date": "1642734631523",
+ "field": "task_creation",
+ "parent_id": "162641062",
+ "data": {},
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": null,
+ "after": null
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/task_deleted.json b/zerver/webhooks/clickup/fixtures/task_deleted.json
new file mode 100644
index 00000000000000..540458df826bf0
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_deleted.json
@@ -0,0 +1,5 @@
+{
+ "event": "taskDeleted",
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/task_moved.json b/zerver/webhooks/clickup/fixtures/task_moved.json
new file mode 100644
index 00000000000000..46f66c88541eb1
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_moved.json
@@ -0,0 +1,52 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800800851630274181",
+ "type": 1,
+ "date": "1642736879339",
+ "field": "section_moved",
+ "parent_id": "162641285",
+ "data": {
+ "mute_notifications": true
+ },
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": {
+ "id": "162641062",
+ "name": "Webhook payloads",
+ "category": {
+ "id": "96771950",
+ "name": "hidden",
+ "hidden": true
+ },
+ "project": {
+ "id": "7002367",
+ "name": "This is my API Space"
+ }
+ },
+ "after": {
+ "id": "162641285",
+ "name": "webhook payloads 2",
+ "category": {
+ "id": "96772049",
+ "name": "hidden",
+ "hidden": true
+ },
+ "project": {
+ "id": "7002367",
+ "name": "This is my API Space"
+ }
+ }
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_assignee.json b/zerver/webhooks/clickup/fixtures/task_updated_assignee.json
new file mode 100644
index 00000000000000..b16d118ecd3f3c
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_assignee.json
@@ -0,0 +1,32 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800789353868594308",
+ "type": 1,
+ "date": "1642736194135",
+ "field": "assignee_add",
+ "parent_id": "162641062",
+ "data": {},
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "after": {
+ "id": 184,
+ "username": "Sam",
+ "email": "sam@company.com",
+ "color": "#7b68ee",
+ "initials": "S",
+ "profilePicture": null
+ }
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_comment.json b/zerver/webhooks/clickup/fixtures/task_updated_comment.json
new file mode 100644
index 00000000000000..d1dd41018e7d9b
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_comment.json
@@ -0,0 +1,85 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800803631413624919",
+ "type": 1,
+ "date": "1642737045116",
+ "field": "comment",
+ "parent_id": "162641285",
+ "data": {},
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "pieterceka123@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": null,
+ "after": "648893191",
+ "comment": {
+ "id": "648893191",
+ "date": "1642737045116",
+ "parent": "1vj38vv",
+ "type": 1,
+ "comment": [
+ {
+ "text": "comment abc1234 56789",
+ "attributes": {}
+ },
+ {
+ "text": "\n",
+ "attributes": {
+ "block-id": "block-4c8fe54f-7bff-4b7b-92a2-9142068983ea"
+ }
+ }
+ ],
+ "text_content": "comment abc1234 56789\n",
+ "x": null,
+ "y": null,
+ "image_y": null,
+ "image_x": null,
+ "page": null,
+ "comment_number": null,
+ "page_id": null,
+ "page_name": null,
+ "view_id": null,
+ "view_name": null,
+ "team": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "pieterceka123@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "new_thread_count": 0,
+ "new_mentioned_thread_count": 0,
+ "email_attachments": [],
+ "threaded_users": [],
+ "threaded_replies": 0,
+ "threaded_assignees": 0,
+ "threaded_assignees_members": [],
+ "threaded_unresolved_count": 0,
+ "thread_followers": [
+ {
+ "id": 183,
+ "username": "Pieter",
+ "email": "pieterceka123@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ }
+ ],
+ "group_thread_followers": [],
+ "reactions": [],
+ "emails": []
+ }
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_due_date.json b/zerver/webhooks/clickup/fixtures/task_updated_due_date.json
new file mode 100644
index 00000000000000..45610c16c0f10a
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_due_date.json
@@ -0,0 +1,29 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800792714143635886",
+ "type": 1,
+ "date": "1642736394447",
+ "field": "due_date",
+ "parent_id": "162641062",
+ "data": {
+ "due_date_time": true,
+ "old_due_date_time": false
+ },
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": "1642701600000",
+ "after": "1643608800000"
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_priority.json b/zerver/webhooks/clickup/fixtures/task_updated_priority.json
new file mode 100644
index 00000000000000..c5825a6b435807
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_priority.json
@@ -0,0 +1,31 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800773800802162647",
+ "type": 1,
+ "date": "1642735267148",
+ "field": "priority",
+ "parent_id": "162641062",
+ "data": {},
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": null,
+ "after": {
+ "id": "2",
+ "priority": "high",
+ "color": "#ffcc00",
+ "orderindex": "2"
+ }
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_status.json b/zerver/webhooks/clickup/fixtures/task_updated_status.json
new file mode 100644
index 00000000000000..395ff54cb1a6ab
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_status.json
@@ -0,0 +1,38 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800787326392370170",
+ "type": 1,
+ "date": "1642736073330",
+ "field": "status",
+ "parent_id": "162641062",
+ "data": {
+ "status_type": "custom"
+ },
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "J",
+ "profilePicture": null
+ },
+ "before": {
+ "status": "to do",
+ "color": "#f9d900",
+ "orderindex": 0,
+ "type": "open"
+ },
+ "after": {
+ "status": "in progress",
+ "color": "#7C4DFF",
+ "orderindex": 1,
+ "type": "custom"
+ }
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+}
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json b/zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json
new file mode 100644
index 00000000000000..09862ebbb19d86
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_time_estimate.json
@@ -0,0 +1,38 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "2800808904123520175",
+ "type": 1,
+ "date": "1642737359443",
+ "field": "time_estimate",
+ "parent_id": "162641285",
+ "data": {
+ "time_estimate_string": "1 hour 30 minutes",
+ "old_time_estimate_string": null,
+ "rolled_up_time_estimate": 5400000,
+ "time_estimate": 5400000,
+ "time_estimates_by_user": [
+ {
+ "userid": 2770032,
+ "user_time_estimate": "5400000",
+ "user_rollup_time_estimate": "5400000"
+ }
+ ]
+ },
+ "source": null,
+ "user": {
+ "id": 183,
+ "username": "Pieter",
+ "email": "kwok.pieter@gmail.com",
+ "color": "#7b68ee",
+ "initials": "P",
+ "profilePicture": null
+ },
+ "before": null,
+ "after": "5400000"
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "webhook_id": "7fa3ec74-69a8-4530-a251-8a13730bd204"
+ }
diff --git a/zerver/webhooks/clickup/fixtures/task_updated_time_spent.json b/zerver/webhooks/clickup/fixtures/task_updated_time_spent.json
new file mode 100644
index 00000000000000..1b44d6d3c6aea2
--- /dev/null
+++ b/zerver/webhooks/clickup/fixtures/task_updated_time_spent.json
@@ -0,0 +1,37 @@
+{
+ "event": "taskUpdated",
+ "history_items": [
+ {
+ "id": "3945907824924417727",
+ "type": "1",
+ "date": "1710990573849",
+ "field": "time_spent",
+ "parent_id": "163597292",
+ "data": {"total_time": "68520000", "rollup_time": "68520000"},
+ "source": null,
+ "user": {
+ "id": "37621629",
+ "username": "Pieter",
+ "email": "pieterceka123@gmail.com",
+ "color": "#5f7c8a",
+ "initials": "P",
+ "profilePicture": null
+ },
+ "before": null,
+ "after": {
+ "id": "3945907824924425939",
+ "start": "1710972573656",
+ "end": "1710990573656",
+ "time": "18000000",
+ "source": "clickup",
+ "date_added": "1710990573849"
+ }
+ }
+ ],
+ "task_id": "86cvyxabb",
+ "data": {
+ "description": "Time Tracking Created",
+ "interval_id": "3945907824924425939"
+ },
+ "webhook_id": "4c21a84b-d0d8-41f7-978e-4fea0776f150"
+}
diff --git a/zerver/webhooks/clickup/tests.py b/zerver/webhooks/clickup/tests.py
new file mode 100644
index 00000000000000..8d96b558b29c99
--- /dev/null
+++ b/zerver/webhooks/clickup/tests.py
@@ -0,0 +1,323 @@
+import json
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+from typing_extensions import override
+
+from zerver.lib.test_classes import WebhookTestCase
+
+from .api_endpoints import get_clickup_api_data
+
+EXPECTED_TOPIC = "ClickUp Notification"
+
+
+class ClickUpHookTests(WebhookTestCase):
+ CHANNEL_NAME = "ClickUp"
+ URL_TEMPLATE = "/api/v1/external/clickup?api_key={api_key}&stream={stream}&team_id=XXXXXXX&clickup_api_key=123"
+ FIXTURE_DIR_NAME = "clickup"
+ WEBHOOK_DIR_NAME = "clickup"
+
+ @override
+ def setUp(self) -> None:
+ super().setUp()
+ self.mock_get_clickup_api_data = patch(
+ "zerver.webhooks.clickup.view.get_clickup_api_data"
+ ).start()
+ self.mock_get_clickup_api_data.side_effect = self.mocked_get_clickup_api_data
+
+ @override
+ def tearDown(self) -> None:
+ self.mock_get_clickup_api_data.stop()
+ super().tearDown()
+
+ def mocked_get_clickup_api_data(self, clickup_api_path: str, **kwargs: Any) -> None:
+ item = clickup_api_path.split("/")[0]
+ with open(f"zerver/webhooks/clickup/callback_fixtures/get_{item}.json") as f:
+ return json.load(f)
+
+ def test_task_created(self) -> None:
+ expected_message = (
+ ":new: **[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!"
+ "\n - Created by: **Pieter CK**"
+ )
+
+ self.check_webhook(
+ fixture_name="task_created",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_deleted(self) -> None:
+ self.url = self.build_webhook_url(team_id="XXXXXXXX", clickup_api_key="123")
+ expected_message = ":trash_can: A Task has been deleted from your ClickUp space!"
+
+ self.check_webhook(
+ fixture_name="task_deleted",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_updated_time_spent(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :stopwatch: Time spent changed to **19:02:00**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_time_spent",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_updated_time_estimate(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :ruler: Time estimate changed from **None** to **1 hour 30 minutes** by **Pieter**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_time_estimate",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_updated_comment(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :speaking_head: Commented by **Pieter**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_comment",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_moved(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :folder: Moved from **Webhook payloads** to **webhook payloads 2**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_moved",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_updated_assignee(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :silhouette: Now assigned to **Sam**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_assignee",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_updated_due_date(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :spiral_calendar: Due date updated from to \n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_due_date",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_updated_priority(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :note: Updated task priority from **None** to **high**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_priority",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_task_updated_status(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :note: Updated task status from **to do** to **in progress**\n"
+ "~~~"
+ )
+
+ self.check_webhook(
+ fixture_name="task_updated_status",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_list_created(self) -> None:
+ expected_message = ":new: **[List: Listener](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!"
+ self.check_webhook(
+ fixture_name="list_created",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_list_deleted(self) -> None:
+ expected_message = ":trash_can: A List has been deleted from your ClickUp space!"
+ self.check_webhook(
+ fixture_name="list_deleted",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_list_updated(self) -> None:
+ expected_message = (
+ "**[List: Listener](https://app.clickup.com/XXXXXXX/home)** has been updated!\n"
+ "~~~ quote\n"
+ " :pencil: Renamed from **webhook payloads 2** to **Webhook payloads round 2**\n"
+ "~~~"
+ )
+ self.check_webhook(
+ fixture_name="list_updated",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_folder_created(self) -> None:
+ expected_message = ":new: **[Folder: Lord Foldemort](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!"
+ self.check_webhook(
+ fixture_name="folder_created",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_folder_deleted(self) -> None:
+ expected_message = ":trash_can: A Folder has been deleted from your ClickUp space!"
+ self.check_webhook(
+ fixture_name="folder_deleted",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_space_created(self) -> None:
+ expected_message = ":new: **[Space: the Milky Way](https://app.clickup.com/XXXXXXX/home)** has been created in your ClickUp space!"
+ self.check_webhook(
+ fixture_name="space_created",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_space_deleted(self) -> None:
+ expected_message = ":trash_can: A Space has been deleted from your ClickUp space!"
+ self.check_webhook(
+ fixture_name="space_deleted",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_space_updated(self) -> None:
+ expected_message = (
+ "**[Space: the Milky Way](https://app.clickup.com/XXXXXXX/home)** has been updated!"
+ )
+ self.check_webhook(
+ fixture_name="space_updated",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_goal_created(self) -> None:
+ expected_message = ":new: **[Goal: hat-trick](https://app.clickup.com/512/goals/6)** has been created in your ClickUp space!"
+ self.check_webhook(
+ fixture_name="goal_created",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_goal_updated(self) -> None:
+ expected_message = (
+ "**[Goal: hat-trick](https://app.clickup.com/512/goals/6)** has been updated!"
+ )
+ self.check_webhook(
+ fixture_name="goal_updated",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_goal_deleted(self) -> None:
+ expected_message = ":trash_can: A Goal has been deleted from your ClickUp space!"
+ self.check_webhook(
+ fixture_name="goal_deleted",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_payload_with_spammy_field(self) -> None:
+ expected_message = (
+ "**[Task: Tanswer](https://app.clickup.com/XXXXXXX/home)** has been updated!"
+ )
+ self.check_webhook(
+ fixture_name="payload_with_spammy_field",
+ expected_topic_name=EXPECTED_TOPIC,
+ expected_message=expected_message,
+ )
+
+ def test_get_clickup_api_data_success_request(self) -> None:
+ with patch("zerver.webhooks.clickup.api_endpoints.requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"key123": "value322"}
+
+ mock_get.return_value = mock_response
+
+ result = get_clickup_api_data("list/123123", token="123")
+
+ mock_get.assert_called_once_with(
+ "https://api.clickup.com/api/v2/list/123123",
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": "123",
+ },
+ params={},
+ )
+ self.assertEqual(result, {"key123": "value322"})
+
+ def test_get_clickup_api_data_failure_request(self) -> None:
+ with patch("zerver.webhooks.clickup.api_endpoints.requests.get") as mock_get:
+ mock_response = MagicMock()
+ mock_response.status_code = 404
+ mock_get.return_value = mock_response
+
+ exception_msg = "HTTP error accessing the ClickUp API. Error: 404"
+
+ with self.assertRaisesRegex(Exception, exception_msg):
+ get_clickup_api_data("list/123123", token="123")
+
+ mock_get.assert_called_once_with(
+ "https://api.clickup.com/api/v2/list/123123",
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": "123",
+ },
+ params={},
+ )
+
+ def test_get_clickup_api_data_missing_api_token(self) -> None:
+ with patch("zerver.webhooks.clickup.api_endpoints.requests"):
+ exception_msg = "ClickUp API 'token' missing in kwargs"
+ with self.assertRaisesRegex(AssertionError, exception_msg):
+ get_clickup_api_data("list/123123", asdasd="123")
diff --git a/zerver/webhooks/clickup/view.py b/zerver/webhooks/clickup/view.py
new file mode 100644
index 00000000000000..72a61b5101f38a
--- /dev/null
+++ b/zerver/webhooks/clickup/view.py
@@ -0,0 +1,248 @@
+# Webhooks for external integrations.
+from typing import Any
+from urllib.parse import urljoin
+
+import requests
+from django.http import HttpRequest, HttpResponse
+
+from zerver.decorator import webhook_view
+from zerver.lib.exceptions import UnsupportedWebhookEventTypeError
+from zerver.lib.response import json_success
+from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint
+from zerver.lib.validator import WildValue, check_none_or, check_string
+from zerver.lib.webhooks.common import check_send_webhook_message, unix_milliseconds_to_timestamp
+from zerver.models import UserProfile
+
+SIMPLE_FIELDS = ["priority", "status"]
+
+SPAMMY_FIELDS = ["tag", "tag_removed", "assignee_rem"]
+
+MESSAGE_WRAPPER = "\n~~~ quote\n {icon} {content}\n~~~\n"
+
+EVENT_NAME_TEMPLATE: str = "**[{event_item_type}: {event_item_name}]({item_url})**"
+
+
+def parse_event_code(event_code: str) -> tuple[str, str]:
+ """
+ Turns string like "taskUpdated" into ("task", "Updated")
+ """
+ data_list = split_camel_case_string(event_code)
+ if len(data_list) != 2:
+ raise UnsupportedWebhookEventTypeError(event_code)
+ return data_list[0], data_list[1]
+
+
+def split_camel_case_string(string: str) -> list[str]:
+ words = []
+ start_index = 0
+
+ for i, char in enumerate(string):
+ if char.isupper() and i > 0:
+ words.append(string[start_index:i])
+ start_index = i
+
+ words.append(string[start_index:])
+
+ return words
+
+
+def generate_created_event_message(item_data: dict[str, Any], event_item_type: str) -> str:
+ body = "\n:new: " + EVENT_NAME_TEMPLATE + " has been created in your ClickUp space!"
+ creator_data = item_data.get("creator")
+ if isinstance(creator_data, dict) and "username" in creator_data:
+ # Some payload only doesn't provide users data.
+ creator_name = creator_data["username"]
+ body += f"\n - Created by: **{creator_name}**"
+ return body.format(
+ event_item_type=event_item_type.title(),
+ event_item_name=item_data["name"],
+ item_url=item_data["url"],
+ )
+
+
+def generate_updated_event_message(
+ item_data: dict[str, Any],
+ event_item_type: str,
+ payload: WildValue,
+) -> str:
+ body = "\n" + EVENT_NAME_TEMPLATE + " has been updated!"
+ history_items = payload.get("history_items", [])
+
+ for history_dict in history_items:
+ updated_field = history_dict["field"].tame(check_string)
+ if updated_field in SPAMMY_FIELDS:
+ continue
+ elif updated_field in SIMPLE_FIELDS:
+ body += body_message_for_simple_fields(history_dict, event_item_type, updated_field)
+ else:
+ body += body_message_for_special_fields(history_dict, updated_field)
+
+ return body.format(
+ event_item_type=event_item_type.title(),
+ event_item_name=item_data["name"],
+ item_url=item_data["url"],
+ )
+
+
+def body_message_for_simple_fields(
+ history_dict: WildValue, event_item_type: str, updated_field: str
+) -> str:
+ # The value of "before"/"after" for these payloads maybe a dict or a bool
+ old_value = (
+ history_dict.get("before").get(updated_field).tame(check_string)
+ if history_dict.get("before")
+ else None
+ )
+ new_value = (
+ history_dict.get("after").get(updated_field).tame(check_string)
+ if history_dict.get("after")
+ else None
+ )
+ return MESSAGE_WRAPPER.format(
+ icon=":note:",
+ content=f"Updated {event_item_type} {updated_field} from **{old_value}** to **{new_value}**",
+ )
+
+
+def body_message_for_special_fields(history_dict: WildValue, updated_field: str) -> str:
+ event_details = history_dict.get("data", {})
+
+ if updated_field == "name":
+ old_value = history_dict["before"].tame(check_none_or(check_string))
+ new_value = history_dict["after"].tame(check_none_or(check_string))
+ return MESSAGE_WRAPPER.format(
+ icon=":pencil:", content=f"Renamed from **{old_value}** to **{new_value}**"
+ )
+ elif updated_field == "assignee_add":
+ new_value = history_dict["after"]["username"].tame(check_string)
+ return MESSAGE_WRAPPER.format(
+ icon=":silhouette:", content=f"Now assigned to **{new_value}**"
+ )
+ elif updated_field == "comment":
+ event_user = history_dict["user"]["username"].tame(check_string)
+ return MESSAGE_WRAPPER.format(
+ icon=":speaking_head:", content=f"Commented by **{event_user}**"
+ )
+ elif updated_field == "due_date":
+ raw_old_due_date = history_dict.get("before").tame(check_none_or(check_string))
+ old_due_date = (
+ unix_milliseconds_to_timestamp(float(raw_old_due_date), "ClickUp").strftime("%Y-%m-%d")
+ if raw_old_due_date
+ else None
+ )
+ raw_new_due_date = history_dict.get("after").tame(check_none_or(check_string))
+ new_due_date = (
+ unix_milliseconds_to_timestamp(float(raw_new_due_date), "ClickUp").strftime("%Y-%m-%d")
+ if raw_new_due_date
+ else None
+ )
+ return MESSAGE_WRAPPER.format(
+ icon=":spiral_calendar:",
+ content=f"Due date updated from to ",
+ )
+ elif updated_field == "section_moved":
+ old_value = history_dict["before"]["name"].tame(check_none_or(check_string))
+ new_value = history_dict["after"]["name"].tame(check_none_or(check_string))
+ return MESSAGE_WRAPPER.format(
+ icon=":folder:", content=f"Moved from **{old_value}** to **{new_value}**"
+ )
+ elif updated_field == "time_spent":
+ raw_time_spent = event_details.get("total_time").tame(check_none_or(check_string))
+ new_time_spent = (
+ unix_milliseconds_to_timestamp(float(raw_time_spent), "ClickUp").strftime("%H:%M:%S")
+ if raw_time_spent
+ else None
+ )
+ return MESSAGE_WRAPPER.format(
+ icon=":stopwatch:", content=f"Time spent changed to **{new_time_spent}**"
+ )
+ elif updated_field == "time_estimate":
+ old_value = event_details["old_time_estimate_string"].tame(check_none_or(check_string))
+ new_value = event_details["time_estimate_string"].tame(check_none_or(check_string))
+ event_user = history_dict["user"]["username"].tame(check_string)
+ return MESSAGE_WRAPPER.format(
+ icon=":ruler:",
+ content=f"Time estimate changed from **{old_value}** to **{new_value}** by **{event_user}**",
+ )
+ else:
+ raise UnsupportedWebhookEventTypeError(updated_field)
+
+
+def get_clickup_api_data(clickup_api_path: str, **kwargs: Any) -> dict[str, Any]:
+ if not kwargs.get("token"):
+ raise AssertionError("ClickUp API 'token' missing in kwargs")
+ token = kwargs.pop("token")
+
+ base_url = "https://api.clickup.com/api/v2/"
+ api_endpoint = urljoin(base_url, clickup_api_path)
+ response = requests.get(
+ api_endpoint,
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": token,
+ },
+ params=kwargs,
+ )
+ if response.status_code != requests.codes.ok:
+ raise Exception(f"HTTP error accessing the ClickUp API. Error: {response.status_code}")
+ return response.json()
+
+
+def get_item_data(
+ event_item_type: str, api_key: str, payload: WildValue, team_id: str
+) -> dict[str, Any]:
+ item_data: dict[str, Any] = {}
+
+ if event_item_type not in ["task", "list", "folder", "space", "goal"]:
+ raise UnsupportedWebhookEventTypeError(event_item_type)
+
+ item_id_key = f"{event_item_type}_id"
+ clickup_api_path = f"{event_item_type}/{payload[item_id_key].tame(check_string)}"
+ item_data = get_clickup_api_data(clickup_api_path, token=api_key)
+
+ if event_item_type == "goal":
+ # The data for "goal" is nested one level deeper.
+ item_data = item_data["goal"]
+
+ item_data["url"] = item_data.get("pretty_url", f"https://app.clickup.com/{team_id}/home")
+
+ return item_data
+
+
+@webhook_view("ClickUp")
+@typed_endpoint
+def api_clickup_webhook(
+ request: HttpRequest,
+ user_profile: UserProfile,
+ *,
+ payload: JsonBodyPayload[WildValue],
+ clickup_api_key: str,
+ team_id: str,
+) -> HttpResponse:
+ event_code = payload["event"].tame(check_string)
+ event_item_type, event_action = parse_event_code(event_code=event_code)
+ topic = "ClickUp Notification"
+
+ if event_action == "Deleted":
+ body = (
+ f"\n:trash_can: A {event_item_type.title()} has been deleted from your ClickUp space!"
+ )
+ check_send_webhook_message(request, user_profile, topic, body)
+ return json_success(request)
+
+ item_data = get_item_data(
+ event_item_type,
+ clickup_api_key,
+ payload,
+ team_id,
+ )
+
+ if event_action == "Created":
+ body = generate_created_event_message(item_data, event_item_type)
+ elif event_action == "Updated":
+ body = generate_updated_event_message(item_data, event_item_type, payload)
+ else:
+ raise UnsupportedWebhookEventTypeError(event_code)
+
+ check_send_webhook_message(request, user_profile, topic, body)
+ return json_success(request)