From 92437b4ab5e2e681e33677cb5380f1040b9266f3 Mon Sep 17 00:00:00 2001 From: PieterCK Date: Thu, 8 Aug 2024 18:22:36 +0700 Subject: [PATCH] integrations: Refactor slack_incoming webhook. This commit refactors `render_attachment` and `render_block` out of slack_incoming.py to promote reusability. The primary motivation for this refactor is to add support for converting integration bots messages in Slack exports, which could use the same functions. Part of #31311. --- .../data_import/slack_message_conversion.py | 164 +++++++++++++++++- zerver/webhooks/slack_incoming/view.py | 164 +----------------- 2 files changed, 165 insertions(+), 163 deletions(-) diff --git a/zerver/data_import/slack_message_conversion.py b/zerver/data_import/slack_message_conversion.py index 9887590523..a5c822bc68 100644 --- a/zerver/data_import/slack_message_conversion.py +++ b/zerver/data_import/slack_message_conversion.py @@ -1,10 +1,23 @@ import re -from typing import Any, TypeAlias +from itertools import zip_longest +from typing import Any, Literal, TypeAlias, TypedDict, cast + +from zerver.lib.types import Validator +from zerver.lib.validator import ( + WildValue, + check_dict, + check_int, + check_list, + check_string, + check_string_in, + check_url, +) # stubs ZerverFieldsT: TypeAlias = dict[str, Any] SlackToZulipUserIDT: TypeAlias = dict[str, int] AddedChannelsT: TypeAlias = dict[str, tuple[str, int]] +SlackFieldsT: TypeAlias = dict[str, Any] # Slack link can be in the format and LINK_REGEX = r""" @@ -184,3 +197,152 @@ def convert_mailto_format(text: str) -> tuple[str, bool]: has_link = True text = text.replace(match.group(0), match.group(1)) return text, has_link + + +def render_block(block: WildValue) -> str: + # https://api.slack.com/reference/block-kit/blocks + block_type = block["type"].tame( + check_string_in(["actions", "context", "divider", "header", "image", "input", "section"]) + ) + if block_type == "actions": + # Unhandled + return "" + elif block_type == "context" and block.get("elements"): + pieces = [] + # Slack renders these pieces left-to-right, packed in as + # closely as possible. We just render them above each other, + # for simplicity. + for element in block["elements"]: + element_type = element["type"].tame(check_string_in(["image", "plain_text", "mrkdwn"])) + if element_type == "image": + pieces.append(render_block_element(element)) + else: + pieces.append(element.tame(check_text_block())["text"]) + return "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "") + elif block_type == "divider": + return "----" + elif block_type == "header": + return "## " + block["text"].tame(check_text_block(plain_text_only=True))["text"] + elif block_type == "image": + image_url = block["image_url"].tame(check_url) + alt_text = block["alt_text"].tame(check_string) + if "title" in block: + alt_text = block["title"].tame(check_text_block(plain_text_only=True))["text"] + return f"[{alt_text}]({image_url})" + elif block_type == "input": + # Unhandled + pass + elif block_type == "section": + pieces = [] + if "text" in block: + pieces.append(block["text"].tame(check_text_block())["text"]) + + if "accessory" in block: + pieces.append(render_block_element(block["accessory"])) + + if "fields" in block: + fields = block["fields"].tame(check_list(check_text_block())) + if len(fields) == 1: + # Special-case a single field to display a bit more + # nicely, without extraneous borders and limitations + # on its contents. + pieces.append(fields[0]["text"]) + else: + # It is not possible to have newlines in a table, nor + # escape the pipes that make it up; replace them with + # whitespace. + field_text = [f["text"].replace("\n", " ").replace("|", " ") for f in fields] + # Because Slack formats this as two columns, but not + # necessarily a table with a bold header, we emit a + # blank header row first. + table = "| | |\n|-|-|\n" + # Then take the fields two-at-a-time to make the table + iters = [iter(field_text)] * 2 + for left, right in zip_longest(*iters, fillvalue=""): + table += f"| {left} | {right} |\n" + pieces.append(table) + + return "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "") + + return "" + + +class TextField(TypedDict): + text: str + type: Literal["plain_text", "mrkdwn"] + + +def check_text_block(plain_text_only: bool = False) -> Validator[TextField]: + if plain_text_only: + type_validator = check_string_in(["plain_text"]) + else: + type_validator = check_string_in(["plain_text", "mrkdwn"]) + + def f(var_name: str, val: object) -> TextField: + block = check_dict( + [ + ("type", type_validator), + ("text", check_string), + ], + )(var_name, val) + + return cast(TextField, block) + + return f + + +def render_block_element(element: WildValue) -> str: + # https://api.slack.com/reference/block-kit/block-elements + # Zulip doesn't support interactive elements, so we only render images here + element_type = element["type"].tame(check_string) + if element_type == "image": + image_url = element["image_url"].tame(check_url) + alt_text = element["alt_text"].tame(check_string) + return f"[{alt_text}]({image_url})" + else: + # Unsupported + return "" + + +def render_attachment(attachment: WildValue) -> str: + # https://api.slack.com/reference/messaging/attachments + # Slack recommends the usage of "blocks" even within attachments; the + # rest of the fields we handle here are legacy fields. These fields are + # optional and may contain null values. + pieces = [] + if attachment.get("title"): + title = attachment["title"].tame(check_string) + if attachment.get("title_link"): + title_link = attachment["title_link"].tame(check_url) + pieces.append(f"## [{title}]({title_link})") + else: + pieces.append(f"## {title}") + if attachment.get("pretext"): + pieces.append(attachment["pretext"].tame(check_string)) + if attachment.get("text"): + pieces.append(attachment["text"].tame(check_string)) + if "fields" in attachment: + fields = [] + for field in attachment["fields"]: + if "title" in field and "value" in field and field["title"] and field["value"]: + title = field["title"].tame(check_string) + value = field["value"].tame(check_string) + fields.append(f"*{title}*: {value}") + elif field.get("title"): + title = field["title"].tame(check_string) + fields.append(f"*{title}*") + elif field.get("value"): + value = field["value"].tame(check_string) + fields.append(f"{value}") + pieces.append("\n".join(fields)) + if attachment.get("blocks"): + pieces += map(render_block, attachment["blocks"]) + if attachment.get("image_url"): + pieces.append("[]({})".format(attachment["image_url"].tame(check_url))) + if attachment.get("footer"): + pieces.append(attachment["footer"].tame(check_string)) + if attachment.get("ts"): + time = attachment["ts"].tame(check_int) + pieces.append(f"") + + return "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "") diff --git a/zerver/webhooks/slack_incoming/view.py b/zerver/webhooks/slack_incoming/view.py index 3db74ac526..252fa1e6e2 100644 --- a/zerver/webhooks/slack_incoming/view.py +++ b/zerver/webhooks/slack_incoming/view.py @@ -2,29 +2,18 @@ import re from collections.abc import Callable from functools import wraps -from itertools import zip_longest -from typing import Literal, TypedDict, cast 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 render_attachment, render_block 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.types import Validator -from zerver.lib.validator import ( - WildValue, - check_dict, - check_int, - check_list, - check_string, - check_string_in, - check_url, - to_wild_value, -) +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 @@ -106,155 +95,6 @@ def api_slack_incoming_webhook( return json_success(request, data={"ok": True}) -def render_block(block: WildValue) -> str: - # https://api.slack.com/reference/block-kit/blocks - block_type = block["type"].tame( - check_string_in(["actions", "context", "divider", "header", "image", "input", "section"]) - ) - if block_type == "actions": - # Unhandled - return "" - elif block_type == "context" and block.get("elements"): - pieces = [] - # Slack renders these pieces left-to-right, packed in as - # closely as possible. We just render them above each other, - # for simplicity. - for element in block["elements"]: - element_type = element["type"].tame(check_string_in(["image", "plain_text", "mrkdwn"])) - if element_type == "image": - pieces.append(render_block_element(element)) - else: - pieces.append(element.tame(check_text_block())["text"]) - return "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "") - elif block_type == "divider": - return "----" - elif block_type == "header": - return "## " + block["text"].tame(check_text_block(plain_text_only=True))["text"] - elif block_type == "image": - image_url = block["image_url"].tame(check_url) - alt_text = block["alt_text"].tame(check_string) - if "title" in block: - alt_text = block["title"].tame(check_text_block(plain_text_only=True))["text"] - return f"[{alt_text}]({image_url})" - elif block_type == "input": - # Unhandled - pass - elif block_type == "section": - pieces = [] - if "text" in block: - pieces.append(block["text"].tame(check_text_block())["text"]) - - if "accessory" in block: - pieces.append(render_block_element(block["accessory"])) - - if "fields" in block: - fields = block["fields"].tame(check_list(check_text_block())) - if len(fields) == 1: - # Special-case a single field to display a bit more - # nicely, without extraneous borders and limitations - # on its contents. - pieces.append(fields[0]["text"]) - else: - # It is not possible to have newlines in a table, nor - # escape the pipes that make it up; replace them with - # whitespace. - field_text = [f["text"].replace("\n", " ").replace("|", " ") for f in fields] - # Because Slack formats this as two columns, but not - # necessarily a table with a bold header, we emit a - # blank header row first. - table = "| | |\n|-|-|\n" - # Then take the fields two-at-a-time to make the table - iters = [iter(field_text)] * 2 - for left, right in zip_longest(*iters, fillvalue=""): - table += f"| {left} | {right} |\n" - pieces.append(table) - - return "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "") - - return "" - - -class TextField(TypedDict): - text: str - type: Literal["plain_text", "mrkdwn"] - - -def check_text_block(plain_text_only: bool = False) -> Validator[TextField]: - if plain_text_only: - type_validator = check_string_in(["plain_text"]) - else: - type_validator = check_string_in(["plain_text", "mrkdwn"]) - - def f(var_name: str, val: object) -> TextField: - block = check_dict( - [ - ("type", type_validator), - ("text", check_string), - ], - )(var_name, val) - - return cast(TextField, block) - - return f - - -def render_block_element(element: WildValue) -> str: - # https://api.slack.com/reference/block-kit/block-elements - # Zulip doesn't support interactive elements, so we only render images here - element_type = element["type"].tame(check_string) - if element_type == "image": - image_url = element["image_url"].tame(check_url) - alt_text = element["alt_text"].tame(check_string) - return f"[{alt_text}]({image_url})" - else: - # Unsupported - return "" - - -def render_attachment(attachment: WildValue) -> str: - # https://api.slack.com/reference/messaging/attachments - # Slack recommends the usage of "blocks" even within attachments; the - # rest of the fields we handle here are legacy fields. These fields are - # optional and may contain null values. - pieces = [] - if attachment.get("title"): - title = attachment["title"].tame(check_string) - if attachment.get("title_link"): - title_link = attachment["title_link"].tame(check_url) - pieces.append(f"## [{title}]({title_link})") - else: - pieces.append(f"## {title}") - if attachment.get("pretext"): - pieces.append(attachment["pretext"].tame(check_string)) - if attachment.get("text"): - pieces.append(attachment["text"].tame(check_string)) - if "fields" in attachment: - fields = [] - for field in attachment["fields"]: - if "title" in field and "value" in field and field["title"] and field["value"]: - title = field["title"].tame(check_string) - value = field["value"].tame(check_string) - fields.append(f"*{title}*: {value}") - elif field.get("title"): - title = field["title"].tame(check_string) - fields.append(f"*{title}*") - elif field.get("value"): - value = field["value"].tame(check_string) - fields.append(f"{value}") - pieces.append("\n".join(fields)) - if attachment.get("blocks"): - pieces += map(render_block, attachment["blocks"]) - if attachment.get("image_url"): - pieces.append("[]({})".format(attachment["image_url"].tame(check_url))) - if attachment.get("footer"): - pieces.append(attachment["footer"].tame(check_string)) - if attachment.get("ts"): - time = attachment["ts"].tame(check_int) - pieces.append(f"") - - return "\n\n".join(piece.strip() for piece in pieces if piece.strip() != "") - - def replace_links(text: str) -> str: return re.sub(r"<(\w+?:\/\/.*?)\|(.*?)>", r"[\2](\1)", text)