diff options
-rw-r--r-- | slack/slack_message.py | 94 | ||||
-rw-r--r-- | tests/conftest.py | 41 | ||||
-rw-r--r-- | tests/test_render_attachments.py | 642 | ||||
-rw-r--r-- | tests/test_render_blocks.py | 2 | ||||
-rw-r--r-- | typings/slack_api/slack_conversations_history.pyi | 13 |
5 files changed, 755 insertions, 37 deletions
diff --git a/slack/slack_message.py b/slack/slack_message.py index 4301560..ade737c 100644 --- a/slack/slack_message.py +++ b/slack/slack_message.py @@ -4,7 +4,7 @@ import re from collections import OrderedDict from datetime import date, datetime, timedelta from enum import Enum -from typing import TYPE_CHECKING, Generator, List, Match, Optional, Union +from typing import TYPE_CHECKING, Generator, Iterable, List, Match, Optional, Union import weechat @@ -19,7 +19,7 @@ from slack.python_compatibility import removeprefix, removesuffix from slack.shared import shared from slack.slack_user import SlackBot, SlackUser, format_bot_nick, nick_color from slack.task import gather -from slack.util import intersperse, unhtmlescape, with_color +from slack.util import htmlescape, intersperse, unhtmlescape, with_color if TYPE_CHECKING: from slack_api.slack_conversations_history import SlackMessage as SlackMessageDict @@ -662,7 +662,9 @@ class SlackMessage: uncaught_error = UncaughtError(e) print_error(store_and_format_uncaught_error(uncaught_error)) text = f"<Error rendering message {self.ts}, error id: {uncaught_error.id}>" - self._rendered_message = with_color(shared.config.color.render_error.value, text) + self._rendered_message = with_color( + shared.config.color.render_error.value, text + ) return self._rendered_message @@ -716,6 +718,19 @@ class SlackMessage: if i < len(message): yield message[i:] + def _unfurl_and_unescape( + self, items: Iterable[Union[str, PendingMessageItem]] + ) -> Generator[Union[str, PendingMessageItem], None, None]: + for item in items: + if isinstance(item, str): + for sub_item in self._unfurl_refs(item): + if isinstance(sub_item, str): + yield unhtmlescape(sub_item) + else: + yield sub_item + else: + yield item + def _get_emoji(self, emoji_name: str, skin_tone: Optional[int] = None) -> str: emoji_name_with_colons = f":{emoji_name}:" if shared.config.look.render_emoji_as.value == "name": @@ -1077,7 +1092,10 @@ class SlackMessage: if "attachments" not in self._message_json: return [] - attachments_texts: List[Union[str, PendingMessageItem]] = [] + attachments: List[List[Union[str, PendingMessageItem]]] = [] + if any(items_before): + attachments.append([]) + for attachment in self._message_json["attachments"]: # Attachments should be rendered roughly like: # @@ -1088,16 +1106,16 @@ class SlackMessage: if ( attachment.get("is_app_unfurl") - and shared.config.look.display_link_previews + and not shared.config.look.display_link_previews.value ): continue - items: List[Union[str, PendingMessageItem]] = [] + lines: List[List[Union[str, PendingMessageItem]]] = [] prepend_title_text = "" if "author_name" in attachment: prepend_title_text = attachment["author_name"] + ": " if "pretext" in attachment: - items.append(attachment["pretext"]) + lines.append([attachment["pretext"]]) link_shown = False title = attachment.get("title") title_link = attachment.get("title_link", "") @@ -1107,10 +1125,12 @@ class SlackMessage: title_link = "" link_shown = True if title and title_link: - items.append(f"{prepend_title_text}{title} ({title_link})") + lines.append( + [f"{prepend_title_text}{title} ({htmlescape(title_link)})"] + ) prepend_title_text = "" elif title and not title_link: - items.append(f"{prepend_title_text}{title}") + lines.append([f"{prepend_title_text}{title}"]) prepend_title_text = "" from_url = attachment.get("from_url", "") if ( @@ -1119,20 +1139,16 @@ class SlackMessage: ) and from_url != title_link ): - items.append(from_url) + lines.append([htmlescape(from_url)]) elif from_url: link_shown = True atext = attachment.get("text") if atext: tx = re.sub(r" *\n[\n ]+", "\n", atext) - items.append(prepend_title_text + tx) + lines.append([prepend_title_text + tx]) prepend_title_text = "" - # TODO: Don't render both text and blocks - blocks_items = self._render_blocks(attachment.get("blocks", [])) - items.extend(blocks_items) - image_url = attachment.get("image_url", "") if ( not any( @@ -1141,30 +1157,41 @@ class SlackMessage: and image_url != from_url and image_url != title_link ): - items.append(image_url) + lines.append([htmlescape(image_url)]) elif image_url: link_shown = True for field in attachment.get("fields", []): if field.get("title"): - items.append(f"{field['title']}: {field['value']}") + lines.append([f"{field['title']}: {field['value']}"]) else: - items.append(field["value"]) + lines.append([field["value"]]) + + lines = [ + [item for item in self._unfurl_and_unescape(line)] for line in lines + ] files = self._render_files(attachment.get("files", [])) if files: - items.append(files) + lines.append([files]) - if attachment.get("is_msg_unfurl"): + # TODO: Don't render both text and blocks + blocks_items = self._render_blocks(attachment.get("blocks", [])) + if blocks_items: + lines.append(blocks_items) + + if "is_msg_unfurl" in attachment and attachment["is_msg_unfurl"]: channel_name = PendingMessageItem( - self, "conversation", self.conversation.id + self, "conversation", attachment["channel_id"], "chat" ) if attachment.get("is_reply_unfurl"): footer = ["From a thread in ", channel_name] else: footer = ["Posted in ", channel_name] elif attachment.get("footer"): - footer = [attachment.get("footer")] + footer: List[Union[str, PendingMessageItem]] = [ + attachment.get("footer") + ] else: footer = [] @@ -1181,20 +1208,23 @@ class SlackMessage: if date.today() - date.fromtimestamp(ts_int) <= timedelta(days=1): time_string = " at {time}" timestamp_formatted = format_date( - ts_int, "date_short_pretty" + time_string + ts_int, "{date_short_pretty}" + time_string ) footer.append(f" | {timestamp_formatted.capitalize()}") - items.extend(footer) + + lines.append([item for item in self._unfurl_and_unescape(footer)]) fallback = attachment.get("fallback") - if items == [] and fallback and not link_shown: - items.append(fallback) + if not any(lines) and fallback and not link_shown: + lines.append([fallback]) + + items = [item for items in intersperse(lines, ["\n"]) for item in items] texts_separate_newlines = [ item_separate_newline for item in items for item_separate_newline in ( - intersperse(item.strip().split("\n"), "\n") + intersperse(item.split("\n"), "\n") if isinstance(item, str) else [item] ) @@ -1221,10 +1251,12 @@ class SlackMessage: for item in texts_separate_newlines ] + attachment_texts: List[Union[str, PendingMessageItem]] = [] if line_color: - attachments_texts.append(weechat.color(line_color)) - attachments_texts.extend(texts_with_prefix) + attachment_texts.append(weechat.color(line_color)) + attachment_texts.extend(texts_with_prefix) if line_color: - attachments_texts.append(weechat.color("reset")) + attachment_texts.append(weechat.color("reset")) + attachments.append(attachment_texts) - return attachments_texts + return [item for items in intersperse(attachments, ["\n"]) for item in items] diff --git a/tests/conftest.py b/tests/conftest.py index 33e44de..d1cf16a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import importlib import importlib.machinery import json import sys -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Dict, Union import pytest @@ -49,6 +49,10 @@ from slack.slack_user import SlackUser from slack.slack_workspace import SlackWorkspace from slack.task import Future +config_values: Dict[str, str] = { + "replace_space_in_nicks_with": "_", +} + def config_new_option( config_file: str, @@ -69,15 +73,30 @@ def config_new_option( callback_delete: str, callback_delete_data: str, ) -> str: + if name not in config_values and default_value is not None: + config_values[name] = default_value return name +def config_option_set(option: str, value: str, run_callback: int) -> int: + # TODO: special values + old_value = config_values.get(option) + if value == old_value: + return weechat.WEECHAT_CONFIG_OPTION_SET_OK_SAME_VALUE + config_values[option] = value + return weechat.WEECHAT_CONFIG_OPTION_SET_OK_CHANGED + + +def config_boolean(option: str) -> int: + return config_values.get(option) == "True" + + def config_integer(option: str) -> int: - return 1 + return int(config_values.get(option, 0)) def config_string(option: str) -> str: - return "_" + return config_values.get(option, "") def config_color(option: str) -> str: @@ -88,11 +107,23 @@ def color(option: str) -> str: return f"<[color:{option}]>" +def info_get(info_name: str, arguments: str): + if info_name == "color_rgb2term": + return arguments + elif info_name == "weechat_data_dir": + return "." + else: + return "" + + weechat.config_new_option = config_new_option +weechat.config_option_set = config_option_set +weechat.config_boolean = config_boolean weechat.config_integer = config_integer weechat.config_string = config_string weechat.config_color = config_color weechat.color = color +weechat.info_get = info_get shared.weechat_version = 0x03080000 shared.weechat_callbacks = {} @@ -103,6 +134,8 @@ color_user_mention = "<[color:<[config_color:user_mention]>]>" color_usergroup_mention = "<[color:<[config_color:usergroup_mention]>]>" color_reset = "<[color:reset]>" +workspace_id = "T0FC8BFQR" + with open("mock_data/slack_users_info_person.json") as f: user_test1_info_response: SlackUserInfoSuccessResponse[SlackUserInfo] = json.loads( f.read() @@ -122,7 +155,7 @@ with open("mock_data/slack_conversations_info_channel_public.json") as f: def workspace(): shared.config = SlackConfig() w = SlackWorkspace("workspace_name") - w.id = "T0FC8BFQR" + w.id = workspace_id user_test1 = SlackUser(w, user_test1_info) user_test1_future = Future[SlackUser]() diff --git a/tests/test_render_attachments.py b/tests/test_render_attachments.py new file mode 100644 index 0000000..4a23b5a --- /dev/null +++ b/tests/test_render_attachments.py @@ -0,0 +1,642 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List + +import pytest + +from slack.shared import shared +from slack.slack_message import SlackMessage +from tests.conftest import ( + channel_public_id, + color_reset, + color_user_mention, + resolve_pending_message_item, + user_test1_id, + workspace_id, +) + +if TYPE_CHECKING: + from typing_extensions import NotRequired, TypedDict +else: + TypedDict = object + + +class Case(TypedDict): + input_message: Any + input_text_before: str + output: str + link_previews: NotRequired[bool] + + +cases: List[Case] = [ + { + "input_message": { + "attachments": [ + { + "title": "Title", + } + ] + }, + "input_text_before": "", + "output": "| Title", + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + } + ] + }, + "input_text_before": "Text before", + "output": "\n".join( + [ + "", + "| Title", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title1", + }, + { + "title": "Title2", + }, + ] + }, + "input_text_before": "Text before", + "output": "\n".join( + [ + "", + "| Title1", + "| Title2", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "text": "Attachment text", + "title_link": "http://title.link", + "from_url": "http://from.url", + "fallback": "Fallback", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Title (http://title.link)", + "| http://from.url", + "| Attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "text": "Attachment text", + "title_link": "http://title.link", + "image_url": "http://image.url", + "fallback": "Fallback", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Title (http://title.link)", + "| Attachment text", + "| http://image.url", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "text": "Attachment text", + "title_link": "http://link1", + "from_url": "http://link2", + "image_url": "http://link3", + } + ] + }, + "input_text_before": "http://link1 http://link2 http://link3", + "output": "\n".join( + [ + "", + "| Title", + "| Attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "text": "Attachment text", + "title_link": "http://link", + "from_url": "http://link", + "image_url": "http://link", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Title (http://link)", + "| Attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "text": "Attachment text", + "from_url": "http://link", + "image_url": "http://link", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Title", + "| http://link", + "| Attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "text": "Attachment text\n\n\nWith multiple lines", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Title", + "| Attachment text", + "| With multiple lines", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "author_name": "Author", + "pretext": "Pretext", + "text": "Attachment text", + "title_link": "http://title.link", + "from_url": "http://from.url", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Pretext", + "| Author: Title (http://title.link)", + "| http://from.url", + "| Attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "author_name": "Author", + "text": "Attachment text", + "title_link": "http://title.link", + "from_url": "http://from.url", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| http://from.url", + "| Author: Attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "fallback": "Fallback", + } + ] + }, + "input_text_before": "", + "output": "| Fallback", + }, + { + "input_message": { + "attachments": [ + { + "fallback": "Fallback", + "title_link": "http://link", + } + ] + }, + "input_text_before": "http://link", + "output": "", + }, + { + "input_message": { + "attachments": [ + { + "fallback": "Fallback", + "from_url": "http://link", + } + ] + }, + "input_text_before": "http://link", + "output": "", + }, + { + "input_message": { + "attachments": [ + { + "fallback": "Fallback", + "image_url": "http://link", + } + ] + }, + "input_text_before": "http://link", + "output": "", + }, + { + "input_message": { + "attachments": [{"text": "Some message", "footer": "Thread in #general"}] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Some message", + "| Thread in #general", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "ts": 1584986782, + "text": "Some message", + "footer": "Thread in #general", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Some message", + "| Thread in #general | Mar 23, 2020", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "ts": "1584986782.261400", + "text": "Some message", + "footer": "Thread in #general", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Some message", + "| Thread in #general | Mar 23, 2020", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "text": "Original message", + "files": [ + { + "title": "File", + "url_private": "http://link", + } + ], + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Original message", + "| http://link (File)", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "fields": [ + { + "title": "First field title", + "value": "First field value", + }, + { + "title": "", + "value": "Second field value", + }, + ], + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| Title", + "| First field title: First field value", + "| Second field value", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "First attachment title", + "text": "First attachment text", + "title_link": "http://title.link.1", + "from_url": "http://from.url.1", + }, + { + "title": "Second attachment title", + "text": "Second attachment text", + "title_link": "http://title.link.2", + "from_url": "http://from.url.2", + }, + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| First attachment title (http://title.link.1)", + "| http://from.url.1", + "| First attachment text", + "| Second attachment title (http://title.link.2)", + "| http://from.url.2", + "| Second attachment text", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "color": "ff0000", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "<[color:16711680]>|<[color:reset]> Title", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "title": "Title", + "color": "#ff0000", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "<[color:16711680]>|<[color:reset]> Title", + ] + ), + }, + { + "input_message": { + "attachments": [{"text": "Attachment text", "is_app_unfurl": True}] + }, + "input_text_before": "", + "output": "| Attachment text", + "link_previews": True, + }, + { + "input_message": {"attachments": [{"text": "Attachment text"}]}, + "input_text_before": "", + "output": "| Attachment text", + "link_previews": False, + }, + { + "input_message": { + "attachments": [{"text": "Attachment text", "is_app_unfurl": True}] + }, + "input_text_before": "", + "output": "", + "link_previews": False, + }, + { + "input_message": { + "attachments": [ + { + "id": 1, + "ts": 1697480778, + "fallback": "title & <asd>", + "text": "text & <asd>", + "pretext": "pretext & <asd>", + "title": "title & <asd>", + "title_link": "https://title.link/?x=<x>&z=z", + "author_name": "author_name & <asd>", + "from_url": "https://from.url/?x=<x>&z=z", + "image_url": "https://image.url/?x=<x>&z=z", + "footer": "footer & <asd>", + "fields": [ + { + "value": "field value & <asd>", + "title": "field title & <asd>", + "short": False, + }, + { + "value": f"field value mention <@{user_test1_id}>", + "title": f"field title mention <@{user_test1_id}>", + "short": False, + }, + ], + }, + { + "id": 2, + "blocks": [ + { + "type": "rich_text", + "block_id": "IQm+Q", + "elements": [ + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "block rich_text_preformatted & <asd>", + } + ], + } + ], + }, + { + "type": "rich_text", + "block_id": "a5bVo", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "block rich_text_section & <asd> ", + }, + { + "type": "link", + "url": "https://block.link?x=<x>&z=z", + "style": {"code": True}, + }, + ], + } + ], + }, + { + "type": "rich_text", + "block_id": "FeChA", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": user_test1_id}, + {"type": "text", "text": ": <@ASD>"}, + ], + } + ], + }, + ], + "fallback": "[no preview available]", + }, + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| pretext & <asd>", + "| author_name & <asd>: title & <asd> (https://title.link/?x=<x>&z=z)", + "| https://from.url/?x=<x>&z=z", + "| text & <asd>", + "| https://image.url/?x=<x>&z=z", + "| field title & <asd>: field value & <asd>", + f"| field title mention <@{user_test1_id}>: field value mention {color_user_mention}@Test_1{color_reset}", + "| footer & <asd> | Oct 16, 2023", + "| ```", + "| block rich_text_preformatted & <asd>", + "| ```", + "| block rich_text_section & <asd> `https://block.link?x=<x>&z=z`", + f"| {color_user_mention}@Test_1{color_reset}: <@ASD>", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "from_url": "https://from.url", + "ts": "1697393234.859799", + "author_id": user_test1_id, + "channel_id": channel_public_id, + "channel_team": workspace_id, + "is_msg_unfurl": True, + "id": 1, + "fallback": "[October 15th, 2023 11:07 AM] username: fallback text", + "text": "text", + "author_name": "Author name", + "author_link": f"https://wee-slack-test.slack.com/team/{user_test1_id}", + "mrkdwn_in": ["text"], + "footer": "Slack Conversation", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| https://from.url", + "| Author name: text", + "| Posted in <[color:chat_channel]>#channel1<[color:reset]> | Oct 15, 2023", + ] + ), + }, + { + "input_message": { + "attachments": [ + { + "from_url": "https://from.url", + "ts": "1697393234.859799", + "author_id": user_test1_id, + "channel_id": channel_public_id, + "channel_team": workspace_id, + "is_msg_unfurl": True, + "is_reply_unfurl": True, + "id": 1, + "fallback": "[October 15th, 2023 11:07 AM] username: fallback text", + "text": "text", + "author_name": "Author name", + "author_link": f"https://wee-slack-test.slack.com/team/{user_test1_id}", + "mrkdwn_in": ["text"], + "footer": "Thread in Slack Conversation", + } + ] + }, + "input_text_before": "", + "output": "\n".join( + [ + "| https://from.url", + "| Author name: text", + "| From a thread in <[color:chat_channel]>#channel1<[color:reset]> | Oct 15, 2023", + ] + ), + }, +] + + +@pytest.mark.parametrize("case", cases) +def test_render_attachments(case: Case, message1_in_channel_public: SlackMessage): + shared.config.look.display_link_previews.value = case.get("link_previews", True) + message1_in_channel_public.update_message_json(case["input_message"]) + parsed = message1_in_channel_public._render_attachments( # pyright: ignore [reportPrivateUsage] + [case["input_text_before"]] + ) + resolved = "".join(resolve_pending_message_item(item) for item in parsed) + assert resolved == case["output"] diff --git a/tests/test_render_blocks.py b/tests/test_render_blocks.py index fedbab6..cd5442e 100644 --- a/tests/test_render_blocks.py +++ b/tests/test_render_blocks.py @@ -138,7 +138,7 @@ cases: List[Case] = [ "block code", "more code", "```", - f"{color_user_mention}@Test_1{color_reset}: :open_mouth:", + f"{color_user_mention}@Test_1{color_reset}: 😮", ], }, { diff --git a/typings/slack_api/slack_conversations_history.pyi b/typings/slack_api/slack_conversations_history.pyi index 6799f02..4b3702a 100644 --- a/typings/slack_api/slack_conversations_history.pyi +++ b/typings/slack_api/slack_conversations_history.pyi @@ -224,7 +224,7 @@ SlackMessageBlock = ( ) @final -class SlackMessageAttachment(TypedDict): +class SlackMessageAttachmentStandard(TypedDict): from_url: str image_url: str image_width: int @@ -241,6 +241,17 @@ class SlackMessageAttachment(TypedDict): footer: str @final +class SlackMessageAttachmentMsgUnfurl(TypedDict): + is_msg_unfurl: Literal[True] + channel_id: str + footer: str + # incomplete + +SlackMessageAttachment = ( + SlackMessageAttachmentStandard | SlackMessageAttachmentMsgUnfurl +) + +@final class SlackMessageReaction(TypedDict): name: str users: List[str] |