diff --git a/elementary/messages/block_builders.py b/elementary/messages/block_builders.py index c5207b2d1..5a8ab5cac 100644 --- a/elementary/messages/block_builders.py +++ b/elementary/messages/block_builders.py @@ -14,6 +14,7 @@ MentionBlock, TextBlock, TextStyle, + WhitespaceBlock, ) SimpleInlineBlock = Union[str, Icon] @@ -38,12 +39,23 @@ def BulletListBlock( *, icon: Union[Icon, str], lines: List[LineBlock] = [], + indent: int = 0, ) -> LinesBlock: + whitespaces = [WhitespaceBlock()] * indent icon_inline: InlineBlock = ( IconBlock(icon=icon) if isinstance(icon, Icon) else TextBlock(text=icon) ) lines = [ - LineBlock(inlines=[icon_inline, *line.inlines], sep=line.sep) for line in lines + LineBlock( + inlines=[ + *whitespaces, + icon_inline, + TextBlock(text=" "), + line, + ], + sep="", + ) + for line in lines ] return LinesBlock(lines=lines) diff --git a/elementary/messages/blocks.py b/elementary/messages/blocks.py index 0ebe22e97..6fcaa5d42 100644 --- a/elementary/messages/blocks.py +++ b/elementary/messages/blocks.py @@ -18,6 +18,7 @@ class Icon(Enum): EYE = "eye" GEAR = "gear" BELL = "bell" + GEM = "gem" class TextStyle(Enum): @@ -67,12 +68,47 @@ class LineBlock(BaseBlock): sep: str = " " +class WhitespaceBlock(BaseBlock): + type: Literal["whitespace"] = "whitespace" + + +class ActionBlock(BaseBlock): + type: Literal["action"] = "action" + action: str + action_id: str + + +class DropdownOptionBlock(BaseBlock): + type: Literal["dropdown_option"] = "dropdown_option" + text: str + value: str + + +class DropdownActionBlock(ActionBlock): + action: Literal["dropdown"] = "dropdown" + options: List[DropdownOptionBlock] + placeholder: Optional[str] = None + initial_option: Optional[DropdownOptionBlock] = None + + +class UserSelectActionBlock(ActionBlock): + action: Literal["users_select"] = "users_select" + placeholder: Optional[str] = None + initial_user: Optional[str] = None + + +class ActionsBlock(BaseBlock): + type: Literal["actions"] = "actions" + actions: Sequence[ActionBlock] + + InlineBlock = Union[ TextBlock, LinkBlock, IconBlock, InlineCodeBlock, MentionBlock, + WhitespaceBlock, "LineBlock", ] @@ -142,6 +178,7 @@ class ExpandableBlock(BaseBlock): LinesBlock, FactListBlock, TableBlock, + ActionsBlock, "ExpandableBlock", ] diff --git a/elementary/messages/formats/adaptive_cards.py b/elementary/messages/formats/adaptive_cards.py index 22f6b5dfe..df14c97b7 100644 --- a/elementary/messages/formats/adaptive_cards.py +++ b/elementary/messages/formats/adaptive_cards.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional from elementary.messages.blocks import ( + ActionsBlock, CodeBlock, DividerBlock, ExpandableBlock, @@ -18,6 +19,7 @@ TableBlock, TextBlock, TextStyle, + WhitespaceBlock, ) from elementary.messages.formats.html import ICON_TO_HTML from elementary.messages.message_body import Color, MessageBlock, MessageBody @@ -55,6 +57,8 @@ def format_inline_block(block: InlineBlock) -> str: return block.user elif isinstance(block, LineBlock): return format_line_block_text(block) + elif isinstance(block, WhitespaceBlock): + return " " else: raise ValueError(f"Unsupported inline block type: {type(block)}") @@ -167,6 +171,10 @@ def format_message_block( return format_expandable_block(block) elif isinstance(block, TableBlock): return [format_table_block(block)] + elif isinstance(block, ActionsBlock): + # Not supported in webhooks, so we don't need to format it. + # When we add support for teams apps, we will need to format it. + return [] else: raise ValueError(f"Unsupported message block type: {type(block)}") diff --git a/elementary/messages/formats/block_kit.py b/elementary/messages/formats/block_kit.py index 25fd77972..afa58175d 100644 --- a/elementary/messages/formats/block_kit.py +++ b/elementary/messages/formats/block_kit.py @@ -6,8 +6,11 @@ from tabulate import tabulate from elementary.messages.blocks import ( + ActionBlock, + ActionsBlock, CodeBlock, DividerBlock, + DropdownActionBlock, ExpandableBlock, FactBlock, FactListBlock, @@ -23,6 +26,8 @@ TableBlock, TextBlock, TextStyle, + UserSelectActionBlock, + WhitespaceBlock, ) from elementary.messages.formats.html import ICON_TO_HTML from elementary.messages.message_body import Color, MessageBlock, MessageBody @@ -83,6 +88,8 @@ def _format_inline_block(self, block: InlineBlock) -> str: return block.user elif isinstance(block, LineBlock): return self._format_line_block_text(block) + elif isinstance(block, WhitespaceBlock): + return " " else: raise ValueError(f"Unsupported inline block type: {type(block)}") @@ -120,6 +127,60 @@ def _format_markdown_section(self, text: str) -> dict: "text": self._format_markdown_section_text(text), } + def _format_action_block(self, block: ActionBlock) -> dict: + if isinstance(block, DropdownActionBlock): + return self._format_dropdown_action_block(block) + elif isinstance(block, UserSelectActionBlock): + return self._format_user_select_action_block(block) + else: + raise ValueError(f"Unsupported action block type: {type(block)}") + + def _format_dropdown_action_block(self, block: DropdownActionBlock) -> dict: + formatted_block = { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": block.placeholder, + "emoji": True, + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": option.text, + "emoji": True, + }, + "value": option.value, + } + for option in block.options + ], + } + if block.initial_option: + formatted_block["initial_option"] = { + "text": { + "type": "plain_text", + "text": block.initial_option.text, + "emoji": True, + }, + "value": block.initial_option.value, + } + return formatted_block + + def _format_user_select_action_block(self, block: UserSelectActionBlock) -> dict: + formatted_block = { + "type": "users_select", + "placeholder": { + "type": "plain_text", + "text": block.placeholder, + "emoji": True, + }, + } + if block.initial_user: + resolved_user = self._resolve_mention(block.initial_user) + if resolved_user: + formatted_block["initial_user"] = resolved_user + return formatted_block + def _add_block(self, block: dict) -> None: if not self._is_divided: self._blocks.append(block) @@ -211,6 +272,16 @@ def _add_table_block(self, block: TableBlock) -> None: table_text = tabulate(new_rows, headers=new_headers, tablefmt="simple") self._add_block(self._format_markdown_section(f"```{table_text}```")) + def _add_actions_block(self, block: ActionsBlock) -> None: + self._add_block( + { + "type": "actions", + "elements": [ + self._format_action_block(action) for action in block.actions + ], + } + ) + def _add_expandable_block(self, block: ExpandableBlock) -> None: """ Expandable blocks are not supported in Slack Block Kit. @@ -233,6 +304,8 @@ def _add_message_block(self, block: MessageBlock) -> None: self._add_expandable_block(block) elif isinstance(block, TableBlock): self._add_table_block(block) + elif isinstance(block, ActionsBlock): + self._add_actions_block(block) else: raise ValueError(f"Unsupported message block type: {type(block)}") @@ -263,6 +336,9 @@ def build(self, message: MessageBody) -> FormattedBlockKitMessage: self._add_message_blocks(message.blocks) color_code = COLOR_MAP.get(message.color) if message.color else None blocks, attachment_blocks = self._get_final_blocks(message.color) + if message.id and blocks: + # The only place in a slack message where we can set a custom id is in blocks, so we set the id of the first block + blocks[0]["block_id"] = message.id built_message = FormattedBlockKitMessage( blocks=blocks, attachments=[ diff --git a/elementary/messages/formats/html.py b/elementary/messages/formats/html.py index a9ac0674b..55baec6ca 100644 --- a/elementary/messages/formats/html.py +++ b/elementary/messages/formats/html.py @@ -13,6 +13,7 @@ Icon.EYE: "👁️", Icon.GEAR: "⚙️", Icon.BELL: "🔔", + Icon.GEM: "💎", } for icon in Icon: diff --git a/elementary/messages/message_body.py b/elementary/messages/message_body.py index 69cccde70..e9675c868 100644 --- a/elementary/messages/message_body.py +++ b/elementary/messages/message_body.py @@ -4,6 +4,7 @@ from pydantic import BaseModel from elementary.messages.blocks import ( + ActionsBlock, CodeBlock, DividerBlock, ExpandableBlock, @@ -26,14 +27,16 @@ class Color(Enum): DividerBlock, LinesBlock, FactListBlock, - ExpandableBlock, TableBlock, + ActionsBlock, + ExpandableBlock, ] class MessageBody(BaseModel): blocks: List[MessageBlock] color: Optional[Color] = None + id: Optional[str] = None MessageBody.update_forward_refs() diff --git a/elementary/messages/messaging_integrations/README.md b/elementary/messages/messaging_integrations/README.md index 196038da7..1b769264b 100644 --- a/elementary/messages/messaging_integrations/README.md +++ b/elementary/messages/messaging_integrations/README.md @@ -49,6 +49,7 @@ If your platform's message format is not yet supported: - ExpandableBlock: Collapsible sections - MentionBlock: Mention a user - TableBlock: Table of data + - WhitespaceBlock: Whitespace for indentation ``` 3. Add tests in `tests/unit/messages/formats/` diff --git a/elementary/messages/messaging_integrations/base_messaging_integration.py b/elementary/messages/messaging_integrations/base_messaging_integration.py index 5bfb6f87c..43409a3bb 100644 --- a/elementary/messages/messaging_integrations/base_messaging_integration.py +++ b/elementary/messages/messaging_integrations/base_messaging_integration.py @@ -38,6 +38,9 @@ def send_message( def supports_reply(self) -> bool: raise NotImplementedError + def supports_actions(self) -> bool: + return False + def reply_to_message( self, destination: DestinationType, diff --git a/elementary/messages/messaging_integrations/slack_web.py b/elementary/messages/messaging_integrations/slack_web.py index 23dfb7433..b3496e3bb 100644 --- a/elementary/messages/messaging_integrations/slack_web.py +++ b/elementary/messages/messaging_integrations/slack_web.py @@ -56,6 +56,9 @@ def from_token( def supports_reply(self) -> bool: return True + def supports_actions(self) -> bool: + return True + def send_message( self, destination: Channel, body: MessageBody ) -> MessageSendResult[SlackWebMessageContext]: diff --git a/tests/unit/messages/formats/adaptive_cards/fixtures/all_icons.json b/tests/unit/messages/formats/adaptive_cards/fixtures/all_icons.json index 38e3d6917..3591f23fc 100644 --- a/tests/unit/messages/formats/adaptive_cards/fixtures/all_icons.json +++ b/tests/unit/messages/formats/adaptive_cards/fixtures/all_icons.json @@ -7,7 +7,7 @@ "items": [ { "type": "TextBlock", - "text": "RED_TRIANGLE \ud83d\udd3a X \u274c WARNING \u26a0\ufe0f EXCLAMATION \u2757 CHECK \u2705 MAGNIFYING_GLASS \ud83d\udd0e HAMMER_AND_WRENCH \ud83d\udee0\ufe0f POLICE_LIGHT \ud83d\udea8 INFO \u2139\ufe0f EYE \ud83d\udc41\ufe0f GEAR \u2699\ufe0f BELL \ud83d\udd14", + "text": "RED_TRIANGLE \ud83d\udd3a X \u274c WARNING \u26a0\ufe0f EXCLAMATION \u2757 CHECK \u2705 MAGNIFYING_GLASS \ud83d\udd0e HAMMER_AND_WRENCH \ud83d\udee0\ufe0f POLICE_LIGHT \ud83d\udea8 INFO \u2139\ufe0f EYE \ud83d\udc41\ufe0f GEAR \u2699\ufe0f BELL \ud83d\udd14 GEM \ud83d\udc8e", "wrap": true } ] diff --git a/tests/unit/messages/formats/adaptive_cards/fixtures/whitespace_block.json b/tests/unit/messages/formats/adaptive_cards/fixtures/whitespace_block.json new file mode 100644 index 000000000..1152cfdb2 --- /dev/null +++ b/tests/unit/messages/formats/adaptive_cards/fixtures/whitespace_block.json @@ -0,0 +1,22 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "Container", + "separator": true, + "items": [ + { + "type": "TextBlock", + "text": "This should not be indented", + "wrap": true + }, + { + "type": "TextBlock", + "text": "  This should be indented", + "wrap": true + } + ] + } + ], + "version": "1.5" +} diff --git a/tests/unit/messages/formats/base_test_format.py b/tests/unit/messages/formats/base_test_format.py index cf18d61e1..0cc3ee63d 100644 --- a/tests/unit/messages/formats/base_test_format.py +++ b/tests/unit/messages/formats/base_test_format.py @@ -22,6 +22,7 @@ TableBlock, TextBlock, TextStyle, + WhitespaceBlock, ) from elementary.messages.message_body import Color, MessageBody @@ -378,3 +379,25 @@ def test_format_table_block(self, text_length: int, column_count: int): ) result = self.format(message_body) self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_whitespace_block(self): + message_body = MessageBody( + blocks=[ + LinesBlock( + lines=[ + LineBlock( + inlines=[TextBlock(text="This should not be indented")] + ), + LineBlock( + inlines=[ + WhitespaceBlock(), + TextBlock(text="This should be indented"), + ] + ), + ] + ) + ] + ) + expected_file_path = self.get_expected_file_path("whitespace_block") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) diff --git a/tests/unit/messages/formats/block_kit/fixtures/all_icons.json b/tests/unit/messages/formats/block_kit/fixtures/all_icons.json index c2b8a8941..cfb43e8bc 100644 --- a/tests/unit/messages/formats/block_kit/fixtures/all_icons.json +++ b/tests/unit/messages/formats/block_kit/fixtures/all_icons.json @@ -4,7 +4,7 @@ "type": "section", "text": { "type": "mrkdwn", - "text": "RED_TRIANGLE \ud83d\udd3a X \u274c WARNING \u26a0\ufe0f EXCLAMATION \u2757 CHECK \u2705 MAGNIFYING_GLASS \ud83d\udd0e HAMMER_AND_WRENCH \ud83d\udee0\ufe0f POLICE_LIGHT \ud83d\udea8 INFO \u2139\ufe0f EYE \ud83d\udc41\ufe0f GEAR \u2699\ufe0f BELL \ud83d\udd14" + "text": "RED_TRIANGLE \ud83d\udd3a X \u274c WARNING \u26a0\ufe0f EXCLAMATION \u2757 CHECK \u2705 MAGNIFYING_GLASS \ud83d\udd0e HAMMER_AND_WRENCH \ud83d\udee0\ufe0f POLICE_LIGHT \ud83d\udea8 INFO \u2139\ufe0f EYE \ud83d\udc41\ufe0f GEAR \u2699\ufe0f BELL \ud83d\udd14 GEM \ud83d\udc8e" } } ], diff --git a/tests/unit/messages/formats/block_kit/fixtures/whitespace_block.json b/tests/unit/messages/formats/block_kit/fixtures/whitespace_block.json new file mode 100644 index 000000000..d6ef668d5 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/whitespace_block.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This should not be indented\n This should be indented" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +}