snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
# Zulip's Slack-compatible incoming webhook
Zulip can process incoming webhook messages written to work with Slack's
[incoming webhook API][1]. This makes it easy to quickly move your
integrations when migrating your organization from Slack to Zulip.
!!! warn ""
**Note:** In the long term, the recommended approach is to use
Zulip's native integrations, which take advantage of Zulip's topics.
There may also be some quirks when Slack's formatting system is
translated into Zulip's.
{start_tabs}
1. {!create-an-incoming-webhook.md!}
1. {!generate-webhook-url-basic.md!}
1. Use the generated URL anywhere you would use a Slack webhook.
{end_tabs}
### Related documentation
- [Slack's incoming webhook documentation][1]
- [Moving from Slack to Zulip](/help/moving-from-slack)
- [Forward Slack messages into Zulip](/integrations/slack)
- [Forward messages Slack <-> Zulip][2] (both directions)
{!webhooks-url-specification.md!}
[1]: https://api.slack.com/messaging/webhooks
[2]: https://github.com/zulip/python-zulip-api/blob/main/zulip/integrations/bridge_with_slack/README.md

View File

@@ -0,0 +1,48 @@
{
"channel": "C1H9RESGL",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Danny Torrence left the following _review_ for your property:"
}
},
{
"type": "section",
"block_id": "section567",
"text": {
"type": "mrkdwn",
"text": "<https://google.com|Overlook Hotel> \n :star: \n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s."
},
"accessory": {
"type": "image",
"image_url": "https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg",
"alt_text": "Haunted hotel image"
}
},
{
"type": "section",
"block_id": "section789",
"fields": [
{
"type": "mrkdwn",
"text": "*Average Rating*\n1.0"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "Reply to review",
"emoji": false
}
}
]
}
]
}

View File

@@ -0,0 +1,21 @@
{
"channel": "#prometheus-alerts",
"username": "Alertmanager",
"blocks": null,
"attachments": [
{
"title": "[FIRING:2] InstanceDown for api-server (env=\"prod\", severity=\"critical\")",
"title_link": "https://alertmanager.local//#/alerts?receiver=default",
"text": ":chart_with_upwards_trend: *<http://generator.local/1|Graph>* :notebook: *<https://runbook.local/1|Runbook>*\n\n*Alert details*:\n*Alert:* api-server down - `critical`\n*Description:* api-server at 1.2.3.4:8080 couldn't be scraped *Details:*\n • *alertname:* `InstanceDown`\n • *env:* `prod`\n • *instance:* `1.2.3.4:8080`\n • *job:* `api-server`\n • *severity:* `critical`\n\n*Alert:* api-server down - `critical`\n*Description:* api-server at 1.2.3.4:8081 couldn't be scraped *Details:*\n • *alertname:* `InstanceDown`\n • *env:* `prod`\n • *instance:* `1.2.3.4:8081`\n • *job:* `api-server`\n • *severity:* `critical`\n \n",
"fallback": "[FIRING:2] InstanceDown api-server (prod critical) | https://alertmanager.local//#/alerts?receiver=default",
"callback_id": "",
"footer": "",
"color": "danger",
"mrkdwn_in": [
"fallback",
"pretext",
"text"
]
}
]
}

View File

@@ -0,0 +1,67 @@
{
"attachments": [
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "This is a section block with an accessory image."
},
"accessory": {
"type": "image",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"alt_text": "cute cat"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "This is a section block with a button."
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "Click Me",
"emoji": true
},
"value": "click_me_123",
"action_id": "button-action"
}
},
{
"type": "section",
"fields": [
{
"type": "plain_text",
"text": "one",
"emoji": true
},
{
"type": "plain_text",
"text": "two",
"emoji": true
},
{
"type": "plain_text",
"text": "three",
"emoji": true
},
{
"type": "plain_text",
"text": "four",
"emoji": true
},
{
"type": "plain_text",
"text": "five",
"emoji": true
}
]
}
]
}
]
}

View File

@@ -0,0 +1,52 @@
{
"attachments": [
{
"color": "good",
"fields": [
{
"title": "Requested by",
"value": "Some user",
"short": true
},
{
"title": "Duration",
"value": "00:02:03",
"short": true
},
{
"title": "Build pipeline",
"value": "ConsumerAddressModule",
"short": true
},
{
"title": "Title with null value",
"value": null,
"short": true
},
{
"title": "Title without value",
"short": true
},
{
"title": null,
"value": "Value with null title",
"short": true
},
{
"value": "Value without title",
"short": true
},
{
"title": null,
"value": null,
"short": true
}
],
"pretext": "Build bla bla succeeded",
"mrkdwn_in": [
"pretext"
],
"fallback": "Build ConsumerAddressModule_20150407.2 succeeded"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"attachments": [
{
"title": "Test",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": 1655945306
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": null,
"title_link": null,
"image_url": null,
"ts": null,
"text": null,
"pretext": null,
"footer": null
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": "Sample title.",
"title_link": "https://www.google.com",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": 1655945306,
"text": "Sample text.",
"pretext": "Sample pretext.",
"footer": null
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": "Sample title.",
"title_link": "https://www.google.com",
"image_url": null,
"ts": 1655945306,
"text": "Sample text.",
"pretext": "Sample pretext.",
"footer": "Sample footer."
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": "Sample title.",
"title_link": "https://www.google.com",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": 1655945306,
"text": "Sample text.",
"pretext": null,
"footer": "Sample footer."
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": "Sample title.",
"title_link": "https://www.google.com",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": 1655945306,
"text": null,
"pretext": "Sample pretext.",
"footer": "Sample footer."
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": "Sample title.",
"title_link": null,
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": 1655945306,
"text": "Sample text.",
"pretext": "Sample pretext.",
"footer": "Sample footer."
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": null,
"title_link": "https://www.google.com",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": 1655945306,
"text": "Sample text.",
"pretext": "Sample pretext.",
"footer": "Sample footer."
}
]
}

View File

@@ -0,0 +1,13 @@
{
"attachments": [
{
"title": "Sample title.",
"title_link": "https://www.google.com",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"ts": null,
"text": "Sample text.",
"pretext": "Sample pretext.",
"footer": "Sample footer."
}
]
}

View File

@@ -0,0 +1,35 @@
{
"text": "Danny Torrence left a 1 star review for your property.",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Danny Torrence left the following review for your property:"
}
},
{
"type": "section",
"block_id": "section567",
"text": {
"type": "mrkdwn",
"text": "<https://example.com|Overlook Hotel> \n :star: \n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s."
},
"accessory": {
"type": "image",
"image_url": "https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg",
"alt_text": "Haunted hotel image"
}
},
{
"type": "section",
"block_id": "section789",
"fields": [
{
"type": "mrkdwn",
"text": "*Average Rating*\n1.0"
}
]
}
]
}

View File

@@ -0,0 +1,16 @@
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Some image here"
},
"accessory": {
"type": "image",
"alt_text": "yay",
"image_url": "null"
}
}
]
}

View File

@@ -0,0 +1,91 @@
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Hello from TaskBot",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Hey there 👋 I'm TaskBot. I'm here to help you create and manage tasks in Slack.\nThere are two ways to quickly create tasks:"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*1️⃣ Use the `/task` command*. Type `/task` followed by a short description of your tasks and I'll ask for a due date (if applicable). Try it out by using the `/task` command in this channel."
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*2️⃣ Use the _Create a Task_ action.* If you want to create a task from a message, select `Create a Task` in a message's context menu. Try it out by selecting the _Create a Task_ action for this message (shown below)."
}
},
{
"type": "image",
"title": {
"type": "plain_text",
"text": "image1",
"emoji": true
},
"image_url": "https://api.slack.com/img/blocks/bkb_template_images/onboardingComplex.jpg",
"alt_text": "image1"
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "➕ To start tracking your team's tasks, *add me to a channel* and I'll introduce myself. I'm usually added to a team or project channel. Type `/invite @TaskBot` from the channel or pick a channel on the right."
},
"accessory": {
"type": "conversations_select",
"placeholder": {
"type": "plain_text",
"text": "Select a channel...",
"emoji": true
}
}
},
{
"type": "input",
"element": {
"type": "plain_text_input",
"action_id": "plain_text_input-action"
},
"label": {
"type": "plain_text",
"text": "Try writing some task in here:",
"emoji": true
}
},
{
"type": "divider"
},
{
"type": "context",
"elements": [
{
"type": "image",
"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
"alt_text": "cute cat"
},
{
"type": "mrkdwn",
"text": "👀 View all tasks with `/task list`"
},
{
"type": "mrkdwn",
"text": "❓Get help at any time with:\n- `/task help`, or\n- type *help* in a DM with me"
}
]
}
]
}

View File

@@ -0,0 +1,3 @@
{
"text": null
}

View File

@@ -0,0 +1,3 @@
{
"text": "Hello, world."
}

View File

@@ -0,0 +1 @@
payload=%7B%22username%22%3A%22DeployBot%22%2C%22icon_url%22%3A%22https%3A%2F%2Fraw.githubusercontent.com%2Fphallstrom%2Fslackistrano%2Fmaster%2Fimages%2Fslackistrano.png%22%2C%22icon_emoji%22%3A%22%3Azap%3A%22%2C%22text%22%3A%22chris+has+started+deploying+project+tag+v0.0.2rc10+to+staging%22%2C%22channel%22%3A%22%23devops%22%7D

View File

@@ -0,0 +1,387 @@
from typing_extensions import override
from zerver.lib.test_classes import WebhookTestCase
class SlackIncomingHookTests(WebhookTestCase):
def test_message(self) -> None:
expected_topic_name = ""
expected_message = """
Hello, world.
""".strip()
self.check_webhook(
"text",
expected_topic_name,
expected_message,
)
def test_message_formatting(self) -> None:
tests = [
("some *foo* word", "some **foo** word"),
("*foo*", "**foo**"),
("*foo* *bar*", "**foo** **bar**"),
("*foo*a*bar*", "*foo*a*bar*"),
("some _foo_ word", "some *foo* word"),
]
self.subscribe(self.test_user, self.channel_name)
for input_value, output_value in tests:
payload = {"text": input_value}
msg = self.send_webhook_payload(
self.test_user,
self.url,
payload,
content_type="application/json",
)
self.assert_channel_message(
message=msg,
channel_name=self.channel_name,
topic_name="",
content=output_value,
)
def test_null_message(self) -> None:
self.check_webhook(
"null_text",
expect_noop=True,
)
def test_message_as_www_urlencoded(self) -> None:
expected_topic_name = "devops"
expected_message = """
:zap: chris has started deploying project tag v0.0.2rc10 to staging
""".strip()
self.check_webhook(
"urlencoded_text",
expected_topic_name,
expected_message,
content_type="application/x-www-form-urlencoded",
)
def test_message_without_payload(self) -> None:
self.url = self.build_webhook_url()
result = self.client_post(self.url)
self.assertEqual(result.json()["error"], "Missing 'payload' argument")
self.assertEqual(result.json()["ok"], False)
def test_message_with_actions(self) -> None:
expected_topic_name = "C1H9RESGL"
expected_message = """
Danny Torrence left the following *review* for your property:
[Overlook Hotel](https://google.com) \n :star: \n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s.
[Haunted hotel image](https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg)
**Average Rating**
1.0
""".strip()
self.check_webhook(
"actions",
expected_topic_name,
expected_message,
)
def test_message_with_blocks(self) -> None:
expected_topic_name = ""
expected_message = """
Danny Torrence left the following review for your property:
[Overlook Hotel](https://example.com) \n :star: \n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s.
[Haunted hotel image](https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg)
**Average Rating**
1.0
""".strip()
self.check_webhook(
"blocks",
expected_topic_name,
expected_message,
)
def test_message_with_attachment(self) -> None:
expected_topic_name = "prometheus-alerts"
expected_message = """
## [[FIRING:2] InstanceDown for api-server (env="prod", severity="critical")](https://alertmanager.local//#/alerts?receiver=default)
:chart_with_upwards_trend: **[Graph](http://generator.local/1)** :notebook: **[Runbook](https://runbook.local/1)**
**Alert details**:
**Alert:** api-server down - `critical`
**Description:** api-server at 1.2.3.4:8080 couldn't be scraped **Details:**
• **alertname:** `InstanceDown`
• **env:** `prod`
• **instance:** `1.2.3.4:8080`
• **job:** `api-server`
• **severity:** `critical`
**Alert:** api-server down - `critical`
**Description:** api-server at 1.2.3.4:8081 couldn't be scraped **Details:**
• **alertname:** `InstanceDown`
• **env:** `prod`
• **instance:** `1.2.3.4:8081`
• **job:** `api-server`
• **severity:** `critical`
""".strip()
self.check_webhook(
"attachment",
expected_topic_name,
expected_message,
)
def test_complicated(self) -> None:
# Paste the JSON into
# https://api.slack.com/tools/block-kit-builder to see how it
# is rendered in Slack
expected_topic_name = ""
expected_message = """
## Hello from TaskBot
Hey there 👋 I'm TaskBot. I'm here to help you create and manage tasks in Slack.
There are two ways to quickly create tasks:
**1️⃣ Use the `/task` command**. Type `/task` followed by a short description of your tasks and I'll ask for a due date (if applicable). Try it out by using the `/task` command in this channel.
**2️⃣ Use the *Create a Task* action.** If you want to create a task from a message, select `Create a Task` in a message's context menu. Try it out by selecting the *Create a Task* action for this message (shown below).
[image1](https://api.slack.com/img/blocks/bkb_template_images/onboardingComplex.jpg)
➕ To start tracking your team's tasks, **add me to a channel** and I'll introduce myself. I'm usually added to a team or project channel. Type `/invite @TaskBot` from the channel or pick a channel on the right.
----
[cute cat](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
👀 View all tasks with `/task list`
❓Get help at any time with:
- `/task help`, or
- type **help** in a DM with me
""".strip()
self.check_webhook(
"complicated",
expected_topic_name,
expected_message,
)
def test_attachment_blocks(self) -> None:
# On https://api.slack.com/tools/block-kit-builder choose
# "Attachment preview" and paste the JSON in.
expected_topic_name = ""
expected_message = """
This is a section block with an accessory image.
[cute cat](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
This is a section block with a button.
| | |
|-|-|
| one | two |
| three | four |
| five | |
""".strip()
self.check_webhook(
"attachment_blocks",
expected_topic_name,
expected_message,
)
def test_attachment_fields(self) -> None:
expected_topic_name = ""
expected_message = """
Build bla bla succeeded
**Requested by**: Some user
**Duration**: 00:02:03
**Build pipeline**: ConsumerAddressModule
**Title with null value**
**Title without value**
Value with null title
Value without title
""".strip()
self.check_webhook(
"attachment_fields",
expected_topic_name,
expected_message,
)
def test_attachment_pieces(self) -> None:
expected_topic_name = ""
expected_message = """
## Test
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces",
expected_topic_name,
expected_message,
)
@override
def get_body(self, fixture_name: str) -> str:
if "urlencoded" in fixture_name:
file_type = "txt"
else:
file_type = "json"
return self.webhook_fixture_data("slack_incoming", fixture_name, file_type=file_type)
def test_attachment_pieces_title_null(self) -> None:
expected_topic_name = ""
expected_message = """
Sample pretext.
Sample text.
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
Sample footer.
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces_title_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_image_url_null(self) -> None:
expected_topic_name = ""
expected_message = """
## [Sample title.](https://www.google.com)
Sample pretext.
Sample text.
Sample footer.
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces_image_url_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_ts_null(self) -> None:
expected_topic_name = ""
expected_message = """
## [Sample title.](https://www.google.com)
Sample pretext.
Sample text.
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
Sample footer.
""".strip()
self.check_webhook(
"attachment_pieces_ts_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_text_null(self) -> None:
expected_topic_name = ""
expected_message = """
## [Sample title.](https://www.google.com)
Sample pretext.
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
Sample footer.
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces_text_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_pretext_null(self) -> None:
expected_topic_name = ""
expected_message = """
## [Sample title.](https://www.google.com)
Sample text.
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
Sample footer.
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces_pretext_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_footer_null(self) -> None:
expected_topic_name = ""
expected_message = """
## [Sample title.](https://www.google.com)
Sample pretext.
Sample text.
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces_footer_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_title_link_null(self) -> None:
expected_topic_name = ""
expected_message = """
## Sample title.
Sample pretext.
Sample text.
[](https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg)
Sample footer.
<time:2022-06-23T00:48:26+00:00>
""".strip()
self.check_webhook(
"attachment_pieces_title_link_null",
expected_topic_name,
expected_message,
)
def test_attachment_pieces_all_null(self) -> None:
self.check_webhook("attachment_pieces_all_null", expect_noop=True)
def test_broken_image(self) -> None:
self.check_webhook("broken_image", "", "Some image here")

View File

@@ -0,0 +1,107 @@
# Webhooks for external integrations.
import re
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.utils.translation import gettext as _
from typing_extensions import ParamSpec
from zerver.data_import.slack_message_conversion import (
LossyConversionError,
convert_slack_formatting,
render_attachment,
render_block,
replace_links,
)
from zerver.decorator import webhook_view
from zerver.lib.exceptions import JsonableError
from zerver.lib.request import RequestVariableMissingError
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.lib.validator import check_string, to_wild_value
from zerver.lib.webhooks.common import OptionalUserSpecifiedTopicStr, check_send_webhook_message
from zerver.models import UserProfile
ParamT = ParamSpec("ParamT")
def slack_error_handler(
view_func: Callable[Concatenate[HttpRequest, ParamT], HttpResponse],
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]:
"""
A decorator that catches JsonableError exceptions and returns a
Slack-compatible error response in the format:
{ok: false, error: "error message"}.
"""
@wraps(view_func)
def wrapped_view(
request: HttpRequest, /, *args: ParamT.args, **kwargs: ParamT.kwargs
) -> HttpResponse:
try:
return view_func(request, *args, **kwargs)
except JsonableError as error:
return JsonResponse({"ok": False, "error": error.msg}, status=error.http_status_code)
return wrapped_view
@webhook_view("SlackIncoming")
@typed_endpoint
@slack_error_handler
def api_slack_incoming_webhook(
request: HttpRequest,
user_profile: UserProfile,
*,
user_specified_topic: OptionalUserSpecifiedTopicStr = None,
) -> HttpResponse:
# Slack accepts webhook payloads as payload="encoded json" as
# application/x-www-form-urlencoded, as well as in the body as
# application/json.
if request.content_type == "application/json":
try:
val = request.body.decode(request.encoding or "utf-8")
except UnicodeDecodeError: # nocoverage
raise JsonableError(_("Malformed payload"))
else:
req_var = "payload"
if req_var in request.POST:
val = request.POST[req_var]
elif req_var in request.GET: # nocoverage
val = request.GET[req_var]
else:
raise RequestVariableMissingError(req_var)
payload = to_wild_value("payload", val)
if user_specified_topic is None and "channel" in payload:
channel = payload["channel"].tame(check_string)
user_specified_topic = re.sub(r"^[@#]", "", channel)
if user_specified_topic is None:
user_specified_topic = "(no topic)"
pieces: list[str] = []
if payload.get("blocks"):
try:
pieces += map(render_block, payload["blocks"])
except LossyConversionError: # nocoverage
pieces.append(payload.get("text", "").tame(check_string))
if payload.get("attachments"):
pieces += map(render_attachment, payload["attachments"])
body = "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "")
if body == "" and payload.get("text"):
if payload.get("icon_emoji"):
body = payload["icon_emoji"].tame(check_string) + " "
body += payload["text"].tame(check_string)
body = body.strip()
if body != "":
body = convert_slack_formatting(replace_links(body).strip())
check_send_webhook_message(request, user_profile, user_specified_topic, body)
return json_success(request, data={"ok": True})