├── test ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_errors.py │ ├── utils.py │ ├── test_attachments.py │ ├── test_views.py │ ├── test_utils.py │ ├── test_messages.py │ ├── test_objects.py │ ├── test_rich_text.py │ ├── test_blocks.py │ └── test_elements.py ├── integration │ ├── __init__.py │ └── test_integration.py └── samples │ ├── objects │ ├── input_parameter_basic.json │ ├── text_markdown_basic.json │ ├── text_plaintext_basic.json │ ├── text_markdown_verbatim.json │ ├── text_plaintext_emoji.json │ ├── dispatch_action_configuration_basic.json │ ├── conversation_filter_basic.json │ ├── option_basic.json │ ├── trigger_basic.json │ ├── workflow_basic.json │ ├── confirmation_dialogue_basic.json │ └── option_group_basic.json │ ├── rich_text │ ├── rich_text_emoji_basic.json │ ├── rich_text_basic.json │ ├── rich_text_section_basic.json │ ├── rich_text_quote_basic.json │ ├── rich_text_code_block_basic.json │ ├── rich_text_link_basic.json │ ├── rich_text_user_basic.json │ ├── rich_text_channel_basic.json │ ├── rich_text_user_group_basic.json │ ├── rich_text_list_ordered.json │ └── rich_text_list_basic.json │ ├── blocks │ ├── divider_block_only.json │ ├── file_block_only.json │ ├── header_block_only.json │ ├── section_block_text_only.json │ ├── context_block_text_only.json │ ├── section_block_single_field_value_coercion.json │ ├── image_block_only.json │ ├── section_block_empty_text_field_value.json │ ├── section_block_fields.json │ ├── section_block_both_text_and_fields.json │ ├── input_block_only.json │ ├── rich_text_block_basic.json │ ├── actions_block_checkboxes.json │ └── table_block.json │ ├── elements │ ├── url_input_basic.json │ ├── number_input_basic.json │ ├── image_basic.json │ ├── datetime_picker_basic.json │ ├── button_basic.json │ ├── button_link.json │ ├── select_menu_user.json │ ├── email_input_basic.json │ ├── select_menu_channel.json │ ├── multi_select_channel.json │ ├── multi_select_user.json │ ├── plaintext_input_basic.json │ ├── date_picker_basic.json │ ├── select_menu_conversation.json │ ├── button_style.json │ ├── multi_select_conversation.json │ ├── select_menu_external.json │ ├── multi_select_external.json │ ├── rich_text_input_basic.json │ ├── timepicker_basic.json │ ├── multi_select_user_with_initial_users.json │ ├── multi_select_static.json │ ├── overflow_menu_basic.json │ ├── workflow_button_basic.json │ ├── checkbox_basic.json │ ├── select_menu_static.json │ └── radio_button_group_basic.json │ ├── views │ ├── hometab_view.json │ └── modal_with_blocks.json │ ├── messages │ ├── message_basic.json │ ├── message_response.json │ ├── message_with_optional_arguments.json │ ├── message_basic_attachment.json │ ├── message_with_attachments.json │ ├── webhook_message_basic.json │ ├── webhook_message_delete.json │ └── message_compound.json │ └── attachments │ ├── attachment_simple.json │ └── attachment_multi_block.json ├── docs_src ├── reference │ ├── utils.md │ ├── modals.md │ ├── views.md │ ├── blocks.md │ ├── objects.md │ ├── elements.md │ ├── messages.md │ ├── rich_text.md │ └── attachments.md ├── img │ ├── sb.png │ ├── hello_world.png │ └── usage │ │ ├── image.png │ │ ├── input.png │ │ ├── table.png │ │ ├── actions.png │ │ ├── context.png │ │ ├── divider.png │ │ ├── header.png │ │ ├── section.png │ │ └── rich_text.png ├── usage │ ├── installation.md │ ├── sending_messages.md │ └── using_blocks.md └── index.md ├── .github └── workflows │ ├── publish.yml │ ├── docs.yml │ ├── formatting.yml │ ├── linting.yml │ ├── type-checking.yml │ └── unit-tests.yml ├── slackblocks ├── errors.py ├── rich_text │ ├── __init__.py │ ├── objects.py │ └── elements.py ├── modals.py ├── __init__.py ├── views.py ├── attachments.py ├── utils.py ├── messages.py └── blocks.py ├── LICENSE ├── LICENSE.BSD-3-Clause ├── .gitignore ├── mkdocs.yml ├── pyproject.toml └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_src/reference/utils.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | ::: utils -------------------------------------------------------------------------------- /docs_src/reference/modals.md: -------------------------------------------------------------------------------- 1 | # Modals 2 | 3 | ::: modals 4 | options: 5 | show_bases: false -------------------------------------------------------------------------------- /docs_src/img/sb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/sb.png -------------------------------------------------------------------------------- /test/samples/objects/input_parameter_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "name", 3 | "value": "value" 4 | } -------------------------------------------------------------------------------- /test/samples/objects/text_markdown_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "mrkdwn", 3 | "text": "hi" 4 | } -------------------------------------------------------------------------------- /test/samples/objects/text_plaintext_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "plain_text", 3 | "text": "hi" 4 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_emoji_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "emoji", 3 | "name": "wave" 4 | } -------------------------------------------------------------------------------- /test/samples/blocks/divider_block_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "divider", 3 | "block_id": "fake_block_id" 4 | } -------------------------------------------------------------------------------- /docs_src/img/hello_world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/hello_world.png -------------------------------------------------------------------------------- /docs_src/img/usage/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/image.png -------------------------------------------------------------------------------- /docs_src/img/usage/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/input.png -------------------------------------------------------------------------------- /docs_src/img/usage/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/table.png -------------------------------------------------------------------------------- /docs_src/img/usage/actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/actions.png -------------------------------------------------------------------------------- /docs_src/img/usage/context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/context.png -------------------------------------------------------------------------------- /docs_src/img/usage/divider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/divider.png -------------------------------------------------------------------------------- /docs_src/img/usage/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/header.png -------------------------------------------------------------------------------- /docs_src/img/usage/section.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/section.png -------------------------------------------------------------------------------- /test/samples/elements/url_input_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "url_text_input", 3 | "action_id": "url_text_input" 4 | } -------------------------------------------------------------------------------- /docs_src/img/usage/rich_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklambourne/slackblocks/HEAD/docs_src/img/usage/rich_text.png -------------------------------------------------------------------------------- /test/samples/objects/text_markdown_verbatim.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "mrkdwn", 3 | "text": "hi", 4 | "verbatim": true 5 | } -------------------------------------------------------------------------------- /test/samples/objects/text_plaintext_emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "plain_text", 3 | "text": "hi", 4 | "emoji": true 5 | } -------------------------------------------------------------------------------- /docs_src/reference/views.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | ::: views 4 | options: 5 | filters: ["!^View"] 6 | show_bases: false -------------------------------------------------------------------------------- /docs_src/reference/blocks.md: -------------------------------------------------------------------------------- 1 | # Blocks 2 | 3 | ::: blocks 4 | options: 5 | filters: ["!^Block"] 6 | show_bases: false -------------------------------------------------------------------------------- /test/samples/objects/dispatch_action_configuration_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "trigger_actions_on": [ 3 | "on_character_entered" 4 | ] 5 | } -------------------------------------------------------------------------------- /docs_src/reference/objects.md: -------------------------------------------------------------------------------- 1 | # Objects 2 | 3 | ::: objects 4 | options: 5 | filters: ["!^CompositionObject"] 6 | show_bases: false -------------------------------------------------------------------------------- /test/samples/elements/number_input_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "number_input", 3 | "is_decimal_allowed": false, 4 | "action_id": "number_input" 5 | } -------------------------------------------------------------------------------- /docs_src/reference/elements.md: -------------------------------------------------------------------------------- 1 | # Elements 2 | 3 | ::: elements 4 | options: 5 | filters: ["!^Element", "!^ElementType"] 6 | show_bases: false -------------------------------------------------------------------------------- /test/samples/elements/image_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "image", 3 | "image_url": "https://ndl.im/img/logo.png", 4 | "alt_text": "Logo for ndl.im" 5 | } -------------------------------------------------------------------------------- /test/samples/objects/conversation_filter_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "public", 4 | "mpim" 5 | ], 6 | "exclude_bot_users": true 7 | } -------------------------------------------------------------------------------- /test/samples/objects/option_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": { 3 | "type": "plain_text", 4 | "text": "Canberra" 5 | }, 6 | "value": "canberra" 7 | } -------------------------------------------------------------------------------- /docs_src/reference/messages.md: -------------------------------------------------------------------------------- 1 | # Messages 2 | 3 | ::: messages 4 | options: 5 | filters: ["!^BaseMessage", "!^MessageResponse"] 6 | show_bases: false -------------------------------------------------------------------------------- /test/samples/blocks/file_block_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "file", 3 | "external_id": "external_id", 4 | "source": "remote", 5 | "block_id": "fake_block_id" 6 | } -------------------------------------------------------------------------------- /test/samples/elements/datetime_picker_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "datetimepicker", 3 | "action_id": "datetime_picker", 4 | "initial_date_time": 1628633830 5 | } -------------------------------------------------------------------------------- /test/samples/blocks/header_block_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "header", 3 | "block_id": "fake_block_id", 4 | "text": { 5 | "type": "plain_text", 6 | "text": "AloHa!" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/blocks/section_block_text_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "section", 3 | "block_id": "fake_block_id", 4 | "text": { 5 | "type": "mrkdwn", 6 | "text": "Hello, world!" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/button_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "button", 3 | "text": { 4 | "type": "plain_text", 5 | "text": "Click Me" 6 | }, 7 | "action_id": "button", 8 | "value": "click_me" 9 | } -------------------------------------------------------------------------------- /test/samples/elements/button_link.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "button", 3 | "text": { 4 | "type": "plain_text", 5 | "text": "Link!" 6 | }, 7 | "action_id": "button", 8 | "url": "https://ndl.im/" 9 | } -------------------------------------------------------------------------------- /test/samples/elements/select_menu_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "users_select", 3 | "action_id": "users_select", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Select one user" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/email_input_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "email_text_input", 3 | "action_id": "email_input", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Enter your email" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "text", 3 | "text": "I am a bold rich text block!", 4 | "style": { 5 | "bold": true, 6 | "italic": true, 7 | "strike": false 8 | } 9 | } -------------------------------------------------------------------------------- /test/samples/elements/select_menu_channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "channels_select", 3 | "action_id": "channels_select", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Select a channel" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/multi_select_channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "multi_channels_select", 3 | "action_id": "multi_channels_select", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Select channels" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/multi_select_user.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "multi_users_select", 3 | "action_id": "multi_users_select", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Select one or more users" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/plaintext_input_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "plain_text_input", 3 | "action_id": "plaintext_input", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Enter your plain text" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/date_picker_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "datepicker", 3 | "action_id": "datepicker", 4 | "initial_date": "1970-01-01", 5 | "placeholder": { 6 | "type": "plain_text", 7 | "text": "Pick a date" 8 | } 9 | } -------------------------------------------------------------------------------- /test/samples/elements/select_menu_conversation.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "conversations_select", 3 | "action_id": "conversations_select", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Select one conversation" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/blocks/context_block_text_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "context", 3 | "block_id": "fake_block_id", 4 | "elements": [ 5 | { 6 | "type": "mrkdwn", 7 | "text": "Hello, world!" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /test/samples/elements/button_style.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "button", 3 | "text": { 4 | "type": "plain_text", 5 | "text": "Load" 6 | }, 7 | "action_id": "button", 8 | "style": "primary", 9 | "value": "im_a_style_button" 10 | } -------------------------------------------------------------------------------- /test/samples/elements/multi_select_conversation.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "multi_conversations_select", 3 | "action_id": "multi_conversations_select", 4 | "placeholder": { 5 | "type": "plain_text", 6 | "text": "Select conversations" 7 | } 8 | } -------------------------------------------------------------------------------- /test/samples/elements/select_menu_external.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "external_select", 3 | "action_id": "external_select", 4 | "min_query_length": 4, 5 | "placeholder": { 6 | "type": "plain_text", 7 | "text": "Select one item" 8 | } 9 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_section_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text_section", 3 | "elements": [ 4 | { 5 | "type": "text", 6 | "text": "The only true wisdom is in knowing you know nothing" 7 | } 8 | ] 9 | } -------------------------------------------------------------------------------- /test/samples/blocks/section_block_single_field_value_coercion.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "section", 3 | "block_id": "fake_block_id", 4 | "fields": [ 5 | { 6 | "type": "mrkdwn", 7 | "text": "Lowly" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /test/samples/elements/multi_select_external.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "multi_external_select", 3 | "action_id": "multi_external_select", 4 | "min_query_length": 3, 5 | "placeholder": { 6 | "type": "plain_text", 7 | "text": "Select items" 8 | } 9 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_quote_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text_quote", 3 | "elements": [ 4 | { 5 | "type": "text", 6 | "text": "Great and good are seldom the same man" 7 | } 8 | ], 9 | "border": 1 10 | } -------------------------------------------------------------------------------- /test/samples/elements/rich_text_input_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text_input", 3 | "action_id": "action_id", 4 | "initial_value": { 5 | "type": "text", 6 | "text": "I'm rich" 7 | }, 8 | "focus_on_load": false, 9 | "placeholder": "Hello" 10 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_code_block_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text_preformatted", 3 | "elements": [ 4 | { 5 | "type": "text", 6 | "text": "\ndef hello_world():\n print('hello, world')" 7 | } 8 | ], 9 | "border": 0 10 | } -------------------------------------------------------------------------------- /test/samples/elements/timepicker_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "timepicker", 3 | "action_id": "timepicker", 4 | "initial_time": "12:00", 5 | "placeholder": { 6 | "type": "plain_text", 7 | "text": "Select your time" 8 | }, 9 | "timezone": "Australia/Sydney" 10 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_link_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "link", 3 | "url": "https://google.com/", 4 | "text": "Google", 5 | "unsafe": false, 6 | "style": { 7 | "bold": true, 8 | "italic": false, 9 | "strike": true, 10 | "code": true 11 | } 12 | } -------------------------------------------------------------------------------- /test/samples/blocks/image_block_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "image", 3 | "block_id": "fake_block_id", 4 | "image_url": "https://api.slack.com/img/blocks/bkb_template_images/beagle.png", 5 | "alt_text": "image1", 6 | "title": { 7 | "type": "plain_text", 8 | "text": "image1" 9 | } 10 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_user_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "user", 3 | "user_id": "DR36TNNLA", 4 | "style": { 5 | "bold": true, 6 | "italic": false, 7 | "strike": true, 8 | "highlight": true, 9 | "client_highlight": true, 10 | "unlink": false 11 | } 12 | } -------------------------------------------------------------------------------- /test/samples/views/hometab_view.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "home", 3 | "blocks": [ 4 | { 5 | "type": "section", 6 | "block_id": "fake_id", 7 | "text": { 8 | "type": "mrkdwn", 9 | "text": "Example Block" 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_channel_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "channel", 3 | "channel_id": "C0261C65XNY", 4 | "style": { 5 | "bold": true, 6 | "italic": false, 7 | "strike": true, 8 | "highlight": true, 9 | "client_highlight": true, 10 | "unlink": false 11 | } 12 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_user_group_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "user_group", 3 | "user_group_id": "C01RGRU0RUK", 4 | "style": { 5 | "bold": true, 6 | "italic": false, 7 | "strike": true, 8 | "highlight": true, 9 | "client_highlight": true, 10 | "unlink": false 11 | } 12 | } -------------------------------------------------------------------------------- /test/unit/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from slackblocks import Attachment 4 | from slackblocks.errors import InvalidUsageError 5 | 6 | 7 | def test_invalid_usage_exception() -> None: 8 | with pytest.raises(InvalidUsageError): 9 | attachment = Attachment(blocks=[], color="0000000000000") 10 | print(attachment) 11 | -------------------------------------------------------------------------------- /test/samples/elements/multi_select_user_with_initial_users.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "multi_users_select", 3 | "action_id": "multi_users_select", 4 | "initial_users": [ 5 | "U064B5H1309", 6 | "U063JR973UP" 7 | ], 8 | "placeholder": { 9 | "type": "plain_text", 10 | "text": "Select one or more users" 11 | } 12 | } -------------------------------------------------------------------------------- /test/samples/objects/trigger_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://slack.com/shortcuts/Ft012KXZK1MZ/8831723c452aac3e87c6d3219bebd44c", 3 | "customizable_input_parameters": [ 4 | { 5 | "name": "A", 6 | "value": "A" 7 | }, 8 | { 9 | "name": "B", 10 | "value": "B" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /docs_src/reference/rich_text.md: -------------------------------------------------------------------------------- 1 | # Rich Text 2 | ::: rich_text 3 | 4 | ## Rich Text Elements (Primitives) 5 | ::: rich_text.elements 6 | options: 7 | filters: ["!^RichTextElement"] 8 | show_bases: false 9 | 10 | ## Rich Text Objects (Containers) 11 | ::: rich_text.objects 12 | options: 13 | filters: ["!^RichTextObject"] 14 | show_bases: false -------------------------------------------------------------------------------- /test/samples/blocks/section_block_empty_text_field_value.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "section", 3 | "block_id": "fake_block_id", 4 | "fields": [ 5 | { 6 | "type": "mrkdwn", 7 | "text": "Highly" 8 | }, 9 | { 10 | "type": "plain_text", 11 | "text": "Strung", 12 | "emoji": true 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /test/samples/messages/message_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel": "#slackblocks", 3 | "mrkdwn": true, 4 | "blocks": [ 5 | { 6 | "type": "section", 7 | "block_id": "fake_block_id", 8 | "text": { 9 | "type": "mrkdwn", 10 | "text": "Hello, world!" 11 | } 12 | } 13 | ], 14 | "text": "" 15 | } -------------------------------------------------------------------------------- /test/samples/attachments/attachment_simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "fake_block_id", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": "I like pretty colours" 9 | } 10 | } 11 | ], 12 | "color": "#000000", 13 | "fallback": "Colours preference" 14 | } -------------------------------------------------------------------------------- /docs_src/reference/attachments.md: -------------------------------------------------------------------------------- 1 | # Attachments 2 | 3 | !!! warning "Warning: Deprecated Feature" 4 | 5 | Attachments, while still accepted by the Slack API, have long (for years now) been considered a deprecated feature. 6 | 7 | That said, there is currently no other way to achieve the vertical, colored bars next to content. 8 | 9 | ::: attachments 10 | options: 11 | show_bases: false -------------------------------------------------------------------------------- /test/samples/blocks/section_block_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "section", 3 | "block_id": "fake_block_id", 4 | "text": { 5 | "type": "mrkdwn", 6 | "text": "Test:" 7 | }, 8 | "fields": [ 9 | { 10 | "type": "plain_text", 11 | "text": "foo" 12 | }, 13 | { 14 | "type": "mrkdwn", 15 | "text": "bar" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /test/samples/messages/message_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "mrkdwn": true, 3 | "blocks": [ 4 | { 5 | "type": "section", 6 | "block_id": "fake_block_id", 7 | "text": { 8 | "type": "mrkdwn", 9 | "text": "Hello, world!" 10 | } 11 | } 12 | ], 13 | "text": "", 14 | "replace_original": false, 15 | "response_type": "ephemeral" 16 | } -------------------------------------------------------------------------------- /test/samples/objects/workflow_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "trigger": { 3 | "url": "https://slack.com/shortcuts/Ft012KXZK1MZ/8831723c452aac3e87c6d3219bebd44c", 4 | "customizable_input_parameters": [ 5 | { 6 | "name": "A", 7 | "value": "A" 8 | }, 9 | { 10 | "name": "B", 11 | "value": "B" 12 | } 13 | ] 14 | } 15 | } -------------------------------------------------------------------------------- /test/samples/objects/confirmation_dialogue_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": { 3 | "type": "plain_text", 4 | "text": "Maybe?" 5 | }, 6 | "text": { 7 | "type": "plain_text", 8 | "text": "Would you like to play checkers?" 9 | }, 10 | "confirm": { 11 | "type": "plain_text", 12 | "text": "Yes" 13 | }, 14 | "deny": { 15 | "type": "plain_text", 16 | "text": "Nope!" 17 | } 18 | } -------------------------------------------------------------------------------- /test/samples/messages/message_with_optional_arguments.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel": "#slackblocks", 3 | "mrkdwn": true, 4 | "blocks": [ 5 | { 6 | "type": "section", 7 | "block_id": "fake_block_id", 8 | "text": { 9 | "type": "mrkdwn", 10 | "text": "Hello, world!" 11 | } 12 | } 13 | ], 14 | "text": "", 15 | "unfurl_links": false, 16 | "unfurl_media": false 17 | } -------------------------------------------------------------------------------- /test/samples/blocks/section_block_both_text_and_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "section", 3 | "block_id": "fake_block_id", 4 | "text": { 5 | "type": "mrkdwn", 6 | "text": "Hello" 7 | }, 8 | "fields": [ 9 | { 10 | "type": "mrkdwn", 11 | "text": "Are you" 12 | }, 13 | { 14 | "type": "plain_text", 15 | "text": "There?", 16 | "emoji": true 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /test/samples/blocks/input_block_only.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "input", 3 | "block_id": "fake_block_id", 4 | "label": { 5 | "type": "plain_text", 6 | "text": "Label", 7 | "emoji": true 8 | }, 9 | "element": { 10 | "type": "plain_text_input", 11 | "action_id": "action" 12 | }, 13 | "hint": { 14 | "type": "plain_text", 15 | "text": "Hint", 16 | "emoji": true 17 | }, 18 | "optional": true 19 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish SlackBlocks Python Package 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Publish Package 13 | uses: celsiusnarhwal/poetry-publish@v2 14 | with: 15 | python-version: 3.11 16 | poetry-version: 1.3.1 17 | token: ${{ secrets.PYPI_TOKEN }} 18 | build: true 19 | -------------------------------------------------------------------------------- /slackblocks/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom error classes for clearer error reporting. 3 | """ 4 | 5 | 6 | class InvalidUsageError(Exception): 7 | """ 8 | You have violated the `slackblocks` API or Slack Web API in a way 9 | that has been caught by validation checks. 10 | 11 | Args: 12 | message: a custom error message providing details of the API 13 | violation. 14 | """ 15 | 16 | def __init__(self, message: str) -> None: 17 | return super(InvalidUsageError, self).__init__(message) 18 | -------------------------------------------------------------------------------- /test/samples/messages/message_basic_attachment.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel": "#slackblocks", 3 | "mrkdwn": true, 4 | "attachments": [ 5 | { 6 | "blocks": [ 7 | { 8 | "type": "section", 9 | "block_id": "block1", 10 | "text": { 11 | "type": "mrkdwn", 12 | "text": "Hello, world!" 13 | } 14 | } 15 | ], 16 | "color": "#000000" 17 | } 18 | ], 19 | "text": "" 20 | } -------------------------------------------------------------------------------- /test/samples/messages/message_with_attachments.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel": "#slackblocks", 3 | "mrkdwn": true, 4 | "attachments": [ 5 | { 6 | "blocks": [ 7 | { 8 | "type": "section", 9 | "block_id": "fake_block_id", 10 | "text": { 11 | "type": "mrkdwn", 12 | "text": "Hello, world!" 13 | } 14 | } 15 | ], 16 | "color": "#ffff00" 17 | } 18 | ], 19 | "text": "" 20 | } -------------------------------------------------------------------------------- /test/unit/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from slackblocks.objects import Option, Text, TextType 5 | 6 | OPTION_A = Option(text=Text("A", type_=TextType.PLAINTEXT), value="A") 7 | OPTION_B = Option(text=Text("B", type_=TextType.PLAINTEXT), value="B") 8 | OPTION_C = Option(text=Text("C", type_=TextType.PLAINTEXT), value="C") 9 | TWO_OPTIONS = [OPTION_A, OPTION_B] 10 | THREE_OPTIONS = TWO_OPTIONS + [ 11 | OPTION_C, 12 | ] 13 | 14 | 15 | def fetch_sample(path: Union[Path, str]) -> str: 16 | with open(path, "r") as file_: 17 | return file_.read() 18 | -------------------------------------------------------------------------------- /test/samples/attachments/attachment_multi_block.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "fake_block_id_0", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": "I like pretty colours" 9 | } 10 | }, 11 | { 12 | "type": "section", 13 | "block_id": "fake_block_id_1", 14 | "text": { 15 | "type": "mrkdwn", 16 | "text": "I don't like pretty colours" 17 | } 18 | } 19 | ], 20 | "color": "#8800ff" 21 | } -------------------------------------------------------------------------------- /test/samples/elements/multi_select_static.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "multi_static_select", 3 | "action_id": "multi_static_select", 4 | "options": [ 5 | { 6 | "text": { 7 | "type": "plain_text", 8 | "text": "A" 9 | }, 10 | "value": "A" 11 | }, 12 | { 13 | "text": { 14 | "type": "plain_text", 15 | "text": "B" 16 | }, 17 | "value": "B" 18 | } 19 | ], 20 | "placeholder": { 21 | "type": "plain_text", 22 | "text": "Select one or more" 23 | } 24 | } -------------------------------------------------------------------------------- /test/samples/elements/overflow_menu_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "overflow", 3 | "action_id": "overflow", 4 | "options": [ 5 | { 6 | "text": { 7 | "type": "plain_text", 8 | "text": "A" 9 | }, 10 | "value": "A" 11 | }, 12 | { 13 | "text": { 14 | "type": "plain_text", 15 | "text": "B" 16 | }, 17 | "value": "B" 18 | }, 19 | { 20 | "text": { 21 | "type": "plain_text", 22 | "text": "C" 23 | }, 24 | "value": "C" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /test/samples/elements/workflow_button_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "workflow_button", 3 | "text": { 4 | "type": "plain_text", 5 | "text": "Run Your Workflow" 6 | }, 7 | "workflow": { 8 | "trigger": { 9 | "url": "https://slack.com/shortcuts/Ft012KXZK1MZ/8831723c452aac3e87c6d3219bebd44c", 10 | "customizable_input_parameters": [ 11 | { 12 | "name": "name_a", 13 | "value": "value_a" 14 | }, 15 | { 16 | "name": "name_b", 17 | "value": "value_b" 18 | } 19 | ] 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_list_ordered.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text_list", 3 | "elements": [ 4 | { 5 | "type": "rich_text_section", 6 | "elements": [ 7 | { 8 | "type": "text", 9 | "text": "Oh" 10 | } 11 | ] 12 | }, 13 | { 14 | "type": "rich_text_section", 15 | "elements": [ 16 | { 17 | "type": "text", 18 | "text": "Hi" 19 | } 20 | ] 21 | } 22 | ], 23 | "style": "ordered", 24 | "indent": 1, 25 | "offset": 2, 26 | "border": 3 27 | } -------------------------------------------------------------------------------- /test/samples/objects/option_group_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": { 3 | "type": "plain_text", 4 | "text": "Group A" 5 | }, 6 | "options": [ 7 | { 8 | "text": { 9 | "type": "plain_text", 10 | "text": "A" 11 | }, 12 | "value": "A" 13 | }, 14 | { 15 | "text": { 16 | "type": "plain_text", 17 | "text": "B" 18 | }, 19 | "value": "B" 20 | }, 21 | { 22 | "text": { 23 | "type": "plain_text", 24 | "text": "C" 25 | }, 26 | "value": "C" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /test/samples/elements/checkbox_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "checkboxes", 3 | "action_id": "and...action", 4 | "options": [ 5 | { 6 | "text": { 7 | "type": "plain_text", 8 | "text": "A" 9 | }, 10 | "value": "A" 11 | }, 12 | { 13 | "text": { 14 | "type": "plain_text", 15 | "text": "B" 16 | }, 17 | "value": "B" 18 | } 19 | ], 20 | "initial_options": [ 21 | { 22 | "text": { 23 | "type": "plain_text", 24 | "text": "A" 25 | }, 26 | "value": "A" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /test/samples/elements/select_menu_static.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "static_select", 3 | "action_id": "static_select", 4 | "options": [ 5 | { 6 | "text": { 7 | "type": "plain_text", 8 | "text": "A" 9 | }, 10 | "value": "A" 11 | }, 12 | { 13 | "text": { 14 | "type": "plain_text", 15 | "text": "B" 16 | }, 17 | "value": "B" 18 | }, 19 | { 20 | "text": { 21 | "type": "plain_text", 22 | "text": "C" 23 | }, 24 | "value": "C" 25 | } 26 | ], 27 | "placeholder": { 28 | "type": "plain_text", 29 | "text": "Select one item" 30 | } 31 | } -------------------------------------------------------------------------------- /test/samples/messages/webhook_message_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "block_id": "fake_block_id", 6 | "text": { 7 | "type": "mrkdwn", 8 | "text": "You wouldn't do ol' Hook in now, would you, lad?" 9 | } 10 | }, 11 | { 12 | "type": "section", 13 | "block_id": "fake_block_id", 14 | "text": { 15 | "type": "mrkdwn", 16 | "text": "Well, all right... if you... say you're a codfish." 17 | } 18 | } 19 | ], 20 | "response_type": "ephemeral", 21 | "replace_original": true, 22 | "unfurl_links": false, 23 | "unfurl_media": false, 24 | "metadata": { 25 | "sender": "Walt" 26 | } 27 | } -------------------------------------------------------------------------------- /slackblocks/rich_text/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rich Text elements can be used to enhance text-based messages with 3 | code, list, quotations and formatted text (including options not 4 | available in traditional markdown like strikethrough). 5 | 6 | These formatting elements can only be used within a 7 | [`RichTextBlock`](/slackblocks/reference/blocks/#blocks.RichTextBlock). 8 | 9 | See: . 10 | """ 11 | 12 | from .elements import ( 13 | RichText, 14 | RichTextChannel, 15 | RichTextElement, 16 | RichTextEmoji, 17 | RichTextLink, 18 | RichTextUser, 19 | RichTextUserGroup, 20 | ) 21 | from .objects import ( 22 | ListType, 23 | RichTextCodeBlock, 24 | RichTextList, 25 | RichTextObject, 26 | RichTextQuote, 27 | RichTextSection, 28 | ) 29 | -------------------------------------------------------------------------------- /test/samples/elements/radio_button_group_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "radio_buttons", 3 | "action_id": "radio_buttons", 4 | "options": [ 5 | { 6 | "text": { 7 | "type": "plain_text", 8 | "text": "A" 9 | }, 10 | "value": "A" 11 | }, 12 | { 13 | "text": { 14 | "type": "plain_text", 15 | "text": "B" 16 | }, 17 | "value": "B" 18 | }, 19 | { 20 | "text": { 21 | "type": "plain_text", 22 | "text": "C" 23 | }, 24 | "value": "C" 25 | } 26 | ], 27 | "initial_option": { 28 | "text": { 29 | "type": "plain_text", 30 | "text": "A" 31 | }, 32 | "value": "A" 33 | } 34 | } -------------------------------------------------------------------------------- /slackblocks/modals.py: -------------------------------------------------------------------------------- 1 | """ 2 | Modals are pop-up windows, primarily used for collecting data from 3 | users. 4 | 5 | This module is kept only for backwards compatibility, modals have 6 | been largely subsumed as a subtype of view. 7 | 8 | See: 9 | """ 10 | 11 | from json import dumps 12 | from typing import Any, Dict 13 | 14 | from slackblocks.views import ModalView 15 | 16 | 17 | class Modal(ModalView): 18 | """ 19 | Kept for backwards compatibility - see 20 | [`ModalView`](/slackblocks/latest/reference/views/#views.ModalView) 21 | """ 22 | 23 | def __repr__(self) -> str: 24 | return dumps(self._resolve(), indent=4) 25 | 26 | def to_dict(self) -> Dict[str, Any]: 27 | return self._resolve() 28 | 29 | def json(self) -> str: 30 | return dumps(self._resolve(), indent=4) 31 | -------------------------------------------------------------------------------- /test/unit/test_attachments.py: -------------------------------------------------------------------------------- 1 | from slackblocks import Attachment, Color, SectionBlock 2 | 3 | 4 | def test_single_attachment() -> None: 5 | block = SectionBlock("I like pretty colours", block_id="fake_block_id") 6 | attachment = Attachment( 7 | blocks=block, color=Color.BLACK, fallback="Colours preference" 8 | ) 9 | with open("test/samples/attachments/attachment_simple.json", "r") as expected: 10 | assert repr(attachment) == expected.read() 11 | 12 | 13 | def test_multi_block_attachment() -> None: 14 | block_0 = SectionBlock("I like pretty colours", block_id="fake_block_id_0") 15 | block_1 = SectionBlock("I don't like pretty colours", block_id="fake_block_id_1") 16 | attachment = Attachment(blocks=[block_0, block_1], color=Color.PURPLE) 17 | with open("test/samples/attachments/attachment_multi_block.json", "r") as expected: 18 | assert repr(attachment) == expected.read() 19 | -------------------------------------------------------------------------------- /test/samples/rich_text/rich_text_list_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text_list", 3 | "elements": [ 4 | { 5 | "type": "rich_text_section", 6 | "elements": [ 7 | { 8 | "type": "text", 9 | "text": "Oh" 10 | } 11 | ] 12 | }, 13 | { 14 | "type": "rich_text_section", 15 | "elements": [ 16 | { 17 | "type": "text", 18 | "text": "Hi" 19 | } 20 | ] 21 | }, 22 | { 23 | "type": "rich_text_section", 24 | "elements": [ 25 | { 26 | "type": "text", 27 | "text": "Mark" 28 | } 29 | ] 30 | } 31 | ], 32 | "style": "bullet", 33 | "indent": 0, 34 | "offset": 0, 35 | "border": 1 36 | } -------------------------------------------------------------------------------- /test/samples/views/modal_with_blocks.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "modal", 3 | "blocks": [ 4 | { 5 | "type": "section", 6 | "block_id": "1", 7 | "text": { 8 | "type": "mrkdwn", 9 | "text": "first section block" 10 | } 11 | }, 12 | { 13 | "type": "divider", 14 | "block_id": "2" 15 | }, 16 | { 17 | "type": "section", 18 | "block_id": "3", 19 | "text": { 20 | "type": "mrkdwn", 21 | "text": "second section block" 22 | } 23 | } 24 | ], 25 | "title": { 26 | "type": "plain_text", 27 | "text": "Hello, world!" 28 | }, 29 | "close": { 30 | "type": "plain_text", 31 | "text": "Close button" 32 | }, 33 | "submit": { 34 | "type": "plain_text", 35 | "text": "Submit button" 36 | } 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Nicholas Lambourne 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /test/samples/blocks/rich_text_block_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "rich_text", 3 | "block_id": "fake_block_id", 4 | "elements": [ 5 | { 6 | "type": "rich_text_section", 7 | "elements": [ 8 | { 9 | "type": "text", 10 | "text": "You 'bout to witness hip-hop in its most purest", 11 | "style": { 12 | "bold": true 13 | } 14 | }, 15 | { 16 | "type": "text", 17 | "text": "Most rawest form, flow almost flawless", 18 | "style": { 19 | "strike": true 20 | } 21 | }, 22 | { 23 | "type": "text", 24 | "text": "Most hardest, most honest known artist", 25 | "style": { 26 | "italic": true 27 | } 28 | } 29 | ] 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /docs_src/usage/installation.md: -------------------------------------------------------------------------------- 1 | ## Installing `slackblocks` 2 | You can install `slackblocks` using any Python package manager with access to PyPI. Installation commands for some of the more popular ones are included below. 3 | 4 | === "pip" 5 | ```bash 6 | pip install slackblocks 7 | ``` 8 | 9 | === "poetry" 10 | ```bash 11 | poetry add slackblocks 12 | ``` 13 | 14 | === "Pipenv" 15 | ``` 16 | pipenv install slackblocks 17 | ``` 18 | 19 | `slackblocks` is a pure Python package and is published automatically to [PyPI](https://pypi.org/project/slackblocks/) as Python wheels whenever a new version is released. 20 | 21 | As of version `v0.1.0`` it has no dependencies outside of the Python standard library. 22 | 23 | ## Uninstalling `slackblocks` 24 | If, for whatever reason, you need to remove `slackblocks` from your environment you can do so with the following commands: 25 | 26 | === "pip" 27 | ```bash 28 | pip uninstall slackblocks 29 | ``` 30 | 31 | === "poetry" 32 | ```bash 33 | poetry remove slackblocks 34 | ``` 35 | 36 | === "Pipenv" 37 | ``` 38 | pipenv uninstall slackblocks 39 | ``` -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docs On Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check Out Source Repository 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set Up Python Environment 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | 22 | - name: Install Poetry 23 | uses: snok/install-poetry@v1 24 | 25 | - name: Configure Git 26 | run: | 27 | git config --global user.name "github-actions[bot]" 28 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 29 | 30 | - name: Install Docs Dependencies 31 | run: | 32 | poetry install --no-interaction --no-root --with docs 33 | 34 | - name: Build and Deploy Docs 35 | run: | 36 | poetry run mike deploy --push --update-aliases ${{ github.ref_name }} latest 37 | -------------------------------------------------------------------------------- /test/samples/messages/webhook_message_delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "attachments": [ 3 | { 4 | "blocks": [ 5 | { 6 | "type": "section", 7 | "block_id": "fake_block_id", 8 | "text": { 9 | "type": "mrkdwn", 10 | "text": "I'M A CODFISH!" 11 | } 12 | } 13 | ] 14 | } 15 | ], 16 | "blocks": [ 17 | { 18 | "type": "section", 19 | "block_id": "fake_block_id", 20 | "text": { 21 | "type": "mrkdwn", 22 | "text": "I'm a codfish." 23 | } 24 | }, 25 | { 26 | "type": "section", 27 | "block_id": "fake_block_id", 28 | "text": { 29 | "type": "mrkdwn", 30 | "text": "Louder!" 31 | } 32 | } 33 | ], 34 | "response_type": "in_channel", 35 | "delete_original": true, 36 | "unfurl_links": true, 37 | "unfurl_media": true, 38 | "metadata": { 39 | "sender": "Walt" 40 | } 41 | } -------------------------------------------------------------------------------- /.github/workflows/formatting.yml: -------------------------------------------------------------------------------- 1 | name: Black Formatting 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | formatting: 14 | runs-on: ubuntu-latest 15 | name: black Formatting 16 | steps: 17 | - name: Check Out Source Repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set Up Python Environment 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.12" 24 | 25 | - name: Install Poetry 26 | uses: snok/install-poetry@v1 27 | with: 28 | version: 1.5.1 29 | virtualenvs-create: true 30 | virtualenvs-in-project: true 31 | 32 | - name: Load Cached venv 33 | id: cached-poetry-dependencies 34 | uses: actions/cache@v4 35 | with: 36 | path: .venv 37 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 38 | 39 | - name: Install Dependencies 40 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 41 | run: 42 | poetry install --no-interaction --no-root --only dev 43 | 44 | - name: Formatting 45 | run: 46 | poetry run black . --check -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Flake8 Linting 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | linting: 14 | runs-on: ubuntu-latest 15 | name: flake8 Linting 16 | steps: 17 | - name: Check Out Source Repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set Up Python Environment 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.12" 24 | 25 | - name: Install Poetry 26 | uses: snok/install-poetry@v1 27 | with: 28 | virtualenvs-create: true 29 | virtualenvs-in-project: true 30 | 31 | - name: Load Cached venv 32 | id: cached-poetry-dependencies 33 | uses: actions/cache@v4 34 | with: 35 | path: .venv 36 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 37 | 38 | - name: Install Dependencies 39 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 40 | run: 41 | poetry install --no-interaction --no-root --only dev 42 | 43 | - name: Linting 44 | uses: py-actions/flake8@v2 45 | with: 46 | plugins: "flake8-black Flake8-pyproject" 47 | -------------------------------------------------------------------------------- /test/samples/blocks/actions_block_checkboxes.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "actions", 3 | "block_id": "fake_block_id", 4 | "elements": [ 5 | { 6 | "type": "checkboxes", 7 | "action_id": "actionId-0", 8 | "options": [ 9 | { 10 | "text": { 11 | "type": "mrkdwn", 12 | "text": "*a*" 13 | }, 14 | "value": "a", 15 | "description": { 16 | "type": "plain_text", 17 | "text": "*a*" 18 | } 19 | }, 20 | { 21 | "text": { 22 | "type": "mrkdwn", 23 | "text": "*b*" 24 | }, 25 | "value": "b", 26 | "description": { 27 | "type": "plain_text", 28 | "text": "*b*" 29 | } 30 | }, 31 | { 32 | "text": { 33 | "type": "mrkdwn", 34 | "text": "*c*" 35 | }, 36 | "value": "c", 37 | "description": { 38 | "type": "plain_text", 39 | "text": "*c*" 40 | } 41 | } 42 | ] 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /.github/workflows/type-checking.yml: -------------------------------------------------------------------------------- 1 | name: Type Checking 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | type-checking: 14 | runs-on: ubuntu-latest 15 | name: mypy Type Checking 16 | steps: 17 | - name: Check Out Source Repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set Up Python Environment 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.12" 24 | 25 | - name: Install Poetry 26 | uses: snok/install-poetry@v1 27 | with: 28 | version: 1.5.1 29 | virtualenvs-create: true 30 | virtualenvs-in-project: true 31 | 32 | - name: Load Cached venv 33 | id: cached-poetry-dependencies 34 | uses: actions/cache@v4 35 | with: 36 | path: .venv 37 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 38 | 39 | - name: Install Dependencies 40 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 41 | run: 42 | poetry install --no-interaction --no-root --only dev 43 | 44 | - name: Verify mypy Installation 45 | run: | 46 | poetry run mypy --version || (echo "mypy is not installed" && exit 1) 47 | 48 | - name: Type Checking 49 | run: 50 | poetry run mypy slackblocks -------------------------------------------------------------------------------- /LICENSE.BSD-3-Clause: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Nicholas Lambourne 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /test/unit/test_views.py: -------------------------------------------------------------------------------- 1 | from slackblocks import HomeTabView, Modal 2 | from slackblocks.blocks import DividerBlock, SectionBlock 3 | 4 | from .utils import fetch_sample 5 | 6 | 7 | def test_modal_with_blocks() -> None: 8 | modal = Modal( 9 | title="Hello, world!", 10 | close="Close button", 11 | submit="Submit button", 12 | blocks=[ 13 | SectionBlock(text="first section block", block_id="1"), 14 | DividerBlock(block_id="2"), 15 | SectionBlock(text="second section block", block_id="3"), 16 | ], 17 | ) 18 | assert fetch_sample("test/samples/views/modal_with_blocks.json") == repr(modal) 19 | 20 | 21 | def test_hometab_view() -> None: 22 | view = HomeTabView(blocks=[SectionBlock(text="Example Block", block_id="fake_id")]) 23 | assert fetch_sample("test/samples/views/hometab_view.json") == repr(view) 24 | 25 | 26 | def test_to_dict() -> None: 27 | modal = Modal( 28 | title="Hello, world!", 29 | close="Close button", 30 | submit="Submit button", 31 | blocks=[SectionBlock(text="first section block", block_id="1")], 32 | ) 33 | assert modal.to_dict() == { 34 | "type": "modal", 35 | "title": {"type": "plain_text", "text": "Hello, world!"}, 36 | "close": {"type": "plain_text", "text": "Close button"}, 37 | "submit": {"type": "plain_text", "text": "Submit button"}, 38 | "blocks": [ 39 | { 40 | "type": "section", 41 | "block_id": "1", 42 | "text": { 43 | "type": "mrkdwn", 44 | "text": "first section block", 45 | }, 46 | } 47 | ], 48 | } 49 | -------------------------------------------------------------------------------- /test/integration/test_integration.py: -------------------------------------------------------------------------------- 1 | # TODO(nick): enable in GH actions 2 | 3 | from os import environ 4 | 5 | from slack_sdk import WebClient 6 | 7 | from slackblocks import Attachment, Color, ImageBlock, Message, SectionBlock 8 | 9 | 10 | def test_basic_attachment_message() -> None: 11 | block = SectionBlock("Hello, world!", block_id="block1") 12 | attachment = Attachment(blocks=block, color=Color.BLACK) 13 | message = Message( 14 | channel="#slackblocks", 15 | attachments=[ 16 | attachment, 17 | ], 18 | ) 19 | client = WebClient(token=environ["SLACK_BOT_TOKEN"]) 20 | response = client.chat_postMessage(**message) 21 | assert response.status_code == 200 22 | with open("test/samples/message_basic_attachment.json", "r") as expected: 23 | assert repr(message) == expected.read() 24 | 25 | 26 | def test_compound_message() -> None: 27 | block1 = SectionBlock("Block, One", block_id="fake_block1") 28 | block2 = SectionBlock("Block, Two", block_id="fake_block2") 29 | block3 = ImageBlock( 30 | title="Crash", 31 | image_url="http://bit.ly/slack-block-test-image", 32 | alt_text="crash", 33 | block_id="fake_block3", 34 | ) 35 | attachment1 = Attachment(blocks=block1, color=Color.PURPLE) 36 | attachment2 = Attachment(blocks=[block2, block3], color=Color.YELLOW) 37 | message = Message( 38 | channel="#slackblocks", 39 | blocks=[block1, block3], 40 | attachments=[attachment1, attachment2], 41 | ) 42 | client = WebClient(token=environ["SLACK_BOT_TOKEN"]) 43 | response = client.chat_postMessage(**message) 44 | assert response.status_code == 200 45 | with open("test/samples/message_compound.json", "r") as expected: 46 | assert repr(message) == expected.read() 47 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | unit-tests: 14 | name: Unit Tests (${{ matrix.os }}, Python ${{ matrix.python-version }}) 15 | runs-on: ${{ matrix.os }} 16 | defaults: 17 | run: 18 | shell: bash 19 | 20 | strategy: 21 | matrix: 22 | os: [macos-latest, ubuntu-latest, windows-latest] 23 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 24 | 25 | steps: 26 | - name: Check Out Source Repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Set Up Python ${{ matrix.python-version }} 30 | id: setup-python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Install Poetry 36 | uses: snok/install-poetry@v1 37 | with: 38 | version: 1.5.1 39 | virtualenvs-create: true 40 | virtualenvs-in-project: true 41 | 42 | - name: Load Cached venv 43 | id: cached-poetry-dependencies 44 | uses: actions/cache@v4 45 | with: 46 | path: .venv 47 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 48 | 49 | - name: Install Dependencies 50 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 51 | run: poetry install --no-interaction --no-root 52 | 53 | - name: Install SlackBlocks Library 54 | run: poetry install --no-interaction 55 | 56 | - name: Test With pytest 57 | run: poetry run pytest test/unit 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .idea 107 | .vscode 108 | 109 | docs/ -------------------------------------------------------------------------------- /slackblocks/__init__.py: -------------------------------------------------------------------------------- 1 | from .attachments import Attachment, Color, Field 2 | from .blocks import ( 3 | ActionsBlock, 4 | ContextBlock, 5 | DividerBlock, 6 | FileBlock, 7 | HeaderBlock, 8 | ImageBlock, 9 | InputBlock, 10 | RichTextBlock, 11 | SectionBlock, 12 | TableBlock, 13 | ) 14 | from .elements import ( 15 | Button, 16 | ChannelMultiSelectMenu, 17 | ChannelSelectMenu, 18 | CheckboxGroup, 19 | ConversationMultiSelectMenu, 20 | ConversationSelectMenu, 21 | DatePicker, 22 | DateTimePicker, 23 | Element, 24 | EmailInput, 25 | ExternalMultiSelectMenu, 26 | ExternalSelectMenu, 27 | Image, 28 | NumberInput, 29 | OverflowMenu, 30 | PlainTextInput, 31 | RadioButtonGroup, 32 | RichTextInput, 33 | StaticMultiSelectMenu, 34 | StaticSelectMenu, 35 | TimePicker, 36 | URLInput, 37 | UserMultiSelectMenu, 38 | UserSelectMenu, 39 | WorkflowButton, 40 | ) 41 | from .errors import InvalidUsageError 42 | from .messages import Message, MessageResponse, ResponseType, WebhookMessage 43 | from .modals import Modal 44 | from .objects import ( 45 | ColumnSettings, 46 | Confirm, 47 | ConfirmationDialogue, 48 | ConversationFilter, 49 | DispatchActionConfiguration, 50 | InputParameter, 51 | Option, 52 | OptionGroup, 53 | RawText, 54 | Text, 55 | TextType, 56 | Trigger, 57 | Workflow, 58 | ) 59 | from .rich_text.elements import ( 60 | RichText, 61 | RichTextChannel, 62 | RichTextElement, 63 | RichTextEmoji, 64 | RichTextLink, 65 | RichTextUser, 66 | RichTextUserGroup, 67 | ) 68 | from .rich_text.objects import ( 69 | RichTextCodeBlock, 70 | RichTextList, 71 | RichTextObject, 72 | RichTextQuote, 73 | RichTextSection, 74 | ) 75 | from .views import HomeTabView, ModalView, View 76 | 77 | name = "slackblocks" 78 | -------------------------------------------------------------------------------- /test/samples/blocks/table_block.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "table", 3 | "rows": [ 4 | [ 5 | { 6 | "type": "raw_text", 7 | "text": "Header A" 8 | }, 9 | { 10 | "type": "raw_text", 11 | "text": "Header B" 12 | } 13 | ], 14 | [ 15 | { 16 | "type": "raw_text", 17 | "text": "Data 1A" 18 | }, 19 | { 20 | "type": "rich_text", 21 | "elements": [ 22 | { 23 | "type": "rich_text_section", 24 | "elements": [ 25 | { 26 | "type": "link", 27 | "url": "https://slack.com", 28 | "text": "Data 1B" 29 | } 30 | ] 31 | } 32 | ] 33 | } 34 | ], 35 | [ 36 | { 37 | "type": "raw_text", 38 | "text": "Data 2A" 39 | }, 40 | { 41 | "type": "rich_text", 42 | "elements": [ 43 | { 44 | "type": "rich_text_section", 45 | "elements": [ 46 | { 47 | "type": "link", 48 | "url": "https://slack.com", 49 | "text": "Data 2B" 50 | } 51 | ] 52 | } 53 | ] 54 | } 55 | ] 56 | ], 57 | "column_settings": [ 58 | { 59 | "is_wrapped": true 60 | }, 61 | { 62 | "align": "right" 63 | } 64 | ] 65 | } -------------------------------------------------------------------------------- /test/samples/messages/message_compound.json: -------------------------------------------------------------------------------- 1 | { 2 | "channel": "#slackblocks", 3 | "mrkdwn": true, 4 | "blocks": [ 5 | { 6 | "type": "section", 7 | "block_id": "fake_block1", 8 | "text": { 9 | "type": "mrkdwn", 10 | "text": "Block, One" 11 | } 12 | }, 13 | { 14 | "type": "image", 15 | "block_id": "fake_block3", 16 | "image_url": "http://bit.ly/slack-block-test-image", 17 | "alt_text": "crash", 18 | "title": { 19 | "type": "plain_text", 20 | "text": " " 21 | } 22 | } 23 | ], 24 | "attachments": [ 25 | { 26 | "blocks": [ 27 | { 28 | "type": "section", 29 | "block_id": "fake_block1", 30 | "text": { 31 | "type": "mrkdwn", 32 | "text": "Block, One" 33 | } 34 | } 35 | ], 36 | "color": "#8800ff" 37 | }, 38 | { 39 | "blocks": [ 40 | { 41 | "type": "section", 42 | "block_id": "fake_block2", 43 | "text": { 44 | "type": "mrkdwn", 45 | "text": "Block, Two" 46 | } 47 | }, 48 | { 49 | "type": "image", 50 | "block_id": "fake_block3", 51 | "image_url": "http://bit.ly/slack-block-test-image", 52 | "alt_text": "crash", 53 | "title": { 54 | "type": "plain_text", 55 | "text": " " 56 | } 57 | } 58 | ], 59 | "color": "#ffff00" 60 | } 61 | ], 62 | "text": "" 63 | } -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: slackblocks 2 | 3 | repo_url: https://github.com/nicklambourne/slackblocks/ 4 | site_url: https://nicklambourne.github.io/slackblocks/ 5 | site_dir: docs 6 | docs_dir: docs_src 7 | 8 | theme: 9 | name: "material" 10 | logo: img/sb.png 11 | favicon: img/sb.png 12 | features: 13 | - navigation.expand 14 | - search.suggest 15 | palette: 16 | # Palette toggle for light mode 17 | - scheme: default 18 | toggle: 19 | icon: material/brightness-7 20 | name: Switch to dark mode 21 | primary: black 22 | accent: indigo 23 | 24 | # Palette toggle for dark mode 25 | - scheme: slate 26 | toggle: 27 | icon: material/brightness-4 28 | name: Switch to light mode 29 | primary: black 30 | accent: deep-purple 31 | icon: 32 | admonition: 33 | warning: material/alert 34 | 35 | plugins: 36 | - search 37 | - mkdocstrings: 38 | default_handler: python 39 | handlers: 40 | python: 41 | paths: [slackblocks] 42 | options: 43 | show_root_heading: false 44 | show_root_toc_entry: false 45 | separate_signature: true 46 | show_signature_annotations: false 47 | show_symbol_type_heading: true 48 | docstring_style: google 49 | heading_level: 3 50 | - mike: 51 | # These fields are all optional; the defaults are as below... 52 | alias_type: symlink 53 | redirect_template: null 54 | deploy_prefix: null 55 | canonical_version: null 56 | version_selector: true 57 | css_dir: css 58 | javascript_dir: js 59 | 60 | markdown_extensions: 61 | - attr_list 62 | - pymdownx.emoji: 63 | emoji_index: !!python/name:material.extensions.emoji.twemoji 64 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 65 | - admonition 66 | - pymdownx.details 67 | - pymdownx.superfences 68 | - pymdownx.tabbed: 69 | alternate_style: true 70 | 71 | nav: 72 | - Home: index.md 73 | - Usage: 74 | - Installation: usage/installation.md 75 | - Using Blocks: usage/using_blocks.md 76 | - Sending Messages: usage/sending_messages.md 77 | - Reference: 78 | - Attachments: reference/attachments.md 79 | - Blocks: reference/blocks.md 80 | - Elements: reference/elements.md 81 | - Messages: reference/messages.md 82 | - Modals: reference/modals.md 83 | - Objects: reference/objects.md 84 | - Rich Text: reference/rich_text.md 85 | - Views: reference/views.md 86 | - Utilities: reference/utils.md 87 | 88 | extra: 89 | version: 90 | provider: mike -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "slackblocks" 3 | version = "1.2.3" 4 | description = "Python wrapper for the Slack Blocks API" 5 | authors = [ 6 | "Nicholas Lambourne ", 7 | ] 8 | maintainers = [ 9 | "Nicholas Lambourne ", 10 | ] 11 | homepage = "https://github.com/nicklambourne/slackblocks" 12 | repository = "https://github.com/nicklambourne/slackblocks" 13 | license = "MIT" 14 | readme = "README.md" 15 | keywords = [ 16 | "slackblocks", 17 | "slack", 18 | "messaging", 19 | "message generation", 20 | "slack blocks", 21 | "blocks", 22 | ] 23 | classifiers=[ 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "License :: OSI Approved :: BSD License", 32 | "License :: OSI Approved :: MIT License", 33 | "Operating System :: OS Independent", 34 | "Typing :: Typed", 35 | "Topic :: Communications :: Chat" 36 | ] 37 | exclude = ["test/**", "docs/**"] 38 | 39 | [tool.poetry.dependencies] 40 | python = ">=3.8.1" 41 | 42 | [tool.poetry.group.dev.dependencies] 43 | aiohttp = "^3.10.11" 44 | black = "^24.8.0" 45 | flake8 = "^6.1.0" 46 | flake8-pyproject = "^1.2.3" 47 | mypy = "^1.14.1" 48 | pytest = "^8.3.4" 49 | slack-sdk = "^3.33.4" 50 | twine = "^6.0.1" 51 | wheel = "^0.45.1" 52 | 53 | [tool.poetry.group.docs.dependencies] 54 | mike = {version = "^2.1.3", python = ">=3.9"} 55 | mkdocs = {version = "^1.6.1", python = ">=3.9"} 56 | mkdocs-material = {version = "^9.5.49", python = ">=3.9"} 57 | mkdocstrings = {extras = ["python"], version = "^0.27.0", python = ">=3.9"} 58 | mkdocstrings-python = {version = "^1.13.0", python = ">=3.9"} 59 | 60 | [build-system] 61 | requires = ["poetry-core"] 62 | build-backend = "poetry.core.masonry.api" 63 | 64 | [tool.flake8] 65 | exclude = [".venv", "./build", "./dist", ".eggs", ".git"] 66 | max-line-length = 100 67 | extend-ignore = """ 68 | BLK100 69 | """ 70 | per-file-ignores = [ 71 | "slackblocks/__init__.py:F401", 72 | "slackblocks/rich_text/__init__.py:F401", 73 | "slackblocks/rich_text/objects.py:W605", 74 | ] 75 | 76 | [tool.pytest.ini_options] 77 | filterwarnings = [ 78 | #transform warnings into errors so they are not possible to ignore 79 | "error", 80 | #except when defined explicitly as per example below. See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#controlling-warnings 81 | #"ignore::UserWarning", 82 | ] 83 | -------------------------------------------------------------------------------- /test/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from slackblocks.errors import InvalidUsageError 4 | from slackblocks.utils import ( 5 | coerce_to_list, 6 | is_hex, 7 | validate_action_id, 8 | validate_string, 9 | ) 10 | 11 | 12 | def test_coerce_to_list_single_item() -> None: 13 | assert coerce_to_list("a", class_=str) == [ 14 | "a", 15 | ] 16 | 17 | 18 | def test_coerce_to_list_list() -> None: 19 | assert coerce_to_list(["a", "b", "c"], class_=str) == ["a", "b", "c"] 20 | 21 | 22 | def test_coerce_to_list_wrong_class() -> None: 23 | with pytest.raises(InvalidUsageError): 24 | assert coerce_to_list(["a", 1], class_=str) 25 | 26 | 27 | def test_coerce_to_list_allow_none() -> None: 28 | assert coerce_to_list(None, class_=str, allow_none=True) is None 29 | 30 | 31 | def test_coerce_to_list_disallow_none() -> None: 32 | with pytest.raises(InvalidUsageError): 33 | assert coerce_to_list(None, class_=str) 34 | 35 | 36 | def test_coerce_to_list_lower_bound() -> None: 37 | with pytest.raises(InvalidUsageError): 38 | assert coerce_to_list(["a"], class_=str, min_size=2) 39 | 40 | 41 | def test_coerce_to_list_upper_bound() -> None: 42 | with pytest.raises(InvalidUsageError): 43 | assert coerce_to_list(["a", 1], class_=str, max_size=1) 44 | 45 | 46 | def test_is_hex_valid() -> None: 47 | assert is_hex("1234abcdef") 48 | 49 | 50 | def test_is_hex_invalid() -> None: 51 | assert not is_hex("1234g") 52 | 53 | 54 | def test_validate_validate_action_id_basic() -> None: 55 | assert validate_action_id("action_id") == "action_id" 56 | 57 | 58 | def test_validate_validate_action_id_allow_none() -> None: 59 | assert validate_action_id(None, allow_none=True) is None 60 | 61 | 62 | def test_validate_validate_action_id_disallow_none() -> None: 63 | with pytest.raises(InvalidUsageError): 64 | assert validate_action_id(None, allow_none=False) 65 | 66 | 67 | def test_validate_validate_action_id_lower_bound() -> None: 68 | with pytest.raises(InvalidUsageError): 69 | assert validate_action_id("") 70 | 71 | 72 | def test_validate_validate_action_id_upper_bound() -> None: 73 | with pytest.raises(InvalidUsageError): 74 | assert validate_action_id("a" * 256) == "a" * 256 75 | 76 | 77 | def test_validate_string_basic() -> None: 78 | assert validate_string("abc123", field_name="field") == "abc123" 79 | 80 | 81 | def test_validate_string_allow_none() -> None: 82 | assert validate_string(None, field_name="field", allow_none=True) is None 83 | 84 | 85 | def test_validate_string_disallow_none() -> None: 86 | with pytest.raises(InvalidUsageError): 87 | assert validate_string(None, field_name="field", allow_none=False) 88 | 89 | 90 | def test_validate_string_exceed_max_length() -> None: 91 | with pytest.raises(InvalidUsageError): 92 | assert validate_string("a" * 5, field_name="field", max_length=4) 93 | -------------------------------------------------------------------------------- /docs_src/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to `slackblocks`! 2 | 3 |

4 | 5 |

6 | 7 | `slackblocks` is Python package for creating complex Slack messages 8 | using the Slack [BlockKit API](https://api.slack.com/block-kit). 9 | 10 | It exists so you don't have to define block-based Slack messages by 11 | hand-writing JSON. 12 | 13 | ## Components 14 | 15 | The [Slack BlockKit API](https://api.slack.com/block-kit) defines a number of 16 | different resource types (all defined in JSON) which work together to 17 | define Block-based messages. 18 | 19 | `slackblocks` makes using this API easier by providing a hierarchy of Python 20 | classes that represent these resources. 21 | 22 | ### Objects 23 | [`Objects`](/slackblocks/latest/reference/objects) (e.g. [`Text`](/slackblocks/latest/reference/objects/#objects.Text)) 24 | are the lowest level pimitives that are used to populate 25 | [`Elements`](/slackblocks/latest/reference/elements) and [`Blocks`](/slackblocks/latest/reference/blocks). 26 | 27 | ### Elements 28 | [`Elements`](/slackblocks/latest/reference/elements) are typically interactive UI elements that take 29 | in [`Object`](/slackblocks/latest/reference/objects) to define their content. For example, the 30 | [`CheckboxGroup`](/elements/#elements.CheckboxGroup) element takes in one or 31 | more [`Option`](/slackblocks/latest/reference/objects/#objects.Option) items and presents a 32 | checkbox menu to the user with those options. 33 | 34 | ### Blocks 35 | [`Blocks`](/slackblocks/latest/reference/blocks) are the core element of the API, with different 36 | [`Blocks`](/slackblocks/latest/reference/blocks) used to create different types of visual 37 | elements. For example, the [`DividerBlock`](/slackblocks/latest/reference/blocks/#blocks.DividerBlock), 38 | when rendered, will show a visual element similar to a `
` HTML element. The 39 | [`RichTextBlock`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock) on the other hand 40 | allows for the display of text elements with visual styling like italics, 41 | block quotes, lists and code blocks. 42 | 43 | ### Messages 44 | [`Messages`](/slackblocks/latest/reference/messages/) are a convenience wrapper around `Blocks` that 45 | can be unpacked as arguments straight into the official Slack Python SDK (or 46 | its legacy `slackclient` counterpart). 47 | 48 | ### Views 49 | [`Views`](reference/views/) are an alternative usage for [`Blocks`](/slackblocks/latest/reference/blocks) 50 | that allow for the creation of custom UI "surfaces" within Slack, e.g. for 51 | third-party apps. 52 | 53 | ## Guides 54 | In addition to a complete reference of all classes and functions provided by the 55 | `slackblocks` library, this documentation contains guides on: 56 | 57 | - [Installing `slackblocks`](usage/installation/) 58 | - [Using Blocks](usage/using_blocks/) 59 | - [Sending Block-based Messages](usage/sending_messages/) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slackblocks 2 | 3 | ![Licence: MIT](https://img.shields.io/badge/License-MIT-green.svg) 4 | ![Licence: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3_Clause-green.svg) 5 | ![Python Versions](https://img.shields.io/pypi/pyversions/slackblocks) 6 | [![PyPI](https://img.shields.io/pypi/v/slackblocks?color=yellow&label=PyPI&logo=python&logoColor=white)](https://pypi.org/project/slackblocks/#history) 7 | [![Downloads](https://static.pepy.tech/badge/slackblocks)](https://pepy.tech/project/slackblocks) 8 | [![Build Status](https://github.com/nicklambourne/slackblocks/actions/workflows/unit-tests.yml/badge.svg?branch=master)](https://github.com/nicklambourne/slackblocks/actions) 9 | [![Docs](https://img.shields.io/badge/Docs-8A2BE2.svg)](https://nicklambourne.github.io/slackblocks) 10 | 11 | ## What is it? 12 | `slackblocks` is a Python API for building messages in the fancy Slack [Block Kit API](https://api.slack.com/block-kit) 13 | 14 | ## Documentation 15 | Full documentation is provided [here](https://nicklambourne.github.io/slackblocks/latest/). 16 | 17 | ## Requirements 18 | `slackblocks` requires Python >= 3.8. 19 | 20 | As of version 0.1.0 it has no dependencies outside the Python standard library. 21 | 22 | ## Installation 23 | ```bash 24 | pip install slackblocks 25 | ``` 26 | 27 | ## Basic Usage 28 | ```python 29 | from slackblocks import Message, SectionBlock 30 | 31 | 32 | block = SectionBlock("Hello, world!") 33 | message = Message(channel="#general", blocks=block) 34 | message.json() 35 | 36 | ``` 37 | 38 | Will produce the following JSON string: 39 | ```json 40 | { 41 | "channel": "#general", 42 | "mrkdwn": true, 43 | "blocks": [ 44 | { 45 | "type": "section", 46 | "block_id": "992ceb6b-9ad4-496b-b8e6-1bd8a632e8b3", 47 | "text": { 48 | "type": "mrkdwn", 49 | "text": "Hello, world!" 50 | } 51 | } 52 | ] 53 | } 54 | ``` 55 | Which can be sent as payload to the Slack message API HTTP endpoints. 56 | 57 | Of more practical use is the ability to unpack the objects directly into 58 | the [(Legacy) Python Slack Client](https://pypi.org/project/slackclient/) in order to send messages: 59 | 60 | ```python 61 | from os import environ 62 | from slack import WebClient 63 | from slackblocks import Message, SectionBlock 64 | 65 | 66 | client = WebClient(token=environ["SLACK_API_TOKEN"]) 67 | block = SectionBlock("Hello, world!") 68 | message = Message(channel="#general", blocks=block) 69 | 70 | response = client.chat_postMessage(**message) 71 | ``` 72 | 73 | Or the modern Python [Slack SDK](https://pypi.org/project/slack-sdk/): 74 | ```python 75 | from os import environ 76 | from slack_sdk import WebClient 77 | from slackblocks import Message, SectionBlock 78 | 79 | 80 | client = WebClient(token=environ["SLACK_API_TOKEN"]) 81 | block = SectionBlock("Hello, world!") 82 | message = Message(channel="#general", blocks=block) 83 | 84 | response = client.chat_postMessage(**message) 85 | ``` 86 | 87 | Note the `**` operator in front of the `message` object. 88 | 89 | ## Can I use this in my project? 90 | Yes, please do! The code is all open source and dual BSD-3.0 and MIT licensed 91 | (use what suits you best). 92 | -------------------------------------------------------------------------------- /docs_src/usage/sending_messages.md: -------------------------------------------------------------------------------- 1 | `slackblocks` is designed primarily for use with either the [`slack-sdk`](https://pypi.org/project/slack-sdk/) or (legacy) [`slackclient`](https://pypi.org/project/slackclient/) Python packages. Usage of `slackblocks` remains identical regardless of which Slack client library you're using. 2 | 3 | While there's nothing stopping you from sending the rendered messages directly with `curl` or `requests`, we recommend using the `**` (dictionary unpacking)operator to unpack `slackblocks` `Messages` directly into the Slack `client`'s `chat_postMessage` function. 4 | 5 | An example of this is provided below along with the JSON result of rendering the message, an equivalent `curl` command, and finally the result of the message as it appears in the Slack user interface. 6 | 7 | ### Sending a Message with the (Modern) `slack-sdk` Library 8 | === "Python (`slackblocks`)" 9 | ```python 10 | from os import environ 11 | from slack_sdk import WebClient 12 | from slackblocks import Message, SectionBlock 13 | 14 | 15 | client = WebClient(token=environ["SLACK_API_TOKEN"]) 16 | block = SectionBlock("Hello, world!") 17 | message = Message(channel="#general", blocks=block) 18 | 19 | response = client.chat_postMessage(**message) 20 | ``` 21 | 22 | === "JSON Message" 23 | ```json 24 | { 25 | "channel": "#general", 26 | "mrkdwn": true, 27 | "blocks": [ 28 | { 29 | "type": "section", 30 | "block_id": "992ceb6b-9ad4-496b-b8e6-1bd8a632e8b3", 31 | "text": { 32 | "type": "mrkdwn", 33 | "text": "Hello, world!" 34 | } 35 | } 36 | ] 37 | } 38 | ``` 39 | * Note that the `block_id` field is a pseudorandomly generated UUID. You can pass a value to `Block` constructors should you desire deterministic `Blocks`. 40 | 41 | === "Equivalent `curl` Command" 42 | ```bash 43 | curl -H "Content-type: application/json" \ 44 | --data '{"channel":"#general","blocks":[{"type":"section", "block_id": "992ceb6b-9ad4-496b-b8e6-1bd8a632e8b3", "text":{"type":"mrkdwn","text":"Hello, world"}}]}' \ 45 | -H "Authorization: Bearer ${SLACK_API_TOKEN}" \ 46 | -X POST https://slack.com/api/chat.postMessage 47 | ``` 48 | 49 | === "Slack UI Output" 50 | ![Hello World Slack Image](../img/hello_world.png) 51 | 52 | 53 | ### Sending a Message with the (Legacy) `slackclient` Library 54 | === "Python (`slackblocks`)" 55 | ```python 56 | from os import environ 57 | from slack import WebClient 58 | from slackblocks import Message, SectionBlock 59 | 60 | 61 | client = WebClient(token=environ["SLACK_API_TOKEN"]) 62 | block = SectionBlock("Hello, world!") 63 | message = Message(channel="#general", blocks=block) 64 | 65 | response = client.chat_postMessage(**message) 66 | ``` 67 | 68 | === "JSON Message" 69 | ```json 70 | { 71 | "channel": "#general", 72 | "mrkdwn": true, 73 | "blocks": [ 74 | { 75 | "type": "section", 76 | "block_id": "992ceb6b-9ad4-496b-b8e6-1bd8a632e8b3", 77 | "text": { 78 | "type": "mrkdwn", 79 | "text": "Hello, world!" 80 | } 81 | } 82 | ] 83 | } 84 | ``` 85 | * Note that the `block_id` field is a pseudorandomly generated UUID. You can pass a value to `Block` constructors should you desire deterministic `Blocks`. 86 | 87 | === "Equivalent `curl` Command" 88 | ```bash 89 | curl -H "Content-type: application/json" \ 90 | --data '{"channel":"#general","blocks":[{"type":"section", "block_id": "992ceb6b-9ad4-496b-b8e6-1bd8a632e8b3", "text":{"type":"mrkdwn","text":"Hello, world"}}]}' \ 91 | -H "Authorization: Bearer ${SLACK_API_TOKEN}" \ 92 | -X POST https://slack.com/api/chat.postMessage 93 | ``` 94 | 95 | === "Slack UI Output" 96 | ![Hello World Slack Image](../img/hello_world.png) 97 | -------------------------------------------------------------------------------- /test/unit/test_messages.py: -------------------------------------------------------------------------------- 1 | from slackblocks import ( 2 | Attachment, 3 | Color, 4 | Message, 5 | MessageResponse, 6 | ResponseType, 7 | SectionBlock, 8 | Text, 9 | WebhookMessage, 10 | ) 11 | 12 | 13 | def test_basic_message() -> None: 14 | block = SectionBlock("Hello, world!", block_id="fake_block_id") 15 | message = Message(channel="#slackblocks", blocks=block) 16 | with open("test/samples/messages/message_basic.json", "r") as expected: 17 | assert repr(message) == expected.read() 18 | 19 | 20 | def test_message_with_optional_arguments() -> None: 21 | block = SectionBlock("Hello, world!", block_id="fake_block_id") 22 | message = Message( 23 | channel="#slackblocks", 24 | blocks=block, 25 | unfurl_links=False, 26 | unfurl_media=False, 27 | ) 28 | with open( 29 | "test/samples/messages/message_with_optional_arguments.json", "r" 30 | ) as expected: 31 | assert repr(message) == expected.read() 32 | 33 | 34 | def test_message_with_attachment() -> None: 35 | block = SectionBlock("Hello, world!", block_id="fake_block_id") 36 | attachment = Attachment(blocks=block, color=Color.YELLOW) 37 | message = Message( 38 | channel="#slackblocks", 39 | attachments=[ 40 | attachment, 41 | ], 42 | ) 43 | with open("test/samples/messages/message_with_attachments.json", "r") as expected: 44 | assert repr(message) == expected.read() 45 | 46 | 47 | def test_message_response() -> None: 48 | block = SectionBlock("Hello, world!", block_id="fake_block_id") 49 | message = MessageResponse(blocks=block, ephemeral=True) 50 | with open("test/samples/messages/message_response.json", "r") as expected: 51 | assert repr(message) == expected.read() 52 | 53 | 54 | def test_to_dict() -> None: 55 | block = SectionBlock("Hello, world!", block_id="fake_block_id") 56 | message = MessageResponse(blocks=block, ephemeral=True) 57 | assert message.to_dict() == { 58 | "mrkdwn": True, 59 | "blocks": [ 60 | { 61 | "type": "section", 62 | "block_id": "fake_block_id", 63 | "text": {"type": "mrkdwn", "text": "Hello, world!"}, 64 | } 65 | ], 66 | "text": "", 67 | "replace_original": False, 68 | "response_type": "ephemeral", 69 | } 70 | 71 | 72 | def test_basic_webhook_message() -> None: 73 | with open("test/samples/messages/webhook_message_basic.json", "r") as expected: 74 | assert ( 75 | repr( 76 | WebhookMessage( 77 | blocks=[ 78 | SectionBlock( 79 | Text("You wouldn't do ol' Hook in now, would you, lad?"), 80 | block_id="fake_block_id", 81 | ), 82 | SectionBlock( 83 | Text("Well, all right... if you... say you're a codfish."), 84 | block_id="fake_block_id", 85 | ), 86 | ], 87 | response_type=ResponseType.EPHEMERAL, 88 | replace_original=True, 89 | unfurl_links=False, 90 | unfurl_media=False, 91 | metadata={ 92 | "sender": "Walt", 93 | }, 94 | ) 95 | ) 96 | == expected.read() 97 | ) 98 | 99 | 100 | def test_webhook_message_delete() -> None: 101 | with open("test/samples/messages/webhook_message_delete.json", "r") as expected: 102 | assert ( 103 | repr( 104 | WebhookMessage( 105 | attachments=[ 106 | Attachment( 107 | blocks=[ 108 | SectionBlock( 109 | Text("I'M A CODFISH!"), 110 | block_id="fake_block_id", 111 | ) 112 | ] 113 | ) 114 | ], 115 | blocks=[ 116 | SectionBlock( 117 | Text("I'm a codfish."), 118 | block_id="fake_block_id", 119 | ), 120 | SectionBlock( 121 | Text("Louder!"), 122 | block_id="fake_block_id", 123 | ), 124 | ], 125 | response_type="in_channel", 126 | delete_original=True, 127 | unfurl_links=True, 128 | unfurl_media=True, 129 | metadata={ 130 | "sender": "Walt", 131 | }, 132 | ) 133 | ) 134 | == expected.read() 135 | ) 136 | -------------------------------------------------------------------------------- /test/unit/test_objects.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from slackblocks.errors import InvalidUsageError 4 | from slackblocks.objects import ( 5 | ConfirmationDialogue, 6 | ConversationFilter, 7 | DispatchActionConfiguration, 8 | InputParameter, 9 | Option, 10 | OptionGroup, 11 | Text, 12 | TextType, 13 | Trigger, 14 | Workflow, 15 | ) 16 | 17 | from .utils import THREE_OPTIONS, fetch_sample 18 | 19 | INPUT_PARAMETERS = [ 20 | InputParameter( 21 | name="A", 22 | value="A", 23 | ), 24 | InputParameter( 25 | name="B", 26 | value="B", 27 | ), 28 | ] 29 | 30 | 31 | def test_text_basic() -> None: 32 | assert fetch_sample(path="test/samples/objects/text_plaintext_basic.json") == repr( 33 | Text(text="hi", type_=TextType.PLAINTEXT) 34 | ) 35 | assert fetch_sample(path="test/samples/objects/text_markdown_basic.json") == repr( 36 | Text(text="hi", type_=TextType.MARKDOWN) 37 | ) 38 | 39 | 40 | def test_text_plaintext_emoji() -> None: 41 | assert fetch_sample(path="test/samples/objects/text_plaintext_emoji.json") == repr( 42 | Text(text="hi", type_=TextType.PLAINTEXT, emoji=True) 43 | ) 44 | 45 | 46 | def test_text_markdown_verbatim() -> None: 47 | assert fetch_sample( 48 | path="test/samples/objects/text_markdown_verbatim.json" 49 | ) == repr(Text(text="hi", type_=TextType.MARKDOWN, verbatim=True)) 50 | 51 | 52 | def test_text_coerce_from_string() -> None: 53 | assert fetch_sample(path="test/samples/objects/text_markdown_basic.json") == repr( 54 | Text.to_text("hi") 55 | ) 56 | 57 | 58 | def test_text_coerce_from_text() -> None: 59 | assert fetch_sample(path="test/samples/objects/text_markdown_basic.json") == repr( 60 | Text.to_text(Text("hi")) 61 | ) 62 | 63 | 64 | def test_text_allow_none() -> None: 65 | assert Text.to_text(None, allow_none=True) is None 66 | 67 | 68 | def test_text_disallow_none() -> None: 69 | with pytest.raises(InvalidUsageError): 70 | assert Text.to_text(None) 71 | 72 | 73 | def test_text_coerce_from_invalid() -> None: 74 | with pytest.raises(InvalidUsageError): 75 | assert Text.to_text(123) 76 | 77 | 78 | def test_text_coerce_max_length_exceeded() -> None: 79 | with pytest.raises(InvalidUsageError): 80 | assert Text.to_text("abcdef", max_length=5) 81 | 82 | 83 | def test_confirmation_dialogue_basic() -> None: 84 | confirmation_dialogue = ConfirmationDialogue( 85 | title=Text("Maybe?", type_=TextType.PLAINTEXT), 86 | text=Text("Would you like to play checkers?", type_=TextType.PLAINTEXT), 87 | confirm=Text("Yes", type_=TextType.PLAINTEXT), 88 | deny=Text("Nope!", type_=TextType.PLAINTEXT), 89 | ) 90 | assert fetch_sample( 91 | path="test/samples/objects/confirmation_dialogue_basic.json" 92 | ) == repr(confirmation_dialogue) 93 | 94 | 95 | def test_conversation_filter_basic() -> None: 96 | conversation_filter = ConversationFilter( 97 | include=[ 98 | "public", 99 | "mpim", 100 | ], 101 | exclude_bot_users=True, 102 | ) 103 | assert fetch_sample( 104 | path="test/samples/objects/conversation_filter_basic.json" 105 | ) == repr(conversation_filter) 106 | 107 | 108 | def test_dispatch_action_config_basic() -> None: 109 | dispatch_action_config = DispatchActionConfiguration( 110 | trigger_actions_on=["on_character_entered"] 111 | ) 112 | assert fetch_sample( 113 | path="test/samples/objects/dispatch_action_configuration_basic.json" 114 | ) == repr(dispatch_action_config) 115 | 116 | 117 | def test_input_parameter_basic() -> None: 118 | input_parameter = InputParameter( 119 | name="name", 120 | value="value", 121 | ) 122 | assert fetch_sample(path="test/samples/objects/input_parameter_basic.json") == repr( 123 | input_parameter 124 | ) 125 | 126 | 127 | def test_option_basic() -> None: 128 | option = Option( 129 | text=Text(text="Canberra", type_=TextType.PLAINTEXT), 130 | value="canberra", 131 | ) 132 | assert fetch_sample(path="test/samples/objects/option_basic.json") == repr(option) 133 | 134 | 135 | def test_option_group_basic() -> None: 136 | option_group = OptionGroup( 137 | label="Group A", 138 | options=THREE_OPTIONS, 139 | ) 140 | assert fetch_sample(path="test/samples/objects/option_group_basic.json") == repr( 141 | option_group 142 | ) 143 | 144 | 145 | def test_trigger_basic() -> None: 146 | trigger = Trigger( 147 | url="https://slack.com/shortcuts/Ft012KXZK1MZ/8831723c452aac3e87c6d3219bebd44c", 148 | customizable_input_parameters=INPUT_PARAMETERS, 149 | ) 150 | assert fetch_sample(path="test/samples/objects/trigger_basic.json") == repr(trigger) 151 | 152 | 153 | def test_workflow_basic() -> None: 154 | workflow = Workflow( 155 | trigger=Trigger( 156 | url="https://slack.com/shortcuts/Ft012KXZK1MZ/8831723c452aac3e87c6d3219bebd44c", 157 | customizable_input_parameters=INPUT_PARAMETERS, 158 | ) 159 | ) 160 | assert fetch_sample(path="test/samples/objects/workflow_basic.json") == repr( 161 | workflow 162 | ) 163 | -------------------------------------------------------------------------------- /test/unit/test_rich_text.py: -------------------------------------------------------------------------------- 1 | from slackblocks.rich_text import ( 2 | ListType, 3 | RichText, 4 | RichTextChannel, 5 | RichTextCodeBlock, 6 | RichTextEmoji, 7 | RichTextLink, 8 | RichTextList, 9 | RichTextQuote, 10 | RichTextSection, 11 | RichTextUser, 12 | RichTextUserGroup, 13 | ) 14 | 15 | from .utils import fetch_sample 16 | 17 | 18 | def test_rich_text_channel_basic() -> None: 19 | assert fetch_sample( 20 | path="test/samples/rich_text/rich_text_channel_basic.json" 21 | ) == repr( 22 | RichTextChannel( 23 | channel_id="C0261C65XNY", 24 | bold=True, 25 | italic=False, 26 | strike=True, 27 | highlight=True, 28 | client_highlight=True, 29 | unlink=False, 30 | ) 31 | ) 32 | 33 | 34 | def test_rich_text_emoji_basic() -> None: 35 | assert fetch_sample( 36 | path="test/samples/rich_text/rich_text_emoji_basic.json" 37 | ) == repr( 38 | RichTextEmoji( 39 | name="wave", 40 | ) 41 | ) 42 | 43 | 44 | def test_rich_text_link_basic() -> None: 45 | assert fetch_sample( 46 | path="test/samples/rich_text/rich_text_link_basic.json" 47 | ) == repr( 48 | RichTextLink( 49 | url="https://google.com/", 50 | text="Google", 51 | unsafe=False, 52 | bold=True, 53 | italic=False, 54 | strike=True, 55 | code=True, 56 | ) 57 | ) 58 | 59 | 60 | def test_rich_text_basic() -> None: 61 | assert fetch_sample(path="test/samples/rich_text/rich_text_basic.json") == repr( 62 | RichText( 63 | text="I am a bold rich text block!", 64 | bold=True, 65 | italic=True, 66 | strike=False, 67 | ) 68 | ) 69 | 70 | 71 | def test_rich_text_user_basic() -> None: 72 | assert fetch_sample( 73 | path="test/samples/rich_text/rich_text_user_basic.json" 74 | ) == repr( 75 | RichTextUser( 76 | user_id="DR36TNNLA", 77 | bold=True, 78 | italic=False, 79 | strike=True, 80 | highlight=True, 81 | client_highlight=True, 82 | unlink=False, 83 | ) 84 | ) 85 | 86 | 87 | def test_rich_text_user_group_basic() -> None: 88 | assert fetch_sample( 89 | path="test/samples/rich_text/rich_text_user_group_basic.json" 90 | ) == repr( 91 | RichTextUserGroup( 92 | user_group_id="C01RGRU0RUK", 93 | bold=True, 94 | italic=False, 95 | strike=True, 96 | highlight=True, 97 | client_highlight=True, 98 | unlink=False, 99 | ) 100 | ) 101 | 102 | 103 | CHANNEL = "channel" 104 | EMOJI = "emoji" 105 | LINK = "link" 106 | TEXT = "text" 107 | USER = "user" 108 | USER_GROUP = "user_group" 109 | 110 | 111 | def test_rich_text_list_basic() -> None: 112 | assert fetch_sample( 113 | path="test/samples/rich_text/rich_text_list_basic.json" 114 | ) == repr( 115 | RichTextList( 116 | elements=[ 117 | RichTextSection( 118 | elements=[ 119 | RichText( 120 | text="Oh", 121 | ) 122 | ] 123 | ), 124 | RichTextSection( 125 | elements=[ 126 | RichText( 127 | text="Hi", 128 | ) 129 | ] 130 | ), 131 | RichTextSection(elements=[RichText(text="Mark")]), 132 | ], 133 | style=ListType.BULLET, 134 | indent=0, 135 | offset=0, 136 | border=1, 137 | ) 138 | ) 139 | 140 | 141 | def test_rich_text_list_ordered() -> None: 142 | assert fetch_sample( 143 | path="test/samples/rich_text/rich_text_list_ordered.json" 144 | ) == repr( 145 | RichTextList( 146 | elements=[ 147 | RichTextSection( 148 | elements=[ 149 | RichText( 150 | text="Oh", 151 | ) 152 | ] 153 | ), 154 | RichTextSection( 155 | elements=[ 156 | RichText( 157 | text="Hi", 158 | ) 159 | ] 160 | ), 161 | ], 162 | style=ListType.ORDERED, 163 | indent=1, 164 | offset=2, 165 | border=3, 166 | ) 167 | ) 168 | 169 | 170 | def test_rich_tex_code_block_basic() -> None: 171 | assert fetch_sample( 172 | path="test/samples/rich_text/rich_text_code_block_basic.json" 173 | ) == repr( 174 | RichTextCodeBlock( 175 | elements=[RichText(text="\ndef hello_world():\n print('hello, world')")], 176 | border=0, 177 | ) 178 | ) 179 | 180 | 181 | def test_rich_text_quote_basic() -> None: 182 | assert fetch_sample( 183 | path="test/samples/rich_text/rich_text_quote_basic.json" 184 | ) == repr( 185 | RichTextQuote( 186 | elements=[RichText(text="Great and good are seldom the same man")], border=1 187 | ), 188 | ) 189 | 190 | 191 | def test_rich_text_section() -> None: 192 | assert fetch_sample( 193 | path="test/samples/rich_text/rich_text_section_basic.json" 194 | ) == repr( 195 | RichTextSection( 196 | elements=[ 197 | RichText(text="The only true wisdom is in knowing you know nothing") 198 | ] 199 | ) 200 | ) 201 | -------------------------------------------------------------------------------- /slackblocks/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Views are app-customized visual areas within modals and Home tabs. 3 | 4 | See: . 5 | """ 6 | 7 | from enum import Enum 8 | from json import dumps 9 | from typing import Any, Dict, List, Optional, Union 10 | 11 | from slackblocks.blocks import Block 12 | from slackblocks.objects import Text, TextLike 13 | from slackblocks.utils import coerce_to_list, validate_string 14 | 15 | 16 | class ViewType(Enum): 17 | MODAL = "modal" 18 | HOME = "home" 19 | 20 | 21 | class View: 22 | """ """ 23 | 24 | def __init__( 25 | self, 26 | type: ViewType, 27 | blocks: Union[Block, List[Block]], 28 | private_metadata: Optional[str] = None, 29 | callback_id: Optional[str] = None, 30 | external_id: Optional[str] = None, 31 | ) -> None: 32 | self.type_ = type.value 33 | self.blocks = coerce_to_list(blocks, class_=Block, min_size=1, max_size=100) 34 | self.private_metadata = validate_string( 35 | private_metadata, 36 | field_name="private_metadata", 37 | max_length=3000, 38 | allow_none=True, 39 | ) 40 | self.callback_id = validate_string( 41 | callback_id, field_name="callback_id", max_length=255, allow_none=True 42 | ) 43 | self.external_id = external_id 44 | 45 | def _resolve(self) -> Dict[str, Any]: 46 | view: Dict[str, Any] = {} 47 | view["type"] = self.type_ 48 | if self.blocks is not None: 49 | view["blocks"] = [block._resolve() for block in self.blocks] 50 | if self.private_metadata: 51 | view["private_metadata"] = self.private_metadata 52 | if self.callback_id: 53 | view["callback_id"] = self.callback_id 54 | if self.external_id: 55 | view["external_id"] = self.external_id 56 | return view 57 | 58 | def to_dict(self) -> Dict[str, Any]: 59 | return self._resolve() 60 | 61 | def __repr__(self) -> str: 62 | return dumps(self._resolve(), indent=4) 63 | 64 | 65 | class ModalView(View): 66 | """ 67 | Modal views are used with the `views.open`, `views.update` and `views.push` 68 | Slack Web API methods. 69 | 70 | See: 71 | 72 | Args: 73 | title: heading that appears at the top left of the view. 74 | blocks: a list of blocks (max 100) that define the content of the view. 75 | close: the text of the close button (max 24 chars) in the view. 76 | Must be `Text.PLAINTEXT`. 77 | submit: the text of the submit button (max 24 chars) in the view. 78 | Must be `Text.PLAINTEXT`. 79 | private_metadata: a string (max 3000 chars) that will be sent to your app 80 | in `view_submission`. 81 | callback_id: A string that will identify submissions of this view. 82 | clear_on_close: when `True` all views in the model will be cleared when 83 | it is closed. 84 | notify_on_close: when `True` a `view_closed` event will be sent when the 85 | modal is closed. 86 | external_id: A custom identifier that is unique within the views of a 87 | given Slack team. 88 | submit_disabled: when `True` disabled submitting the form until one or 89 | more inputs have been provided. Used only for 90 | [`configuaration models`](https://api.slack.com/reference/workflows/configuration-view). 91 | """ 92 | 93 | def __init__( 94 | self, 95 | title: TextLike, 96 | blocks: Union[Block, List[Block]], 97 | close: Optional[TextLike] = None, 98 | submit: Optional[TextLike] = None, 99 | private_metadata: Optional[str] = None, 100 | callback_id: Optional[str] = None, 101 | clear_on_close: Optional[bool] = False, 102 | notify_on_close: Optional[bool] = False, 103 | external_id: Optional[str] = None, 104 | submit_disabled: Optional[bool] = False, 105 | ) -> None: 106 | super().__init__( 107 | type=ViewType.MODAL, 108 | blocks=blocks, 109 | private_metadata=private_metadata, 110 | callback_id=callback_id, 111 | external_id=external_id, 112 | ) 113 | self.title = Text.to_text_nonnull(title, force_plaintext=True, max_length=24) 114 | self.close = Text.to_text( 115 | close, force_plaintext=True, max_length=24, allow_none=True 116 | ) 117 | self.submit = Text.to_text( 118 | submit, force_plaintext=True, max_length=24, allow_none=True 119 | ) 120 | self.clear_on_close = clear_on_close 121 | self.notify_on_close = notify_on_close 122 | self.submit_disabled = submit_disabled 123 | 124 | def _resolve(self) -> Dict[str, Any]: 125 | modal_view = super()._resolve() 126 | modal_view["title"] = self.title._resolve() 127 | if self.close: 128 | modal_view["close"] = self.close._resolve() 129 | if self.submit: 130 | modal_view["submit"] = self.submit._resolve() 131 | if self.clear_on_close: 132 | modal_view["clear_on_close"] = self.clear_on_close 133 | if self.notify_on_close: 134 | modal_view["notify_on_close"] = self.notify_on_close 135 | if self.submit_disabled: 136 | modal_view["submit_disabled"] = self.submit_disabled 137 | return modal_view 138 | 139 | 140 | class HomeTabView(View): 141 | """ 142 | `HomeTabViews` are used with the `views.publish` Web API method. 143 | 144 | See: . 145 | 146 | Args: 147 | blocks: A list of blocks that defines the content of the view (max 100). 148 | private_metadata: a string (max 3000 chars) that will be sent to your app 149 | in `view_submission`. 150 | callback_id: A string that will identify submissions of this view. 151 | external_id: A custom identifier that is unique within the views of a 152 | given Slack team. 153 | """ 154 | 155 | def __init__( 156 | self, 157 | blocks: Union[Block, List[Block]], 158 | private_metadata: Optional[str] = None, 159 | callback_id: Optional[str] = None, 160 | external_id: Optional[str] = None, 161 | ) -> None: 162 | super().__init__( 163 | type=ViewType.HOME, 164 | blocks=blocks, 165 | private_metadata=private_metadata, 166 | callback_id=callback_id, 167 | external_id=external_id, 168 | ) 169 | -------------------------------------------------------------------------------- /slackblocks/attachments.py: -------------------------------------------------------------------------------- 1 | """ 2 | Secondary (less important) content can be attached using the deprecated 3 | attachments API. 4 | 5 | See: . 6 | """ 7 | 8 | from enum import Enum 9 | from json import dumps 10 | from typing import Any, Dict, List, Optional, Union 11 | 12 | from slackblocks.blocks import Block 13 | from slackblocks.errors import InvalidUsageError 14 | from slackblocks.utils import coerce_to_list, is_hex 15 | 16 | 17 | class Color(Enum): 18 | """ 19 | Color is a utility class for use with the Slack secondary attachments API. 20 | 21 | Pass these to the `color` argument of 22 | [`Attachment`](/slackblocks/latest/reference/attachments/#attachments.Attachment). 23 | 24 | 25 | 26 | 27 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 45 | 48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 | 66 | 67 | 68 | 69 | 72 | 73 | 74 | 75 | 78 | 79 | 80 | 81 | 84 | 85 |
Color.GOOD 28 | good 29 |
Color.WARNING 34 | warning 35 |
Color.DANGER 40 | danger 41 |
Color.RED 46 | red 47 |
Color.BLUE 52 | blue 53 |
Color.YELLOW 58 | yellow 59 |
Color.GREEN 64 | green 65 |
Color.ORANGE 70 | orange 71 |
Color.PURPLE 76 | purple 77 |
Color.BLACK 82 | black 83 |
86 | """ 87 | 88 | GOOD = "good" 89 | WARNING = "warning" 90 | DANGER = "danger" 91 | RED = "#ff0000" 92 | BLUE = "#0000ff" 93 | YELLOW = "#ffff00" 94 | GREEN = "#00ff00" 95 | ORANGE = "#ff8800" 96 | PURPLE = "#8800ff" 97 | BLACK = "#000000" 98 | 99 | def __repr__(self) -> str: 100 | return f"" 101 | 102 | 103 | class Field: 104 | """ 105 | Field text objects for use with Slack's secondary attachment API. 106 | 107 | See . 108 | 109 | Args: 110 | title: text shown as a bold heading on the field. 111 | value: text (`mrkdwn` or `plaintext`) representing the value of the field. 112 | short: whether the contents of the field is short enough to be presented in 113 | multipe columns. 114 | """ 115 | 116 | def __init__( 117 | self, 118 | title: Optional[str] = None, 119 | value: Optional[str] = None, 120 | short: Optional[bool] = False, 121 | ): 122 | self.title = title 123 | self.value = value 124 | self.short = short 125 | 126 | def _resolve(self): 127 | field = dict() 128 | field["short"] = self.short 129 | if self.title: 130 | field["title"] = self.title 131 | if self.value: 132 | field["value"] = self.value 133 | return dumps(field) 134 | 135 | 136 | class Attachment: 137 | """ 138 | Lower priority content can be attached to messages using Attachments. 139 | This is content that doesn't necessarily need to be seen to appreciate 140 | the intent of the message, but perhaps adds further context or additional information. 141 | 142 | See . 143 | 144 | N.B: `fields` is a deprecated field, included only for legacy purposes. Other legacy 145 | fields, e.g. `author_name` are deliberately omitted as they were never implemented in 146 | `slackblocks`. 147 | 148 | Args: 149 | blocks: an array of Blocks that define the content of the attachment. 150 | color: the color (in hex format, e.g. #ffffff) of the vertical bar to the left of the 151 | attachment content. Consider using the `Color` enum from this module. 152 | fields: a list of `Field` objects to be included in what's rendered in the attachment. 153 | fallback: A plain text summary of the attachment used in clients that don't show 154 | formatted text (eg. IRC, mobile notifications). 155 | 156 | Throws: 157 | InvalidUsageError: if the `color` code provided is invalid. 158 | """ 159 | 160 | def __init__( 161 | self, 162 | blocks: Optional[Union[Block, List[Block]]] = None, 163 | color: Optional[Union[str, Color]] = None, 164 | fields: Optional[Union[Field, List[Field]]] = None, 165 | fallback: Optional[str] = None, 166 | ): 167 | self.blocks = coerce_to_list(blocks, Block, allow_none=True) 168 | self.fields = coerce_to_list(fields, Field, allow_none=True) 169 | self.fallback = fallback 170 | self.color: Optional[str] 171 | if type(color) is Color: 172 | self.color = color.value 173 | elif type(color) is str: 174 | if len(color) == 7 and color.startswith("#") and is_hex(color[1:]): 175 | self.color = color 176 | elif len(color) == 6 and is_hex(color): 177 | self.color = f"#{color}" 178 | else: 179 | raise InvalidUsageError( 180 | "Color must be a valid hex code (e.g. `#ffffff`)" 181 | ) 182 | else: 183 | self.color = None 184 | 185 | def _resolve(self) -> Dict[str, Any]: 186 | attachment: Dict[str, Any] = {} 187 | if self.blocks: 188 | attachment["blocks"] = [block._resolve() for block in self.blocks] 189 | if self.color: 190 | attachment["color"] = self.color 191 | if self.fallback: 192 | attachment["fallback"] = self.fallback 193 | return attachment 194 | 195 | def __repr__(self) -> str: 196 | return dumps(self._resolve(), indent=4) 197 | -------------------------------------------------------------------------------- /test/unit/test_blocks.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from slackblocks import ( 6 | ActionsBlock, 7 | CheckboxGroup, 8 | ColumnSettings, 9 | ContextBlock, 10 | DividerBlock, 11 | FileBlock, 12 | HeaderBlock, 13 | ImageBlock, 14 | InputBlock, 15 | InvalidUsageError, 16 | Option, 17 | PlainTextInput, 18 | RawText, 19 | RichText, 20 | RichTextBlock, 21 | RichTextSection, 22 | SectionBlock, 23 | TableBlock, 24 | Text, 25 | TextType, 26 | ) 27 | from slackblocks.rich_text import RichTextLink 28 | 29 | from .utils import fetch_sample 30 | 31 | 32 | def test_basic_section_block() -> None: 33 | block = SectionBlock("Hello, world!", block_id="fake_block_id") 34 | assert fetch_sample( 35 | path="test/samples/blocks/section_block_text_only.json" 36 | ) == repr(block) 37 | 38 | 39 | def test_basic_section_fields() -> None: 40 | block = SectionBlock( 41 | "Test:", 42 | fields=[Text(text="foo", type_=TextType.PLAINTEXT), Text(text="bar")], 43 | block_id="fake_block_id", 44 | ) 45 | assert fetch_sample(path="test/samples/blocks/section_block_fields.json") == repr( 46 | block 47 | ) 48 | 49 | 50 | def test_section_empty_text_field_value() -> None: 51 | block = SectionBlock( 52 | block_id="fake_block_id", 53 | fields=[ 54 | Text("Highly", type_=TextType.MARKDOWN), 55 | Text("Strung", type_=TextType.PLAINTEXT, emoji=True), 56 | ], 57 | ) 58 | assert fetch_sample( 59 | path="test/samples/blocks/section_block_empty_text_field_value.json" 60 | ) == repr(block) 61 | 62 | 63 | def test_section_neither_fields_nor_text() -> None: 64 | with pytest.raises(InvalidUsageError): 65 | SectionBlock( 66 | block_id="fake_block_id", 67 | text=None, 68 | fields=None, 69 | ) 70 | 71 | 72 | def test_section_invalid_field_content() -> None: 73 | with pytest.raises(InvalidUsageError): 74 | SectionBlock( 75 | block_id="fake_block_id", 76 | fields=[ 77 | None, 78 | ], 79 | ) 80 | 81 | 82 | def test_section_single_field_value_coercion() -> None: 83 | block = SectionBlock( 84 | block_id="fake_block_id", 85 | fields="Lowly", 86 | ) 87 | assert fetch_sample( 88 | path="test/samples/blocks/section_block_single_field_value_coercion.json" 89 | ) == repr(block) 90 | 91 | 92 | def test_section_both_text_and_fields() -> None: 93 | block = SectionBlock( 94 | text="Hello", 95 | block_id="fake_block_id", 96 | fields=[ 97 | Text("Are you", type_=TextType.MARKDOWN), 98 | Text("There?", type_=TextType.PLAINTEXT, emoji=True), 99 | ], 100 | ) 101 | assert fetch_sample( 102 | path="test/samples/blocks/section_block_both_text_and_fields.json" 103 | ) == repr(block) 104 | 105 | 106 | def test_basic_context_block() -> None: 107 | block = ContextBlock(elements=[Text("Hello, world!")], block_id="fake_block_id") 108 | assert fetch_sample( 109 | path="test/samples/blocks/context_block_text_only.json" 110 | ) == repr(block) 111 | 112 | 113 | def test_basic_divider_block() -> None: 114 | block = DividerBlock(block_id="fake_block_id") 115 | assert fetch_sample(path="test/samples/blocks/divider_block_only.json") == repr( 116 | block 117 | ) 118 | 119 | 120 | def test_basic_image_block() -> None: 121 | block = ImageBlock( 122 | image_url="https://api.slack.com/img/blocks/bkb_template_images/beagle.png", 123 | alt_text="image1", 124 | title="image1", 125 | block_id="fake_block_id", 126 | ) 127 | assert fetch_sample(path="test/samples/blocks/image_block_only.json") == repr(block) 128 | 129 | 130 | def test_basic_header_block() -> None: 131 | block = HeaderBlock(text="AloHa!", block_id="fake_block_id") 132 | assert fetch_sample(path="test/samples/blocks/header_block_only.json") == repr( 133 | block 134 | ) 135 | 136 | 137 | def test_checkbox_action_block() -> None: 138 | options = [ 139 | Option(text="*a*", value="a", description="*a*"), 140 | Option(text="*b*", value="b", description="*b*"), 141 | Option(text="*c*", value="c", description="*c*"), 142 | ] 143 | block = ActionsBlock( 144 | block_id="fake_block_id", 145 | elements=CheckboxGroup(action_id="actionId-0", options=options), 146 | ) 147 | assert fetch_sample( 148 | path="test/samples/blocks/actions_block_checkboxes.json" 149 | ) == repr(block) 150 | 151 | 152 | def test_basic_input_block() -> None: 153 | block = InputBlock( 154 | label=Text("Label", type_=TextType.PLAINTEXT, emoji=True), 155 | hint=Text("Hint", type_=TextType.PLAINTEXT, emoji=True), 156 | element=PlainTextInput(action_id="action"), 157 | block_id="fake_block_id", 158 | optional=True, 159 | ) 160 | assert fetch_sample(path="test/samples/blocks/input_block_only.json") == repr(block) 161 | 162 | 163 | def test_input_block_invalid_element() -> None: 164 | with pytest.raises(InvalidUsageError): 165 | InputBlock( 166 | label=Text("Label", type_=TextType.PLAINTEXT, emoji=True), 167 | hint=Text("Hint", type_=TextType.PLAINTEXT, emoji=True), 168 | element=Text("hello"), 169 | block_id="fake_block_id", 170 | ) 171 | 172 | 173 | def test_input_block_invalid_label_type() -> None: 174 | with pytest.raises(InvalidUsageError): 175 | InputBlock( 176 | label=Text("Label", type_=TextType.MARKDOWN), 177 | hint=Text("Hint", type_=TextType.PLAINTEXT, emoji=True), 178 | element=Text("hello"), 179 | block_id="fake_block_id", 180 | ) 181 | 182 | 183 | def test_basic_rich_text_block() -> None: 184 | assert fetch_sample(path="test/samples/blocks/rich_text_block_basic.json") == repr( 185 | RichTextBlock( 186 | RichTextSection( 187 | [ 188 | RichText( 189 | "You 'bout to witness hip-hop in its most purest", 190 | bold=True, 191 | ), 192 | RichText( 193 | "Most rawest form, flow almost flawless", 194 | strike=True, 195 | ), 196 | RichText( 197 | "Most hardest, most honest known artist", 198 | italic=True, 199 | ), 200 | ] 201 | ), 202 | block_id="fake_block_id", 203 | ) 204 | ) 205 | 206 | 207 | def test_basic_table_block() -> None: 208 | block = TableBlock( 209 | column_settings=[ 210 | ColumnSettings(is_wrapped=True), 211 | ColumnSettings(align="right"), 212 | ], 213 | rows=[ 214 | [ 215 | RawText(text="Header A"), 216 | RawText(text="Header B"), 217 | ], 218 | [ 219 | RawText(text="Data 1A"), 220 | RichTextSection( 221 | elements=RichTextLink( 222 | url="https://slack.com", 223 | text="Data 1B", 224 | ) 225 | ), 226 | ], 227 | [ 228 | RawText(text="Data 2A"), 229 | RichTextSection( 230 | elements=RichTextLink( 231 | url="https://slack.com", 232 | text="Data 2B", 233 | ) 234 | ), 235 | ], 236 | ], 237 | ) 238 | # Add block_id to the sample as it is auto-generated 239 | sample = json.loads(fetch_sample(path="test/samples/blocks/table_block.json")) 240 | sample["block_id"] = block.block_id 241 | assert sample == json.loads(repr(block)) 242 | 243 | 244 | def text_basic_file_block() -> None: 245 | assert fetch_sample(path="test/samples/blocks/rich_text_block_basic.json") == repr( 246 | FileBlock( 247 | external_id="external_id", 248 | block_id="fake_block_id", 249 | ) 250 | ) 251 | -------------------------------------------------------------------------------- /slackblocks/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module collects various utility functions used for validating 3 | the input to `Messages`, `Blocks`, `Elements` and `Objects`. 4 | """ 5 | 6 | from string import hexdigits 7 | from typing import Any, List, Optional, TypeVar, Union 8 | 9 | from slackblocks.errors import InvalidUsageError 10 | 11 | T = TypeVar("T") 12 | 13 | 14 | def coerce_to_list_nonnull( 15 | object_or_objects: Union[T, List[T]], 16 | class_: Union[Any, List[Any]], 17 | min_size: Optional[int] = None, 18 | max_size: Optional[int] = None, 19 | ) -> List[T]: 20 | """ 21 | Takes an object or list of objects and validates its contents, ensuring that the 22 | resulting object is a list. This version does not handle None values. 23 | 24 | Args: 25 | object_or_objects: the Python object or objects to validate and convert to a list. 26 | class_: the Python type (or class) of objects expected in the list. 27 | min_size: if provided, the length of `object_or_objects` cannot be smaller than this. 28 | max_size: if provided, the length of `object_or_objects` cannot be larger than this. 29 | 30 | Returns: 31 | `object_or_objects` if it was a valid list, `[object_or_objects]` if it was a valid object. 32 | 33 | Throws: 34 | InvalidUsageError: if any of the validation checks fail. 35 | """ 36 | if isinstance(object_or_objects, List): 37 | items = object_or_objects 38 | else: 39 | items = [object_or_objects] 40 | 41 | for item in items: 42 | if not isinstance(class_, tuple): 43 | class_ = (class_,) 44 | if not isinstance(item, class_): 45 | raise InvalidUsageError( 46 | f"Type of {item} ({type(item)})) inconsistent with expected type {class_}." 47 | ) 48 | 49 | length = len(items) 50 | if min_size is not None and length < min_size: 51 | raise InvalidUsageError( 52 | f"Size ({length}) of list of {type(class_)} is less than `min_size` ({min_size})" 53 | ) 54 | 55 | if max_size is not None and length > max_size: 56 | raise InvalidUsageError( 57 | f"Size ({length}) of list of {type(class_)} exceeds `max_size` ({max_size})" 58 | ) 59 | 60 | return items 61 | 62 | 63 | def coerce_to_list( 64 | object_or_objects: Optional[Union[T, List[T]]], 65 | class_: Union[Any, List[Any]], 66 | allow_none: bool = False, 67 | min_size: Optional[int] = None, 68 | max_size: Optional[int] = None, 69 | ) -> Optional[List[T]]: 70 | """ 71 | Takes and object or list of objects and validates its contents, ensuring that the 72 | resulting object is a list. 73 | 74 | Args: 75 | object_or_objects: the Python object or objects to validate and convert to a list. 76 | class_: the Python type (or class) of objects expected in the list. 77 | allow_none: whether or not None is a valid input (and thus output) option. 78 | min_size: if provided, the length of `object_or_objects` cannot be smaller than this. 79 | max_size: if provided, the length of `object_or_objects` cannot be larger than this. 80 | 81 | Returns: 82 | `object_or_objects` if it was a valid list, `[object_or_objects]` if it was a valid 83 | object, or `None` if provided and allowed. 84 | 85 | Throws: 86 | InvalidUsageError: if any of the validation checks fail. 87 | """ 88 | if object_or_objects is None: 89 | if allow_none: 90 | return None 91 | raise InvalidUsageError( 92 | f"Type of {object_or_objects} ({type(object_or_objects)})) is " 93 | f"None should be type `{class_}`." 94 | ) 95 | 96 | return coerce_to_list_nonnull(object_or_objects, class_, min_size, max_size) 97 | 98 | 99 | def is_hex(string: str) -> bool: 100 | """ 101 | Determines whether a given string is a valid hexadecimal number. 102 | 103 | Args: 104 | string: the string to examine for hex characters. 105 | 106 | Returns: 107 | `True` if the string is a valid hexadecimal number, otherwise `False`. 108 | """ 109 | return all(char in hexdigits for char in string) 110 | 111 | 112 | def validate_action_id( 113 | action_id: Optional[str], allow_none: bool = False 114 | ) -> Optional[str]: 115 | """ 116 | Action IDs are used in the handing of user interactivity within Slack blocks. 117 | This function checks that a given `action_id` is valid as per the requirements 118 | imposed by the Slack API. 119 | 120 | See: 121 | 122 | Args: 123 | action_id: the action_id string to validate for correctness as per the Slack API. 124 | allow_none: whether to accept `None` as a valid value for `action_id`. 125 | 126 | Returns: 127 | The original value `action_id` if all validation checks pass. 128 | 129 | Throws: 130 | InvalidUsageError if any of the validation checks fail. 131 | """ 132 | if action_id is None: 133 | if not allow_none: 134 | raise InvalidUsageError("`action_id` cannot be None.") 135 | else: 136 | length = len(action_id) 137 | if length < 1: 138 | raise InvalidUsageError("`action_id` cannot be empty.") 139 | if length > 255: 140 | raise InvalidUsageError( 141 | f"`action_id` length ({length}) exceeds limit of 255 characters (id: {action_id})." 142 | ) 143 | return action_id 144 | 145 | 146 | def validate_string( 147 | string: Optional[str], 148 | field_name: str, 149 | max_length: Optional[int] = None, 150 | min_length: Optional[int] = None, 151 | allow_none: bool = False, 152 | ) -> Optional[str]: 153 | """ 154 | Performs basic validation actions (e.g. length checking) on a given string 155 | based on the provided criteria. 156 | 157 | Args: 158 | string: the string to validate 159 | field_name: the name of the field the string belongs to (for error reporting purposes). 160 | min_length: if the string is less than this length, an error will be raised. 161 | max_length: if the string is greated than this length, an error will be raised. 162 | allow_none: whether `None` is a valid value for the string being validated. 163 | 164 | Returns: 165 | The original string if it deemed to be valid (i.e. no errors are thrown). 166 | 167 | Throws: 168 | InvalidUsageError: if any of the validation checks (length, `None`) fail. 169 | """ 170 | if string is None: 171 | if not allow_none: 172 | raise InvalidUsageError( 173 | f"Expecting string for field `{field_name}`, cannot be None." 174 | ) 175 | return None 176 | return validate_string_nonnull(string, max_length, min_length, field_name) 177 | 178 | 179 | def validate_string_nonnull( 180 | string: str, 181 | max_length: Optional[int] = None, 182 | min_length: Optional[int] = None, 183 | field_name: str = "string", 184 | ) -> str: 185 | length = len(string) 186 | if min_length and length < min_length: 187 | raise InvalidUsageError( 188 | f"Argument to field `{field_name}` ({length} characters) " 189 | f"is less than minimum length of {min_length} characters" 190 | ) 191 | if max_length and length > max_length: 192 | raise InvalidUsageError( 193 | f"Argument to field `{field_name}` ({length} characters) " 194 | f"exceeds length limit of {max_length} characters" 195 | ) 196 | return string 197 | 198 | 199 | def validate_int( 200 | num: Optional[int], 201 | min_value: Optional[int] = None, 202 | max_value: Optional[int] = None, 203 | allow_none: bool = False, 204 | ) -> Optional[int]: 205 | """ 206 | Performs basic validation checks against a given integer. 207 | 208 | Args: 209 | num: the number to validate. 210 | min_value: if `num` is less than this value, an error will be thrown. 211 | max_value: if `num` is greater than this value, an error will be thrown. 212 | allow_none: whether `None` is a valid value for `num`. If `num` is `None` 213 | `allow_none` is `False`, an error will be thrown. 214 | 215 | Returns: 216 | The original value of `num` if it passes all validation checks. 217 | 218 | Throws: 219 | InvalidUsageError: if any of the validation checks fail. 220 | """ 221 | if num is None and not allow_none: 222 | raise InvalidUsageError("`num` is None, which is disallowed.") 223 | if num is not None: 224 | if min_value is not None and num < min_value: 225 | raise InvalidUsageError(f"{num} is less than the minimum {min_value}") 226 | if max_value is not None and num > max_value: 227 | raise InvalidUsageError(f"{num} is less than the minimum {max_value}") 228 | return num 229 | -------------------------------------------------------------------------------- /slackblocks/rich_text/objects.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rich text objects are containers for rich text elements. 3 | 4 | These obejects form the contents of the 5 | [`RichTextBlock`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock). 6 | """ 7 | 8 | from abc import ABC, abstractmethod 9 | from enum import Enum 10 | from json import dumps 11 | from typing import Any, Dict, List, Optional, Union 12 | 13 | from slackblocks.errors import InvalidUsageError 14 | from slackblocks.rich_text.elements import ( 15 | RichText, 16 | RichTextChannel, 17 | RichTextElement, 18 | RichTextEmoji, 19 | RichTextLink, 20 | RichTextUser, 21 | RichTextUserGroup, 22 | ) 23 | from slackblocks.utils import coerce_to_list, validate_int 24 | 25 | 26 | class RichTextObjectType(Enum): 27 | SECTION = "rich_text_section" 28 | LIST = "rich_text_list" 29 | PREFORMATTED = "rich_text_preformatted" 30 | QUOTE = "rich_text_quote" 31 | TABLE_CELL = "rich_text_table_cell" 32 | 33 | 34 | class ListType(Enum): 35 | """ 36 | An `Enum` that lists the available types of rich text lists. 37 | 38 | - `ListType.BULLET`: an unorderd (bulleted) list. 39 | - `ListType.ORDERED`: an ordered (numbered) list. 40 | """ 41 | 42 | BULLET = "bullet" 43 | ORDERED = "ordered" 44 | 45 | @classmethod 46 | def all(cls) -> List[str]: 47 | return [list_type.value for list_type in ListType] 48 | 49 | 50 | class RichTextObject(ABC): 51 | """ 52 | Abstract class housing shared functionality of RichTextObjects. 53 | 54 | Args: 55 | type_: the type of rich text object this class is, from 56 | `RichTextObjectType`. 57 | """ 58 | 59 | def __init__(self, type_: RichTextObjectType) -> None: 60 | self.type_ = type_ 61 | 62 | @abstractmethod 63 | def _resolve(self) -> Dict[str, Any]: 64 | return {"type": self.type_.value} 65 | 66 | def __repr__(self) -> str: 67 | return dumps(self._resolve(), indent=4) 68 | 69 | 70 | class RichTextSection(RichTextObject): 71 | """ 72 | The most basic rich text container object, which takes rich text elements 73 | and renders them when `RichTextSection` is passed to a 74 | [`RichTextBlock`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock). 75 | 76 | See: . 77 | 78 | Args: 79 | elements: one or more rich text elements that will form the content of the section. 80 | e.g. `RichText`, `RichTextLink`. 81 | 82 | Throws: 83 | InvalidUsageError: if any of the items passed to `elements` isn't a valid 84 | `RichTextObject`. 85 | """ 86 | 87 | def __init__(self, elements: Union[RichTextElement, List[RichTextElement]]) -> None: 88 | super().__init__(type_=RichTextObjectType.SECTION) 89 | self.elements = coerce_to_list( 90 | elements, 91 | class_=( 92 | RichTextChannel, 93 | RichTextEmoji, 94 | RichTextLink, 95 | RichText, 96 | RichTextUser, 97 | RichTextUserGroup, 98 | ), 99 | min_size=1, 100 | ) 101 | 102 | def _resolve(self) -> Dict[str, Any]: 103 | section = super()._resolve() 104 | if self.elements is not None: 105 | section["elements"] = [element._resolve() for element in self.elements] 106 | return section 107 | 108 | 109 | class RichTextList(RichTextObject): 110 | """ 111 | Renders to a HTML list containing rich text elements. 112 | 113 | See: . 114 | 115 | Args: 116 | style: one of `ListType.BULLET` or `ListType.ORDERED`. 117 | elements: a list of (possibly nested) `RichTextSection` elements. 118 | Each object in this list will be rendered as a list item. 119 | indent: indent (in pixels) of each list item. 120 | offset: offset (in pixels) of each list item. 121 | border: thickness (in pixels) of the (optional) border around the list. 122 | 123 | Throws: 124 | InvalidUsageError: if style is not a valid `ListType` or any of the 125 | items in `elements` isn't a valid `RichTextSection`. 126 | """ 127 | 128 | def __init__( 129 | self, 130 | style: Union[str, ListType], 131 | elements: Union[RichTextSection, List[RichTextSection]], 132 | indent: Optional[int] = None, 133 | offset: Optional[int] = 0, 134 | border: Optional[int] = 0, 135 | ) -> None: 136 | super().__init__(type_=RichTextObjectType.LIST) 137 | if isinstance(style, str): 138 | if style in ListType.all(): 139 | self.style = style 140 | else: 141 | raise InvalidUsageError(f"`style` must be one of [{ListType.all()}]") 142 | elif isinstance(style, ListType): 143 | self.style = style.value 144 | self.elements = coerce_to_list(elements, RichTextSection, min_size=1) 145 | self.indent = validate_int(indent, allow_none=True) 146 | self.offset = validate_int(offset, allow_none=True) 147 | self.border = validate_int(border, allow_none=True) 148 | 149 | def _resolve(self) -> Dict[str, Any]: 150 | rich_text_list: Dict[str, Any] = super()._resolve() 151 | if self.elements is not None: 152 | rich_text_list["elements"] = [ 153 | element._resolve() for element in self.elements if element is not None 154 | ] 155 | rich_text_list["style"] = self.style 156 | if self.indent is not None: 157 | rich_text_list["indent"] = self.indent 158 | if self.offset is not None: 159 | rich_text_list["offset"] = self.offset 160 | if self.border is not None: 161 | rich_text_list["border"] = self.border 162 | return rich_text_list 163 | 164 | 165 | class RichTextCodeBlock(RichTextObject): 166 | """ 167 | A rich text element for representing blocks of code in 168 | [`RichTextBlocks`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock). 169 | 170 | This is roughly equivalent to the triple-backtick ```code``` syntax in markdown. 171 | 172 | See: . 173 | 174 | Args: 175 | elements: one or more rich text primitive objexts 176 | (e.g. [`RichText`](/slackblocks/latest/reference/rich_text/#rich_text.RichText)). 177 | border: the thickness (in pixels) of the border around the code block. 178 | 179 | Throws: 180 | InvalidUsageError: if any of the items in `elements` aren't valid rich 181 | text elements. 182 | """ 183 | 184 | def __init__( 185 | self, 186 | elements: Union[RichTextElement, List[RichTextElement]], 187 | border: Optional[int] = None, 188 | ) -> None: 189 | super().__init__(type_=RichTextObjectType.PREFORMATTED) 190 | self.elements = coerce_to_list( 191 | elements, 192 | ( 193 | RichText, 194 | RichTextChannel, 195 | RichTextEmoji, 196 | RichTextLink, 197 | RichTextUser, 198 | RichTextUserGroup, 199 | ), 200 | ) 201 | self.border = border 202 | 203 | def _resolve(self) -> Dict[str, Any]: 204 | preformatted = super()._resolve() 205 | if self.elements is not None: 206 | preformatted["elements"] = [ 207 | element._resolve() for element in self.elements if element is not None 208 | ] 209 | if self.border is not None: 210 | preformatted["border"] = self.border 211 | return preformatted 212 | 213 | 214 | class RichTextQuote(RichTextObject): 215 | """ 216 | A rich text object for representing a block quote. 217 | 218 | Block quotes are presented with a vertical bar to the left hand side of 219 | the text. 220 | 221 | See: 222 | 223 | Args: 224 | elements: one or more rich text primitive objexts 225 | (e.g. [`RichText`](/slackblocks/latest/reference/rich_text/#rich_text.RichText)). 226 | border: the thickness (in pixels) of the border around the code block. 227 | """ 228 | 229 | def __init__( 230 | self, 231 | elements: Union[RichTextElement, List[RichTextElement]], 232 | border: Optional[int] = None, 233 | ) -> None: 234 | super().__init__(RichTextObjectType.QUOTE) 235 | self.elements = coerce_to_list( 236 | elements, 237 | ( 238 | RichText, 239 | RichTextChannel, 240 | RichTextEmoji, 241 | RichTextLink, 242 | RichTextUser, 243 | RichTextUserGroup, 244 | ), 245 | ) 246 | self.border = border 247 | 248 | def _resolve(self) -> Dict[str, Any]: 249 | quote = super()._resolve() 250 | if self.elements is not None: 251 | quote["elements"] = [ 252 | element._resolve() for element in self.elements if element is not None 253 | ] 254 | if self.border is not None: 255 | quote["border"] = self.border 256 | return quote 257 | -------------------------------------------------------------------------------- /test/unit/test_elements.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from slackblocks.elements import ( 4 | Button, 5 | ButtonStyle, 6 | ChannelMultiSelectMenu, 7 | ChannelSelectMenu, 8 | CheckboxGroup, 9 | ConversationMultiSelectMenu, 10 | ConversationSelectMenu, 11 | DatePicker, 12 | DateTimePicker, 13 | EmailInput, 14 | ExternalMultiSelectMenu, 15 | ExternalSelectMenu, 16 | Image, 17 | NumberInput, 18 | OverflowMenu, 19 | PlainTextInput, 20 | RadioButtonGroup, 21 | RichTextInput, 22 | StaticMultiSelectMenu, 23 | StaticSelectMenu, 24 | TimePicker, 25 | URLInput, 26 | UserMultiSelectMenu, 27 | UserSelectMenu, 28 | WorkflowButton, 29 | ) 30 | from slackblocks.errors import InvalidUsageError 31 | from slackblocks.objects import ( 32 | InputParameter, 33 | Option, 34 | Text, 35 | TextType, 36 | Trigger, 37 | Workflow, 38 | ) 39 | from slackblocks.rich_text import RichText 40 | 41 | from .utils import OPTION_A, THREE_OPTIONS, TWO_OPTIONS, fetch_sample 42 | 43 | 44 | def test_button_basic() -> None: 45 | button = Button(text="Click Me", value="click_me", action_id="button") 46 | assert fetch_sample(path="test/samples/elements/button_basic.json") == repr(button) 47 | 48 | 49 | def test_button_link() -> None: 50 | link_button = Button(text="Link!", url="https://ndl.im/", action_id="button") 51 | assert fetch_sample(path="test/samples/elements/button_link.json") == repr( 52 | link_button 53 | ) 54 | 55 | 56 | def test_button_style() -> None: 57 | style_button = Button( 58 | text="Load", 59 | style=ButtonStyle.PRIMARY, 60 | value="im_a_style_button", 61 | action_id="button", 62 | ) 63 | assert fetch_sample(path="test/samples/elements/button_style.json") == repr( 64 | style_button 65 | ) 66 | 67 | 68 | def test_checkbox_basic() -> None: 69 | checkbox = CheckboxGroup( 70 | options=TWO_OPTIONS, action_id="and...action", initial_options=OPTION_A 71 | ) 72 | assert fetch_sample(path="test/samples/elements/checkbox_basic.json") == repr( 73 | checkbox 74 | ) 75 | 76 | 77 | def test_datepicker_basic() -> None: 78 | datepicker = DatePicker( 79 | action_id="datepicker", initial_date="1970-01-01", placeholder="Pick a date" 80 | ) 81 | assert fetch_sample(path="test/samples/elements/date_picker_basic.json") == repr( 82 | datepicker 83 | ) 84 | 85 | 86 | def test_datetime_picker_basic() -> None: 87 | datetime_picker = DateTimePicker( 88 | action_id="datetime_picker", initial_datetime=1628633830 89 | ) 90 | assert fetch_sample( 91 | path="test/samples/elements/datetime_picker_basic.json" 92 | ) == repr(datetime_picker) 93 | 94 | 95 | def test_email_input_basic() -> None: 96 | email_input = EmailInput(action_id="email_input", placeholder="Enter your email") 97 | assert fetch_sample(path="test/samples/elements/email_input_basic.json") == repr( 98 | email_input 99 | ) 100 | 101 | 102 | def test_image_basic() -> None: 103 | image = Image(image_url="https://ndl.im/img/logo.png", alt_text="Logo for ndl.im") 104 | assert fetch_sample(path="test/samples/elements/image_basic.json") == repr(image) 105 | 106 | 107 | def test_multi_select_channel() -> None: 108 | multi_select_channel = ChannelMultiSelectMenu( 109 | action_id="multi_channels_select", 110 | placeholder=Text("Select channels", type_=TextType.PLAINTEXT), 111 | ) 112 | assert fetch_sample(path="test/samples/elements/multi_select_channel.json") == repr( 113 | multi_select_channel 114 | ) 115 | 116 | 117 | def test_multi_select_conversation() -> None: 118 | multi_select_conversation = ConversationMultiSelectMenu( 119 | action_id="multi_conversations_select", 120 | placeholder=Text("Select conversations", type_=TextType.PLAINTEXT), 121 | ) 122 | assert fetch_sample( 123 | path="test/samples/elements/multi_select_conversation.json" 124 | ) == repr(multi_select_conversation) 125 | 126 | 127 | def test_multi_select_external() -> None: 128 | multi_select_external = ExternalMultiSelectMenu( 129 | action_id="multi_external_select", 130 | placeholder=Text("Select items", type_=TextType.PLAINTEXT), 131 | min_query_length=3, 132 | ) 133 | assert fetch_sample( 134 | path="test/samples/elements/multi_select_external.json" 135 | ) == repr(multi_select_external) 136 | 137 | 138 | def test_multi_select_static() -> None: 139 | multi_select_static = StaticMultiSelectMenu( 140 | action_id="multi_static_select", 141 | placeholder=Text("Select one or more", type_=TextType.PLAINTEXT), 142 | options=TWO_OPTIONS, 143 | ) 144 | assert fetch_sample(path="test/samples/elements/multi_select_static.json") == repr( 145 | multi_select_static 146 | ) 147 | 148 | 149 | def test_multi_select_static_invalid_option() -> None: 150 | with pytest.raises(InvalidUsageError): 151 | StaticMultiSelectMenu( 152 | action_id="multi_static_select", 153 | placeholder=Text("Select one or more", type_=TextType.PLAINTEXT), 154 | options=TWO_OPTIONS 155 | + [Option(text=Text("C", type_=TextType.MARKDOWN), value="X")], 156 | ) 157 | 158 | 159 | def test_multi_select_user() -> None: 160 | multi_select_user = UserMultiSelectMenu( 161 | action_id="multi_users_select", 162 | placeholder=Text("Select one or more users", type_=TextType.PLAINTEXT), 163 | ) 164 | assert fetch_sample(path="test/samples/elements/multi_select_user.json") == repr( 165 | multi_select_user 166 | ) 167 | 168 | 169 | def test_multi_select_user_with_initial_users() -> None: 170 | multi_select_user = UserMultiSelectMenu( 171 | action_id="multi_users_select", 172 | placeholder=Text("Select one or more users", type_=TextType.PLAINTEXT), 173 | initial_users=["U064B5H1309", "U063JR973UP"], 174 | ) 175 | assert fetch_sample( 176 | path="test/samples/elements/multi_select_user_with_initial_users.json" 177 | ) == repr(multi_select_user) 178 | 179 | 180 | def test_number_input_basic() -> None: 181 | number_input = NumberInput(action_id="number_input", is_decimal_allowed=False) 182 | assert fetch_sample(path="test/samples/elements/number_input_basic.json") == repr( 183 | number_input 184 | ) 185 | 186 | 187 | def test_overflow_menu_basic() -> None: 188 | overflow_menu = OverflowMenu(options=THREE_OPTIONS, action_id="overflow") 189 | assert fetch_sample(path="test/samples/elements/overflow_menu_basic.json") == repr( 190 | overflow_menu 191 | ) 192 | 193 | 194 | def test_plaintext_input_menu_basic() -> None: 195 | plaintext_input = PlainTextInput( 196 | action_id="plaintext_input", placeholder="Enter your plain text" 197 | ) 198 | assert fetch_sample( 199 | path="test/samples/elements/plaintext_input_basic.json" 200 | ) == repr(plaintext_input) 201 | 202 | 203 | def test_radio_button_group_basic() -> None: 204 | radio_button_group = RadioButtonGroup( 205 | action_id="radio_buttons", initial_option=OPTION_A, options=THREE_OPTIONS 206 | ) 207 | assert fetch_sample( 208 | path="test/samples/elements/radio_button_group_basic.json" 209 | ) == repr(radio_button_group) 210 | 211 | 212 | def test_select_menu_channel() -> None: 213 | select_menu_channel = ChannelSelectMenu( 214 | action_id="channels_select", placeholder="Select a channel" 215 | ) 216 | assert fetch_sample(path="test/samples/elements/select_menu_channel.json") == repr( 217 | select_menu_channel 218 | ) 219 | 220 | 221 | def test_select_menu_conversation() -> None: 222 | select_menu_conversation = ConversationSelectMenu( 223 | action_id="conversations_select", placeholder="Select one conversation" 224 | ) 225 | assert fetch_sample( 226 | path="test/samples/elements/select_menu_conversation.json" 227 | ) == repr(select_menu_conversation) 228 | 229 | 230 | def test_select_menu_external() -> None: 231 | select_menu_external = ExternalSelectMenu( 232 | action_id="external_select", placeholder="Select one item", min_query_length=4 233 | ) 234 | assert fetch_sample(path="test/samples/elements/select_menu_external.json") == repr( 235 | select_menu_external 236 | ) 237 | 238 | 239 | def test_select_menu_static() -> None: 240 | select_menu_static = StaticSelectMenu( 241 | action_id="static_select", placeholder="Select one item", options=THREE_OPTIONS 242 | ) 243 | assert fetch_sample(path="test/samples/elements/select_menu_static.json") == repr( 244 | select_menu_static 245 | ) 246 | 247 | 248 | def test_select_menu_static_invalid_option() -> None: 249 | with pytest.raises(InvalidUsageError): 250 | StaticSelectMenu( 251 | action_id="static_select", 252 | placeholder="Select one item", 253 | options=THREE_OPTIONS 254 | + [Option(text=Text("C", type_=TextType.MARKDOWN), value="X")], 255 | ) 256 | 257 | 258 | def test_select_menu_user() -> None: 259 | select_menu_user = UserSelectMenu( 260 | action_id="users_select", placeholder="Select one user" 261 | ) 262 | assert fetch_sample(path="test/samples/elements/select_menu_user.json") == repr( 263 | select_menu_user 264 | ) 265 | 266 | 267 | def test_timepicker_basic() -> None: 268 | timepicker = TimePicker( 269 | timezone="Australia/Sydney", 270 | action_id="timepicker", 271 | initial_time="12:00", 272 | placeholder="Select your time", 273 | ) 274 | assert fetch_sample(path="test/samples/elements/timepicker_basic.json") == repr( 275 | timepicker 276 | ) 277 | 278 | 279 | def test_url_input_basic() -> None: 280 | url_input = URLInput(action_id="url_text_input") 281 | assert fetch_sample(path="test/samples/elements/url_input_basic.json") == repr( 282 | url_input 283 | ) 284 | 285 | 286 | def test_workflow_button_basic() -> None: 287 | workflow_button = WorkflowButton( 288 | text=Text("Run Your Workflow", type_=TextType.PLAINTEXT), 289 | workflow=Workflow( 290 | trigger=Trigger( 291 | url="https://slack.com/shortcuts/Ft012KXZK1MZ/8831723c452aac3e87c6d3219bebd44c", 292 | customizable_input_parameters=[ 293 | InputParameter( 294 | name="name_a", 295 | value="value_a", 296 | ), 297 | InputParameter( 298 | name="name_b", 299 | value="value_b", 300 | ), 301 | ], 302 | ) 303 | ), 304 | ) 305 | assert fetch_sample( 306 | path="test/samples/elements/workflow_button_basic.json" 307 | ) == repr(workflow_button) 308 | 309 | 310 | def test_rich_text_input_basic() -> None: 311 | assert fetch_sample( 312 | path="test/samples/elements/rich_text_input_basic.json" 313 | ) == repr( 314 | RichTextInput( 315 | action_id="action_id", 316 | initial_value=RichText("I'm rich"), 317 | focus_on_load=False, 318 | placeholder="Hello", 319 | ) 320 | ) 321 | -------------------------------------------------------------------------------- /slackblocks/messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Messages are the core unit of Slack messaging functionality. They can be 3 | built out using blocks, elements, objects, and rich text features. 4 | 5 | See: 6 | """ 7 | 8 | from enum import Enum 9 | from json import dumps 10 | from typing import Any, Dict, List, Optional, Union 11 | 12 | from slackblocks.utils import coerce_to_list 13 | 14 | from .attachments import Attachment 15 | from .blocks import Block 16 | from .errors import InvalidUsageError 17 | 18 | 19 | class ResponseType(Enum): 20 | """ 21 | Types of messages that can be sent via `WebhookMessage`. 22 | """ 23 | 24 | EPHEMERAL = "ephemeral" 25 | IN_CHANNEL = "in_channel" 26 | 27 | @staticmethod 28 | def get_value(value: Union["ResponseType", str]) -> str: 29 | if isinstance(value, ResponseType): 30 | return value.value 31 | if value not in [response_type.value for response_type in ResponseType]: 32 | raise InvalidUsageError( 33 | "ResponseType must be either `ephemeral` or `in_channel`" 34 | ) 35 | return value 36 | 37 | 38 | class BaseMessage: 39 | """ 40 | Abstract class for shared functionality between Messages and 41 | MessageResponses. 42 | """ 43 | 44 | def __init__( 45 | self, 46 | channel: Optional[str] = None, 47 | text: Optional[str] = "", 48 | blocks: Optional[Union[Block, List[Block]]] = None, 49 | attachments: Optional[Union[Attachment, List[Attachment]]] = None, 50 | thread_ts: Optional[str] = None, 51 | mrkdwn: bool = True, 52 | ) -> None: 53 | self.blocks = coerce_to_list(blocks, class_=Block, allow_none=True) 54 | self.channel = channel 55 | self.text = text 56 | self.attachments = coerce_to_list( 57 | attachments, class_=Attachment, allow_none=True 58 | ) 59 | self.thread_ts = thread_ts 60 | self.mrkdwn = mrkdwn 61 | 62 | def _resolve(self) -> Dict[str, Any]: 63 | message: Dict[str, Any] = {} 64 | if self.channel: 65 | message["channel"] = self.channel 66 | message["mrkdwn"] = self.mrkdwn 67 | if self.blocks: 68 | message["blocks"] = [block._resolve() for block in self.blocks] 69 | if self.attachments: 70 | message["attachments"] = [ 71 | attachment._resolve() for attachment in self.attachments 72 | ] 73 | if self.thread_ts: 74 | message["thread_ts"] = self.thread_ts 75 | if self.text or self.text == "": 76 | message["text"] = self.text 77 | return message 78 | 79 | def to_dict(self) -> Dict[str, Any]: 80 | return self._resolve() 81 | 82 | def json(self) -> str: 83 | return dumps(self._resolve(), indent=4) 84 | 85 | def __repr__(self) -> str: 86 | return self.json() 87 | 88 | def __getitem__(self, item): 89 | return self._resolve()[item] 90 | 91 | def keys(self) -> List[str]: 92 | return list(self._resolve().keys()) 93 | 94 | 95 | class Message(BaseMessage): 96 | """ 97 | A Slack message object that can be converted to a JSON string for use with 98 | the Slack message API. 99 | 100 | Args: 101 | channel: the Slack channel to send the message to, e.g. "#general". 102 | text: markdown text to send in the message. If `blocks` are provided 103 | then this is a fallback to display in notifications. 104 | blocks: a list of [`Blocks`](/slackblocks/latest/reference/blocks) to form the contents 105 | of the message instead of the contents of `text`. 106 | attachments: a list of 107 | [`Attachments`](/slackblocks/latest/reference/attachments/#attachments.Attachment) 108 | that form the secondary contents of the message (deprecated). 109 | thread_ts: the timestamp ID of another unthreaded message that will 110 | become the parent message of this message (now a reply in a thread). 111 | mrkdwn: if `True` the contents of `text` will be rendered as markdown 112 | rather than plain text. 113 | unfurl_links: if `True`, links in the message will be automatically 114 | unfurled. 115 | unfurl_media: if `True`, media from links (e.g. images) will 116 | automatically unfurl. 117 | Throws: 118 | InvalidUsageException: in the event that the items passed to `blocks` 119 | are not valid [`Blocks`](/slackblocks/latest/reference/blocks). 120 | """ 121 | 122 | def __init__( 123 | self, 124 | channel: str, 125 | text: Optional[str] = "", 126 | blocks: Optional[Union[List[Block], Block]] = None, 127 | attachments: Optional[List[Attachment]] = None, 128 | thread_ts: Optional[str] = None, 129 | mrkdwn: bool = True, 130 | unfurl_links: Optional[bool] = None, 131 | unfurl_media: Optional[bool] = None, 132 | ) -> None: 133 | super().__init__(channel, text, blocks, attachments, thread_ts, mrkdwn) 134 | self.unfurl_links = unfurl_links 135 | self.unfurl_media = unfurl_media 136 | 137 | def _resolve(self) -> Dict[str, Any]: 138 | result = {**super()._resolve()} 139 | if self.unfurl_links is not None: 140 | result["unfurl_links"] = self.unfurl_links 141 | if self.unfurl_media is not None: 142 | result["unfurl_media"] = self.unfurl_media 143 | return result 144 | 145 | 146 | class MessageResponse(BaseMessage): 147 | """ 148 | A required, immediate response that confirms your app received the payload. 149 | """ 150 | 151 | def __init__( 152 | self, 153 | text: Optional[str] = "", 154 | blocks: Optional[Union[List[Block], Block]] = None, 155 | attachments: Optional[List[Attachment]] = None, 156 | thread_ts: Optional[str] = None, 157 | mrkdwn: bool = True, 158 | replace_original: bool = False, 159 | ephemeral: bool = False, 160 | ) -> None: 161 | super().__init__( 162 | text=text, 163 | blocks=blocks, 164 | attachments=attachments, 165 | thread_ts=thread_ts, 166 | mrkdwn=mrkdwn, 167 | ) 168 | self.replace_original = replace_original 169 | self.ephemeral = ephemeral 170 | 171 | def _resolve(self) -> Dict[str, Any]: 172 | result: Dict[str, Any] = { 173 | **super()._resolve(), 174 | "replace_original": self.replace_original, 175 | } 176 | if self.ephemeral: 177 | result["response_type"] = "ephemeral" 178 | return result 179 | 180 | 181 | class WebhookMessage: 182 | """ 183 | Messages sent via the Slack `WebhookClient` takes different arguments than 184 | those sent via the regular `WebClient`. 185 | 186 | See: 187 | 188 | Args: 189 | text: markdown text to send in the message. If `blocks` are provided 190 | then this is a fallback to display in notifications. 191 | attachments: a list of 192 | [`Attachments`](/slackblocks/latest/reference/attachments/#attachments.Attachment) 193 | that form the secondary contents of the message (deprecated). 194 | blocks: a list of [`Blocks`](/slackblocks/latest/reference/blocks) to form the contents 195 | of the message instead of the contents of `text`. 196 | response_type: one of `ResponseType.EPHEMERAL` or `ResponseType.IN_CHANNEL`. 197 | Ephemeral messages are shown only to the requesting user whereas 198 | "in-channel" messages are shown to all channel participants. 199 | replace_orginal: when `True`, the message triggering this response will be 200 | replaced by this messaage. Mutually exclusive with `delete_original`. 201 | delete_original: when `True`, the original message triggering this response 202 | will be deleted, and any content of this message will be posted as a 203 | new message. Mutually exclusive with `replace_orginal`. 204 | unfurl_links: if `True`, links in the message will be automatically 205 | unfurled. 206 | unfurl_media: if `True`, media from links (e.g. images) will 207 | automatically unfurl. 208 | metadata: additional metadata to attach to the message. 209 | headres: HTTP request headers to include with the message. 210 | 211 | Throws: 212 | InvalidUsageError: when any of the passed fields fail validation. 213 | """ 214 | 215 | def __init__( 216 | self, 217 | text: Optional[str] = None, 218 | attachments: Optional[Union[Attachment, List[Attachment]]] = None, 219 | blocks: Optional[Union[Block, List[Block]]] = None, 220 | response_type: Optional[Union[ResponseType, str]] = None, 221 | replace_original: Optional[bool] = None, 222 | delete_original: Optional[bool] = None, 223 | unfurl_links: Optional[bool] = None, 224 | unfurl_media: Optional[bool] = None, 225 | metadata: Optional[Dict[str, Any]] = None, 226 | headers: Optional[Dict[str, str]] = None, 227 | ) -> None: 228 | self.text = text 229 | self.attachments: Optional[List[Attachment]] = coerce_to_list( 230 | attachments, Attachment, allow_none=True 231 | ) 232 | self.blocks: Optional[List[Block]] = coerce_to_list( 233 | blocks, Block, allow_none=True 234 | ) 235 | self.response_type = ( 236 | ResponseType.get_value(response_type) if response_type is not None else None 237 | ) 238 | self.replace_original = replace_original 239 | self.delete_original = delete_original 240 | self.unfurl_links = unfurl_links 241 | self.unfurl_media = unfurl_media 242 | self.metadata = metadata 243 | self.headers = headers 244 | 245 | def _resolve(self) -> Dict[str, Any]: 246 | webhook_message: Dict[str, Any] = {} 247 | if self.text is not None: 248 | webhook_message["text"] = self.text 249 | if self.attachments is not None: 250 | webhook_message["attachments"] = [ 251 | attachment._resolve() 252 | for attachment in self.attachments 253 | if attachment is not None 254 | ] 255 | if self.blocks is not None: 256 | webhook_message["blocks"] = [ 257 | block._resolve() for block in self.blocks if block is not None 258 | ] 259 | if self.response_type is not None: 260 | webhook_message["response_type"] = self.response_type 261 | if self.replace_original is not None: 262 | webhook_message["replace_original"] = self.replace_original 263 | if self.delete_original is not None: 264 | webhook_message["delete_original"] = self.delete_original 265 | if self.unfurl_links is not None: 266 | webhook_message["unfurl_links"] = self.unfurl_links 267 | if self.unfurl_media is not None: 268 | webhook_message["unfurl_media"] = self.unfurl_media 269 | if self.metadata is not None: 270 | webhook_message["metadata"] = self.metadata 271 | if self.headers is not None: 272 | webhook_message["headers"] = self.headers 273 | return webhook_message 274 | 275 | def to_dict(self) -> Dict[str, Any]: 276 | return self._resolve() 277 | 278 | def json(self) -> str: 279 | return dumps(self._resolve(), indent=4) 280 | 281 | def __repr__(self) -> str: 282 | return self.json() 283 | 284 | def __getitem__(self, item): 285 | return self._resolve()[item] 286 | 287 | def keys(self) -> List[str]: 288 | return list(self._resolve().keys()) 289 | -------------------------------------------------------------------------------- /slackblocks/rich_text/elements.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rich text elements are the primitive elements used to populate the rich 3 | text object "containers", which are then fed into the 4 | [`RichTextBlock`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock). 5 | """ 6 | 7 | from abc import ABC, abstractmethod 8 | from enum import Enum 9 | from json import dumps 10 | from typing import Any, Dict, Optional 11 | 12 | from slackblocks.utils import validate_string 13 | 14 | 15 | class RichTextElementType(Enum): 16 | """ 17 | Used for identification of the lowest level rich text primitives. 18 | """ 19 | 20 | CHANNEL = "channel" 21 | EMOJI = "emoji" 22 | LINK = "link" 23 | TEXT = "text" 24 | USER = "user" 25 | USER_GROUP = "user_group" 26 | 27 | 28 | class RichTextElement(ABC): 29 | """ 30 | Abstract base class for all rich text element classes. 31 | 32 | These are the primitives that form the basis of rich text objects and 33 | the [`RichTextBlock`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock). 34 | """ 35 | 36 | def __init__(self, type_: RichTextElementType) -> None: 37 | super().__init__() 38 | self.type_ = type_ 39 | 40 | @abstractmethod 41 | def _resolve(self) -> Dict[str, Any]: 42 | return {"type": self.type_.value} 43 | 44 | def __repr__(self) -> str: 45 | return dumps(self._resolve(), indent=4) 46 | 47 | 48 | class RichText(RichTextElement): 49 | """ 50 | The core unit of the rich text API. Allows for the formatting of text 51 | with visual styles like bolding, italics and strikethroughs. 52 | Combined with higher-level containers like `RichTextSection`, 53 | `RichText` can be used to create complicated and deeply nested 54 | rich text within Slack messages. 55 | 56 | Args: 57 | text: the text content to render. 58 | bold: whether to render the given text in bold font. 59 | italic: whether to render the given text in italics. 60 | strike: whether to render the given text with a "strikethrough". 61 | code: whether to render the given text as an inline code snippet 62 | (monospaced). 63 | """ 64 | 65 | def __init__( 66 | self, 67 | text: str, 68 | bold: Optional[bool] = None, 69 | italic: Optional[bool] = None, 70 | strike: Optional[bool] = None, 71 | code: Optional[bool] = None, 72 | ) -> None: 73 | super().__init__(type_=RichTextElementType.TEXT) 74 | self.text = text 75 | self.bold = bold 76 | self.italic = italic 77 | self.strike = strike 78 | self.code = code 79 | 80 | def _resolve(self) -> Dict[str, Any]: 81 | rich_text = super()._resolve() 82 | rich_text["text"] = self.text 83 | style = {} 84 | if self.bold is not None: 85 | style["bold"] = self.bold 86 | if self.italic is not None: 87 | style["italic"] = self.italic 88 | if self.strike is not None: 89 | style["strike"] = self.strike 90 | if self.code is not None: 91 | style["code"] = self.code 92 | if style: 93 | rich_text["style"] = style 94 | return rich_text 95 | 96 | 97 | class RichTextChannel(RichTextElement): 98 | """ 99 | Rich text rendering of a Slack channel (e.g. #general). 100 | 101 | See: 102 | 103 | Args: 104 | channel_id: the ID of the channel to render. You can get this from 105 | the channel settings or the URL (if using Slack in the browser). 106 | bold: whether to render the given channel in bold font. 107 | italic: whether to render the given channel in italics. 108 | strike: whether to render the given channel with a "strikethrough". 109 | highlight: whether to give the channel a distinct highlight when rendered. 110 | client_highlight: ??? 111 | unlink: whether to remove the link to the channel from the channel when 112 | rendered. 113 | """ 114 | 115 | def __init__( 116 | self, 117 | channel_id: str, 118 | bold: Optional[bool] = None, 119 | italic: Optional[bool] = None, 120 | strike: Optional[bool] = None, 121 | highlight: Optional[bool] = None, 122 | client_highlight: Optional[bool] = None, 123 | unlink: Optional[bool] = None, 124 | ) -> None: 125 | super().__init__(RichTextElementType.CHANNEL) 126 | self.channel_id = channel_id 127 | self.bold = bold 128 | self.italic = italic 129 | self.strike = strike 130 | self.highlight = highlight 131 | self.client_highlight = client_highlight 132 | self.unlink = unlink 133 | 134 | def _resolve(self) -> Dict[str, Any]: 135 | channel = super()._resolve() 136 | channel["channel_id"] = self.channel_id 137 | style = {} 138 | if self.bold is not None: 139 | style["bold"] = self.bold 140 | if self.italic is not None: 141 | style["italic"] = self.italic 142 | if self.strike is not None: 143 | style["strike"] = self.strike 144 | if self.highlight is not None: 145 | style["highlight"] = self.highlight 146 | if self.client_highlight is not None: 147 | style["client_highlight"] = self.client_highlight 148 | if self.unlink is not None: 149 | style["unlink"] = self.unlink 150 | if style: 151 | channel["style"] = style 152 | return channel 153 | 154 | 155 | class RichTextEmoji(RichTextElement): 156 | """ 157 | A rich text element for displaying an emoji. 158 | 159 | The emoji can either be one built in to Slack or a custom workspace emoji. 160 | 161 | See: 162 | 163 | Args: 164 | name: the unique name of the emoji to represent e.g. "wave". 165 | 166 | Throws: 167 | InvalidUsageError: if the emoji `name` provided is empty. 168 | """ 169 | 170 | def __init__(self, name: str) -> None: 171 | super().__init__(RichTextElementType.EMOJI) 172 | self.name = validate_string(name, field_name="name", min_length=1) 173 | 174 | def _resolve(self) -> Dict[str, Any]: 175 | emoji = super()._resolve() 176 | emoji["name"] = self.name 177 | return emoji 178 | 179 | 180 | class RichTextLink(RichTextElement): 181 | """ 182 | A rich text primitive to display links in text. 183 | 184 | See: 185 | 186 | Args: 187 | url: the url which the link will point to. 188 | text: the text to render with the link. If not provided, the raw URL 189 | will be used. 190 | unsafe: whether the link is "safe". 191 | bold: whether to render the given text in bold font. 192 | italic: whether to render the given text in italics. 193 | strike: whether to render the given text with a "strikethrough". 194 | code: whether to render the given text as an inline code snippet 195 | (monospaced). 196 | """ 197 | 198 | def __init__( 199 | self, 200 | url: str, 201 | text: Optional[str] = None, 202 | unsafe: Optional[bool] = None, 203 | bold: Optional[bool] = None, 204 | italic: Optional[bool] = None, 205 | strike: Optional[bool] = None, 206 | code: Optional[bool] = None, 207 | ) -> None: 208 | super().__init__(type_=RichTextElementType.LINK) 209 | self.url = url 210 | self.text = text 211 | self.unsafe = unsafe 212 | self.bold = bold 213 | self.italic = italic 214 | self.strike = strike 215 | self.code = code 216 | 217 | def _resolve(self) -> Dict[str, Any]: 218 | link = super()._resolve() 219 | link["url"] = self.url 220 | if self.text is not None: 221 | link["text"] = self.text 222 | if self.unsafe is not None: 223 | link["unsafe"] = self.unsafe 224 | style = {} 225 | if self.bold is not None: 226 | style["bold"] = self.bold 227 | if self.italic is not None: 228 | style["italic"] = self.italic 229 | if self.strike is not None: 230 | style["strike"] = self.strike 231 | if self.code is not None: 232 | style["code"] = self.code 233 | if style: 234 | link["style"] = style 235 | return link 236 | 237 | 238 | class RichTextUser(RichTextElement): 239 | """ 240 | Rich text element for representing users in 241 | [`RichTextBlocks`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock). 242 | 243 | See: . 244 | 245 | Args: 246 | user_id: the Slack ID of the user in question, you can get these 247 | from users' profiles or Slack client requests. 248 | bold: whether to render the given user in bold font. 249 | italic: whether to render the given user in italics. 250 | strike: whether to render the given user with a "strikethrough". 251 | highlight: whether to give the user a distinct highlight when rendered. 252 | client_highlight: ??? 253 | unlink: whether to remove the link to the user from the channel when 254 | rendered. 255 | """ 256 | 257 | def __init__( 258 | self, 259 | user_id: str, 260 | bold: Optional[bool] = None, 261 | italic: Optional[bool] = None, 262 | strike: Optional[bool] = None, 263 | highlight: Optional[bool] = None, 264 | client_highlight: Optional[bool] = None, 265 | unlink: Optional[bool] = None, 266 | ) -> None: 267 | super().__init__(RichTextElementType.USER) 268 | self.user_id = user_id 269 | self.bold = bold 270 | self.italic = italic 271 | self.strike = strike 272 | self.highlight = highlight 273 | self.client_highlight = client_highlight 274 | self.unlink = unlink 275 | 276 | def _resolve(self) -> Dict[str, Any]: 277 | user = super()._resolve() 278 | user["user_id"] = self.user_id 279 | style = {} 280 | if self.bold is not None: 281 | style["bold"] = self.bold 282 | if self.italic is not None: 283 | style["italic"] = self.italic 284 | if self.strike is not None: 285 | style["strike"] = self.strike 286 | if self.highlight is not None: 287 | style["highlight"] = self.highlight 288 | if self.client_highlight is not None: 289 | style["client_highlight"] = self.client_highlight 290 | if self.unlink is not None: 291 | style["unlink"] = self.unlink 292 | if style: 293 | user["style"] = style 294 | return user 295 | 296 | 297 | class RichTextUserGroup(RichTextElement): 298 | """ 299 | Rich text element for representing groups of users in 300 | [`RichTextBlocks`](/slackblocks/latest/reference/blocks/#blocks.RichTextBlock)`. 301 | 302 | See: . 303 | 304 | Args: 305 | user_group_id: the Slack ID of the user group being represented. 306 | bold: whether to render the given user in bold font. 307 | italic: whether to render the given user in italics. 308 | strike: whether to render the given user with a "strikethrough". 309 | highlight: whether to give the user a distinct highlight when rendered. 310 | client_highlight: ??? 311 | unlink: whether to remove the link to the user from the channel when 312 | rendered. 313 | """ 314 | 315 | def __init__( 316 | self, 317 | user_group_id: str, 318 | bold: Optional[bool] = None, 319 | italic: Optional[bool] = None, 320 | strike: Optional[bool] = None, 321 | highlight: Optional[bool] = None, 322 | client_highlight: Optional[bool] = None, 323 | unlink: Optional[bool] = None, 324 | ) -> None: 325 | super().__init__(RichTextElementType.USER_GROUP) 326 | self.user_group_id = user_group_id 327 | self.bold = bold 328 | self.italic = italic 329 | self.strike = strike 330 | self.highlight = highlight 331 | self.client_highlight = client_highlight 332 | self.unlink = unlink 333 | 334 | def _resolve(self) -> Dict[str, Any]: 335 | user_group = super()._resolve() 336 | user_group["user_group_id"] = self.user_group_id 337 | style = {} 338 | if self.bold is not None: 339 | style["bold"] = self.bold 340 | if self.italic is not None: 341 | style["italic"] = self.italic 342 | if self.strike is not None: 343 | style["strike"] = self.strike 344 | if self.highlight is not None: 345 | style["highlight"] = self.highlight 346 | if self.client_highlight is not None: 347 | style["client_highlight"] = self.client_highlight 348 | if self.unlink is not None: 349 | style["unlink"] = self.unlink 350 | if style: 351 | user_group["style"] = style 352 | return user_group 353 | -------------------------------------------------------------------------------- /docs_src/usage/using_blocks.md: -------------------------------------------------------------------------------- 1 | 2 | ## Section Block 3 | :::blocks.SectionBlock 4 | options: 5 | show_bases: false 6 | show_source: false 7 | 8 | 9 | === "`slackblocks`" 10 | 11 | ```python 12 | from slackblocks import Checkboxes, Option, SectionBlock 13 | 14 | SectionBlock( 15 | text="This is a section block with a checkbox accessory.", 16 | block_id="fake_block_id" 17 | accessory=CheckboxGroup( 18 | action_id="checkboxes-action", 19 | options=[ 20 | Option( 21 | text="*Your Only Option*", 22 | value="option_one" 23 | ) 24 | ] 25 | ) 26 | ) 27 | ``` 28 | 29 | === "JSON" 30 | ```json 31 | { 32 | "type": "section", 33 | "block_id": "fake_block_id", 34 | "text": { 35 | "type": "mrkdwn", 36 | "text": "This is a section block with a checkbox accessory." 37 | }, 38 | "accessory": { 39 | "type": "checkboxes", 40 | "options": [ 41 | { 42 | "text": { 43 | "type": "mrkdwn", 44 | "text": "*Your Only Option*" 45 | }, 46 | "value": "option_one" 47 | } 48 | ], 49 | "action_id": "checkboxes-action" 50 | } 51 | } 52 | ``` 53 | 54 | === "Slack UI" 55 | ![An example of the UI output of a Section Block](../img/usage/section.png) 56 | 57 | 58 | 59 | ## Rich Text Block 60 | :::blocks.RichTextBlock 61 | options: 62 | show_bases: false 63 | show_source: false 64 | 65 | === "`slackblocks`" 66 | ```python 67 | from slackblock import RichTextBlock, RichTextSection, RichText 68 | 69 | RichTextBlock( 70 | RichTextSection( 71 | [ 72 | RichText( 73 | "You 'bout to witness hip-hop in its most purest", 74 | bold=True, 75 | ), 76 | RichText( 77 | "Most rawest form, flow almost flawless", 78 | strike=True, 79 | ), 80 | RichText( 81 | "Most hardest, most honest known artist", 82 | italic=True, 83 | ), 84 | ] 85 | ), 86 | block_id="fake_block_id", 87 | ) 88 | ``` 89 | 90 | === "JSON" 91 | ```json 92 | { 93 | "type": "rich_text", 94 | "block_id": "fake_block_id", 95 | "elements": [ 96 | { 97 | "type": "rich_text_section", 98 | "elements": [ 99 | { 100 | "type": "text", 101 | "text": "You 'bout to witness hip-hop in its most purest\n", 102 | "style": { 103 | "bold": true 104 | } 105 | }, 106 | { 107 | "type": "text", 108 | "text": "Most rawest form, flow almost flawless\n", 109 | "style": { 110 | "strike": true 111 | } 112 | }, 113 | { 114 | "type": "text", 115 | "text": "Most hardest, most honest known artist\n", 116 | "style": { 117 | "italic": true 118 | } 119 | } 120 | ] 121 | } 122 | ] 123 | } 124 | ``` 125 | 126 | === "Slack UI" 127 | ![An example of the UI output of a Rich Text Block](../img/usage/rich_text.png) 128 | 129 | 130 | ## Header Block 131 | :::blocks.HeaderBlock 132 | options: 133 | show_bases: false 134 | show_source: false 135 | 136 | 137 | === "`slackblocks`" 138 | ```python 139 | from slackblocks import HeaderBlock 140 | 141 | HeaderBlock( 142 | "This is a header block", 143 | ) 144 | ``` 145 | 146 | === "JSON" 147 | ```json 148 | { 149 | "type": "header", 150 | "text": { 151 | "type": "plain_text", 152 | "text": "This is a header block", 153 | "emoji": true 154 | } 155 | } 156 | ``` 157 | 158 | === "Slack UI" 159 | ![An example of the UI output of a Header Block](../img/usage/header.png) 160 | 161 | 162 | ## Image Block 163 | :::blocks.ImageBlock 164 | options: 165 | show_bases: false 166 | show_source: false 167 | 168 | 169 | === "`slackblocks`" 170 | ```python 171 | from slackblocks import ImageBlock 172 | 173 | ImageBlock( 174 | image_url="https://api.slack.com/img/blocks/bkb_template_images/beagle.png", 175 | alt_text="a beagle", 176 | title="dog", 177 | block_id="fake_block_id", 178 | ) 179 | ``` 180 | 181 | === "JSON" 182 | ```json 183 | { 184 | "type": "image", 185 | "block_id": "fake_block_id", 186 | "image_url": "https://api.slack.com/img/blocks/bkb_template_images/beagle.png", 187 | "alt_text": "a beagle", 188 | "title": { 189 | "type": "plain_text", 190 | "text": "dog" 191 | } 192 | } 193 | ``` 194 | 195 | === "Slack UI" 196 | ![An example of the UI output of an Image Block](../img/usage/image.png) 197 | 198 | 199 | ## Input Block 200 | :::blocks.InputBlock 201 | options: 202 | show_bases: false 203 | show_source: false 204 | 205 | 206 | === "`slackblocks`" 207 | ```python 208 | from slackblocks import InputBlock, Text, TextType, PlainTextInput 209 | 210 | InputBlock( 211 | label=Text("Label", type_=TextType.PLAINTEXT, emoji=True), 212 | hint=Text("Hint", type_=TextType.PLAINTEXT, emoji=True), 213 | element=PlainTextInput(action_id="action"), 214 | block_id="fake_block_id", 215 | optional=True, 216 | ) 217 | ``` 218 | 219 | === "JSON" 220 | ```json 221 | { 222 | "type": "input", 223 | "block_id": "fake_block_id", 224 | "label": { 225 | "type": "plain_text", 226 | "text": "Label", 227 | "emoji": true 228 | }, 229 | "element": { 230 | "type": "plain_text_input", 231 | "action_id": "action" 232 | }, 233 | "hint": { 234 | "type": "plain_text", 235 | "text": "Hint", 236 | "emoji": true 237 | }, 238 | "optional": true 239 | } 240 | ``` 241 | 242 | === "Slack UI" 243 | ![An example of the UI output of an Input Block](../img/usage/input.png) 244 | 245 | 246 | ## Divider Block 247 | :::blocks.DividerBlock 248 | options: 249 | show_bases: false 250 | show_source: false 251 | 252 | 253 | === "`slackblocks`" 254 | ```python 255 | from slackblocks import DividerBlock 256 | 257 | DividerBlock() 258 | ``` 259 | 260 | === "JSON" 261 | ```json 262 | { 263 | "type": "divider" 264 | } 265 | ``` 266 | 267 | === "Slack UI" 268 | ![An example of the UI output of an Divider Block](../img/usage/divider.png) 269 | 270 | 271 | ## File Block 272 | :::blocks.FileBlock 273 | options: 274 | show_bases: false 275 | show_source: false 276 | 277 | 278 | === "`slackblocks`" 279 | ```python 280 | from slackblocks import FileBlock 281 | 282 | FileBlock( 283 | external_id="external_id", 284 | block_id="fake_block_id", 285 | ) 286 | ``` 287 | 288 | === "JSON" 289 | ```json 290 | { 291 | "type": "file", 292 | "external_id": "external_id", 293 | "source": "remote", 294 | "block_id": "fake_block_id" 295 | } 296 | ``` 297 | 298 | === "Slack UI" 299 | ![An example of the UI output of an File Block](https://a.slack-edge.com/f156a3/img/api/file_upload_remote_file.png) 300 | * Note that this example comes from the Slack Web API docs. 301 | 302 | 303 | ## Context Block 304 | :::blocks.ContextBlock 305 | options: 306 | show_bases: false 307 | show_source: false 308 | 309 | 310 | === "`slackblocks`" 311 | ```python 312 | from slackblocks import ContextBlock, Text 313 | 314 | ContextBlock( 315 | elements=[ 316 | Text("Hello, world!"), 317 | ], 318 | block_id="fake_block_id" 319 | ) 320 | ``` 321 | 322 | === "JSON" 323 | ```json 324 | { 325 | "type": "context", 326 | "block_id": "fake_block_id", 327 | "elements": [ 328 | { 329 | "type": "mrkdwn", 330 | "text": "Hello, world!" 331 | } 332 | ] 333 | } 334 | ``` 335 | 336 | === "Slack UI" 337 | ![An example of the UI output of an Context Block](../img/usage/divider.png) 338 | 339 | ## Actions Block 340 | :::blocks.ActionsBlock 341 | options: 342 | show_bases: false 343 | show_source: false 344 | 345 | === "`slackblocks`" 346 | ```python 347 | ActionsBlock( 348 | block_id="fake_block_id", 349 | elements=CheckboxGroup( 350 | action_id="actionId-0", 351 | options=[ 352 | Option(text="*a*", value="a", description="*a*"), 353 | Option(text="*b*", value="b", description="*b*"), 354 | Option(text="*c*", value="c", description="*c*"), 355 | ], 356 | ), 357 | ) 358 | ``` 359 | 360 | === "JSON" 361 | ```json 362 | { 363 | "type": "actions", 364 | "block_id": "fake_block_id", 365 | "elements": [ 366 | { 367 | "type": "checkboxes", 368 | "action_id": "actionId-0", 369 | "options": [ 370 | { 371 | "text": { 372 | "type": "mrkdwn", 373 | "text": "*a*" 374 | }, 375 | "value": "a", 376 | "description": { 377 | "type": "plain_text", 378 | "text": "*a*" 379 | } 380 | }, 381 | { 382 | "text": { 383 | "type": "mrkdwn", 384 | "text": "*b*" 385 | }, 386 | "value": "b", 387 | "description": { 388 | "type": "plain_text", 389 | "text": "*b*" 390 | } 391 | }, 392 | { 393 | "text": { 394 | "type": "mrkdwn", 395 | "text": "*c*" 396 | }, 397 | "value": "c", 398 | "description": { 399 | "type": "plain_text", 400 | "text": "*c*" 401 | } 402 | } 403 | ] 404 | } 405 | ] 406 | } 407 | ``` 408 | 409 | === "Slack UI" 410 | ![An example of the UI output of an Actions Block](../img/usage/actions.png) 411 | 412 | ## Table Block 413 | :::blocks.TableBlock 414 | options: 415 | show_bases: false 416 | show_source: false 417 | 418 | === "`slackblocks`" 419 | ```python 420 | TableBlock( 421 | column_settings=[ 422 | ColumnSettings(align="right", is_wrapped=True), 423 | ColumnSettings(align="left"), 424 | ], 425 | rows=[ 426 | [ 427 | RichTextSection( 428 | elements=RichText( 429 | text="Header 1", 430 | bold=True, 431 | ) 432 | ), 433 | RichTextSection( 434 | elements=RichTextLink( 435 | text="Header 2", 436 | bold=True, 437 | ) 438 | ), 439 | ], 440 | [ 441 | RawText(text="Datum 1"), 442 | RichTextSection( 443 | elements=RichTextLink( 444 | url="https://slack.com", 445 | text="Datum 2", 446 | ) 447 | ), 448 | ], 449 | ], 450 | ) 451 | ``` 452 | 453 | === "JSON" 454 | ```json 455 | { 456 | "blocks": [ 457 | { 458 | "type": "table", 459 | "rows": [ 460 | [ 461 | { 462 | "type": "rich_text", 463 | "elements": [ 464 | { 465 | "type": "rich_text_section", 466 | "elements": [ 467 | { 468 | "type": "text", 469 | "text": "Header 1", 470 | "style": { 471 | "bold": true 472 | } 473 | } 474 | ] 475 | } 476 | ] 477 | }, 478 | { 479 | "type": "rich_text", 480 | "elements": [ 481 | { 482 | "type": "rich_text_section", 483 | "elements": [ 484 | { 485 | "type": "text", 486 | "text": "Header 2", 487 | "style": { 488 | "bold": true 489 | } 490 | } 491 | ] 492 | } 493 | ] 494 | } 495 | ], 496 | [ 497 | { 498 | "type": "rich_text", 499 | "elements": [ 500 | { 501 | "type": "rich_text_section", 502 | "elements": [ 503 | { 504 | "type": "text", 505 | "text": "Datum 1" 506 | } 507 | ] 508 | } 509 | ] 510 | }, 511 | { 512 | "type": "rich_text", 513 | "elements": [ 514 | { 515 | "type": "rich_text_section", 516 | "elements": [ 517 | { 518 | "type": "text", 519 | "text": "Datum 2" 520 | } 521 | ] 522 | } 523 | ] 524 | } 525 | ] 526 | ] 527 | } 528 | ] 529 | } 530 | ``` 531 | 532 | === "Slack UI" 533 | ![An example of the UI output of a Table Block](../img/usage/table.png) 534 | -------------------------------------------------------------------------------- /slackblocks/blocks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Blocks are a series of container components that can be combined to create rich and 3 | interactive messages. 4 | 5 | See: . 6 | """ 7 | 8 | from abc import ABC, abstractmethod 9 | from enum import Enum 10 | from json import dumps 11 | from typing import Any, Dict, List, Optional, Union 12 | from uuid import uuid4 13 | 14 | from slackblocks.elements import ( 15 | ChannelMultiSelectMenu, 16 | ChannelSelectMenu, 17 | CheckboxGroup, 18 | ConversationMultiSelectMenu, 19 | ConversationSelectMenu, 20 | DatePicker, 21 | DateTimePicker, 22 | Element, 23 | ElementType, 24 | EmailInput, 25 | ExternalMultiSelectMenu, 26 | ExternalSelectMenu, 27 | NumberInput, 28 | PlainTextInput, 29 | RadioButtonGroup, 30 | RichTextInput, 31 | StaticMultiSelectMenu, 32 | StaticSelectMenu, 33 | URLInput, 34 | UserMultiSelectMenu, 35 | UserSelectMenu, 36 | ) 37 | from slackblocks.errors import InvalidUsageError 38 | from slackblocks.objects import ( 39 | ColumnSettings, 40 | CompositionObject, 41 | CompositionObjectType, 42 | RawText, 43 | Text, 44 | TextLike, 45 | TextType, 46 | ) 47 | from slackblocks.rich_text import ( 48 | RichTextCodeBlock, 49 | RichTextList, 50 | RichTextObject, 51 | RichTextQuote, 52 | RichTextSection, 53 | ) 54 | from slackblocks.utils import coerce_to_list, coerce_to_list_nonnull, validate_string 55 | 56 | ALLOWED_INPUT_ELEMENTS = ( 57 | PlainTextInput, 58 | NumberInput, 59 | CheckboxGroup, 60 | RadioButtonGroup, 61 | DatePicker, 62 | DateTimePicker, 63 | ChannelSelectMenu, 64 | ChannelMultiSelectMenu, 65 | ConversationSelectMenu, 66 | ConversationMultiSelectMenu, 67 | ExternalSelectMenu, 68 | ExternalMultiSelectMenu, 69 | StaticSelectMenu, 70 | StaticMultiSelectMenu, 71 | UserSelectMenu, 72 | UserMultiSelectMenu, 73 | RichTextInput, 74 | EmailInput, 75 | URLInput, 76 | ) 77 | 78 | 79 | ALLOWED_TABLE_CELL_ELEMENTS = ( 80 | RawText, 81 | RichTextList, 82 | RichTextCodeBlock, 83 | RichTextQuote, 84 | RichTextSection, 85 | ) 86 | 87 | 88 | class BlockType(Enum): 89 | """ 90 | Convenience class for identifying the different types of blocks available 91 | in the Slack Blocks API and their programmatic names. 92 | """ 93 | 94 | ACTIONS = "actions" 95 | CONTEXT = "context" 96 | DIVIDER = "divider" 97 | FILE = "file" 98 | HEADER = "header" 99 | IMAGE = "image" 100 | INPUT = "input" 101 | RICH_TEXT = "rich_text" 102 | SECTION = "section" 103 | TABLE = "table" 104 | 105 | 106 | class Block(ABC): 107 | """ 108 | Basis block containing attributes and behaviour common to all blocks. 109 | N.B: Block is an abstract class and cannot be sent directly. 110 | """ 111 | 112 | def __init__(self, type_: BlockType, block_id: Optional[str] = None) -> None: 113 | self.type = type_ 114 | self.block_id = block_id if block_id else str(uuid4()) 115 | 116 | def __add__(self, other: "Block"): 117 | return [self, other] 118 | 119 | def _attributes(self): 120 | return {"type": self.type.value, "block_id": self.block_id} 121 | 122 | @abstractmethod 123 | def _resolve(self) -> Dict[str, Any]: 124 | pass 125 | 126 | def __repr__(self) -> str: 127 | return dumps(self._resolve(), indent=4) 128 | 129 | 130 | class ActionsBlock(Block): 131 | """ 132 | A `Block` that is used to hold interactive elements (normally for users to interface with). 133 | 134 | Args: 135 | elements: a list of [Elements](/slackblocks/latest/reference/elements) 136 | (up to a maximum of 25). 137 | block_id: you can use this field to provide a deterministic identifier for the block. 138 | 139 | Throws: 140 | InvalidUsageError: if any of the items in `elements` are invalid. 141 | """ 142 | 143 | def __init__( 144 | self, 145 | elements: Optional[List[Element]] = None, 146 | block_id: Optional[str] = None, 147 | ) -> None: 148 | super().__init__(type_=BlockType.ACTIONS, block_id=block_id) 149 | self.elements: Optional[List[Element]] = coerce_to_list( 150 | elements, (Element), allow_none=True, max_size=25 151 | ) 152 | 153 | def _resolve(self): 154 | actions = self._attributes() 155 | actions["elements"] = [element._resolve() for element in self.elements] 156 | return actions 157 | 158 | 159 | class ContextBlock(Block): 160 | """ 161 | A `ContextBlock` displays contextul message info, including both images and text. 162 | 163 | Args: 164 | elements: a list of `Text` objects and `Image` elements. 165 | block_id: you can use this field to provide a deterministic identifier for the block. 166 | 167 | Throws: 168 | InvalidUsageError: when items in `elements` are not `Text` or `Image` or exceed 10 items. 169 | """ 170 | 171 | def __init__( 172 | self, 173 | elements: Optional[List[Union[Element, CompositionObject]]] = None, 174 | block_id: Optional[str] = None, 175 | ) -> None: 176 | super().__init__(type_=BlockType.CONTEXT, block_id=block_id) 177 | self.elements = [] 178 | if elements is not None: 179 | for element in elements: 180 | if ( 181 | element.type == CompositionObjectType.TEXT 182 | or element.type == ElementType.IMAGE 183 | ): 184 | self.elements.append(element) 185 | else: 186 | raise InvalidUsageError( 187 | f"Context blocks can only hold image and text elements, not {element.type}" 188 | ) 189 | if len(self.elements) > 10: 190 | raise InvalidUsageError("Context blocks can hold a maximum of ten elements") 191 | 192 | def _resolve(self) -> Dict[str, Any]: 193 | context = self._attributes() 194 | context["elements"] = [element._resolve() for element in self.elements] 195 | return context 196 | 197 | 198 | class DividerBlock(Block): 199 | """ 200 | A content divider, like an `
` in HTML, to split up different blocks inside of 201 | a message. 202 | 203 | Args: 204 | block_id: you can use this field to provide a deterministic identifier for the block. 205 | """ 206 | 207 | def __init__(self, block_id: Optional[str] = None) -> None: 208 | super().__init__(type_=BlockType.DIVIDER, block_id=block_id) 209 | 210 | def _resolve(self): 211 | return self._attributes() 212 | 213 | 214 | class FileBlock(Block): 215 | """ 216 | Displays a remote file (e.g. a PDF). 217 | 218 | For details on how remote files are exposed to Slack, see 219 | . 220 | 221 | Args: 222 | external_id: the ID assigned to the remote file when it was added to Slack. 223 | block_id: you can use this field to provide a deterministic identifier for the block. 224 | source: always "remote" as per the Slack API (may change in the future). 225 | """ 226 | 227 | def __init__( 228 | self, external_id: str, block_id: Optional[str], source: str = "remote" 229 | ) -> None: 230 | super().__init__(type_=BlockType.FILE, block_id=block_id) 231 | self.external_id = external_id 232 | self.source = source 233 | 234 | def _resolve(self) -> Dict[str, Any]: 235 | file = self._attributes() 236 | file["external_id"] = self.external_id 237 | file["source"] = self.source 238 | return file 239 | 240 | 241 | class HeaderBlock(Block): 242 | """ 243 | A Header Block is a plain-text block that displays in a larger, bold font. 244 | 245 | Args: 246 | text: the text that will be rendered as a heading. 247 | block_id: you can use this field to provide a deterministic identifier for the block. 248 | """ 249 | 250 | def __init__(self, text: Union[str, Text], block_id: Optional[str] = None) -> None: 251 | super().__init__(type_=BlockType.HEADER, block_id=block_id) 252 | if type(text) is Text: 253 | self.text = text 254 | else: 255 | self.text = Text.to_text_nonnull(text=text, force_plaintext=True) 256 | 257 | def _resolve(self) -> Dict[str, Any]: 258 | header = self._attributes() 259 | header["text"] = self.text._resolve() 260 | return header 261 | 262 | 263 | class ImageBlock(Block): 264 | """ 265 | An Image Block contains a single graphic, accessed by URL. 266 | 267 | Args: 268 | image_url: the URL pointing to the image file you want to display. 269 | alt_text: alternative text for accessibility purposes and when the image fails to load. 270 | title: an optional text title to be presented with the image. 271 | block_id: you can use this field to provide a deterministic identifier for the block. 272 | 273 | Throws: 274 | InvalidUsageError: when one or more of the provided args fails validation. 275 | """ 276 | 277 | def __init__( 278 | self, 279 | image_url: str, 280 | alt_text: Optional[str] = " ", 281 | title: Optional[Union[Text, str]] = None, 282 | block_id: Optional[str] = None, 283 | ) -> None: 284 | super().__init__(type_=BlockType.IMAGE, block_id=block_id) 285 | self.image_url = validate_string( 286 | string=image_url, 287 | field_name="title", 288 | max_length=3000, 289 | ) 290 | self.alt_text = validate_string( 291 | alt_text, field_name="alt_text", max_length=2000 292 | ) 293 | if title and isinstance(title, Text): 294 | if title.text_type == TextType.MARKDOWN: 295 | # Coerce title into plaintext 296 | self.title = Text( 297 | text=title.text, 298 | type_=TextType.PLAINTEXT, 299 | emoji=title.emoji, 300 | verbatim=title.verbatim, 301 | ) 302 | else: 303 | self.title = title 304 | elif isinstance(title, str): 305 | self.title = Text(text=title, type_=TextType.PLAINTEXT) 306 | 307 | def _resolve(self) -> Dict[str, Any]: 308 | image = self._attributes() 309 | image["image_url"] = self.image_url 310 | if self.alt_text: 311 | image["alt_text"] = self.alt_text 312 | if self.title: 313 | image["title"] = self.title._resolve() 314 | return image 315 | 316 | 317 | class InputBlock(Block): 318 | """ 319 | A block that collects information from users - it can hold a plain-text 320 | input element, a checkbox element, a radio button element, a select 321 | menu element, a multi-select menu element, or a datepicker. 322 | 323 | Args: 324 | label: the name which identifies the input field. 325 | element: an interactive [Element](/slackblocks/latest/reference/elements) 326 | (e.g. a text field). 327 | dispatch_action: whether the [Element](/slackblocks/latest/reference/elements) 328 | should trigger the sending of a `block_actions` payload. 329 | block_id: you can use this field to provide a deterministic identifier for the block. 330 | hint: an optional additional guide on what input the user should prodive. 331 | optional: whether this input field may be empty when the user submits e.g. the modal. 332 | 333 | Throws: 334 | InvalidUsageError: when any of the provided arguments fail validation. 335 | """ 336 | 337 | def __init__( 338 | self, 339 | label: TextLike, 340 | element: Element, 341 | dispatch_action: bool = False, 342 | block_id: Optional[str] = None, 343 | hint: Optional[TextLike] = None, 344 | optional: bool = False, 345 | ) -> None: 346 | super().__init__(type_=BlockType.INPUT, block_id=block_id) 347 | self.label = Text.to_text( 348 | label, force_plaintext=True, max_length=2000, allow_none=False 349 | ) 350 | if not isinstance(element, ALLOWED_INPUT_ELEMENTS): 351 | raise InvalidUsageError( 352 | f"InputBlocks can only hold elements of type: {ALLOWED_INPUT_ELEMENTS}" 353 | ) 354 | self.element = element 355 | self.dispatch_action = dispatch_action 356 | self.hint = Text.to_text( 357 | hint, force_plaintext=True, max_length=2000, allow_none=True 358 | ) 359 | self.optional = optional 360 | 361 | def _resolve(self) -> Dict[str, Any]: 362 | input_block = self._attributes() 363 | if self.label is not None: 364 | input_block["label"] = self.label._resolve() 365 | if self.element is not None: 366 | input_block["element"] = self.element._resolve() 367 | if self.hint: 368 | input_block["hint"] = self.hint._resolve() 369 | if self.dispatch_action: 370 | input_block["dispatch_action"] = self.dispatch_action 371 | if self.optional: 372 | input_block["optional"] = self.optional 373 | return input_block 374 | 375 | 376 | class RichTextBlock(Block): 377 | """ 378 | A RichTextBlock is used to provide easier rich text formatting 379 | than standard markdown text (e.g. in a 380 | [`SectionBlock`](/slackblocks/latest/reference/blocks/#blocks.SectionBlock)) 381 | and access to text formatting features not available in traditional 382 | markdown (e.g. strikethrough). See the various rich text elements 383 | you can include [here](/slackblocks/latest/reference/rich_text). 384 | 385 | Args: 386 | elements: a single [rich text element](rich_text) 387 | or a list of those elements. 388 | block_id: you can use this field to provide a deterministic identifier 389 | for the block. 390 | 391 | Throws: 392 | InvalidUsageError: if the elements in `elements` are not valid rich 393 | text elements. 394 | """ 395 | 396 | def __init__( 397 | self, 398 | elements: Union[RichTextObject, List[RichTextObject]], 399 | block_id: Optional[str] = None, 400 | ) -> None: 401 | super().__init__(type_=BlockType.RICH_TEXT, block_id=block_id) 402 | self.elements = coerce_to_list( 403 | elements, 404 | ( 405 | RichTextList, 406 | RichTextCodeBlock, 407 | RichTextQuote, 408 | RichTextSection, 409 | ), 410 | min_size=1, 411 | ) 412 | 413 | def _resolve(self) -> Dict[str, Any]: 414 | rich_text_block: Dict[str, Any] = self._attributes() 415 | if self.elements is not None: 416 | rich_text_block["elements"] = [ 417 | element._resolve() for element in self.elements 418 | ] 419 | return rich_text_block 420 | 421 | 422 | class SectionBlock(Block): 423 | """ 424 | A section is one of the most flexible blocks available - 425 | it can be used as a simple text block, or with any of the 426 | available block elements. 427 | 428 | Section blocks can also optionally be given an "accessory," 429 | which is typically one of the interactive 430 | [Elements](/slackblocks/latest/reference/elements). 431 | 432 | Args: 433 | text: text to include in the block. Can be a string or `Text` object (of either 434 | `mrkdwn` or `plaintext` variety). Defaults to markdown if unspecified. One of either 435 | `text` or `fields` must be provided. 436 | block_id: you can use this field to provide a deterministic identifier for the block. 437 | fields: a list of text objects. One of either `text` or `fields` must be provided. 438 | accessory: an optional [Element](/slackblocks/latest/reference/elements) object 439 | that will take a secondary place in the block (after or to the side of `text` 440 | or `fields`). 441 | 442 | Throws: 443 | InvalidUsageError: if any of the provided arguments fail validation checks. 444 | """ 445 | 446 | def __init__( 447 | self, 448 | text: Optional[TextLike] = None, 449 | block_id: Optional[str] = None, 450 | fields: Optional[Union[TextLike, List[TextLike]]] = None, 451 | accessory: Optional[Element] = None, 452 | ) -> None: 453 | super().__init__(type_=BlockType.SECTION, block_id=block_id) 454 | if not text and not fields: 455 | raise InvalidUsageError( 456 | "Must supply either `text` or `fields` or `both` to SectionBlock." 457 | ) 458 | self.text = Text.to_text(text, max_length=3000, allow_none=True) 459 | self.fields: Optional[List[Text]] 460 | if fields is not None: 461 | field_list: List[Union[str, Text]] = coerce_to_list_nonnull( 462 | fields, class_=(str, Text) 463 | ) 464 | self.fields = [ 465 | Text.to_text_nonnull(field, max_length=2000) 466 | for field in field_list 467 | if field is not None 468 | ] 469 | if len(self.fields) > 10: 470 | raise InvalidUsageError( 471 | "Section blocks can hold a maximum of ten fields" 472 | ) 473 | else: 474 | self.fields = None 475 | 476 | self.accessory = accessory 477 | 478 | def _resolve(self) -> Dict[str, Any]: 479 | section = self._attributes() 480 | if self.text: 481 | section["text"] = self.text._resolve() 482 | if self.fields: 483 | section["fields"] = [ 484 | field._resolve() for field in self.fields if isinstance(field, Text) 485 | ] 486 | if self.accessory: 487 | section["accessory"] = self.accessory._resolve() 488 | return section 489 | 490 | 491 | class TableBlock(Block): 492 | """ 493 | A `TableBlock` displays data in a table format. 494 | 495 | Args: 496 | rows: a list of lists of `RawText` or `RichTextObject` objects. 497 | column_settings: a list of `ColumnSettings` objects. 498 | block_id: you can use this field to provide a deterministic identifier for the block. 499 | 500 | Throws: 501 | InvalidUsageError: when items in `rows` are not `RawText` or `RichTextObject` objects. 502 | InvalidUsageError: when the number of column_settings does not match the number of 503 | columns in each row. 504 | InvalidUsageError: when the number of rows is greater than 100. 505 | InvalidUsageError: when the number of columns in a row is greater than 20. 506 | InvalidUsageError: when the number of column_settings is greater than 20. 507 | """ 508 | 509 | def __init__( 510 | self, 511 | rows: List[List[Union[RawText, RichTextObject]]], 512 | column_settings: Optional[List[ColumnSettings]] = None, 513 | block_id: Optional[str] = None, 514 | ) -> None: 515 | super().__init__(type_=BlockType.TABLE, block_id=block_id) 516 | # Validate that there is at least one row 517 | if len(rows) < 1: 518 | raise InvalidUsageError("`rows` must have at least one row.") 519 | # If column_settings are provided, make sure each row has the same number of elements 520 | num_columns = len(rows[0]) 521 | for row in rows: 522 | if len(row) != num_columns: 523 | raise InvalidUsageError( 524 | "All rows must have the same number of columns." 525 | ) 526 | if column_settings is not None: 527 | if num_columns != len(column_settings): 528 | raise InvalidUsageError( 529 | f"Number of column_settings ({len(column_settings)}) must" 530 | f"match number of columns in each row ({num_columns})." 531 | ) 532 | if len(rows) > 100: 533 | raise InvalidUsageError("`rows` can have a maximum of 100 items.") 534 | for row in rows: 535 | if len(row) > 20: 536 | raise InvalidUsageError("Each row can have a maximum of 20 cells.") 537 | # Validate each cell is an allowed type 538 | self.rows = [] 539 | for row in rows: 540 | validated_row = [] 541 | for cell in row: 542 | # Validate cell type 543 | if not isinstance(cell, ALLOWED_TABLE_CELL_ELEMENTS): 544 | raise InvalidUsageError( 545 | f"Table cells must be one of {ALLOWED_TABLE_CELL_ELEMENTS}" 546 | ) 547 | validated_row.append(cell) 548 | self.rows.append(validated_row) 549 | if column_settings and len(column_settings) > 20: 550 | raise InvalidUsageError("`column_settings` can have a maximum of 20 items.") 551 | self.column_settings = column_settings 552 | 553 | def _resolve(self) -> Dict[str, Any]: 554 | table = self._attributes() 555 | table["rows"] = [ 556 | [self._resolve_cell(cell) for cell in row] for row in self.rows 557 | ] 558 | if self.column_settings: 559 | table["column_settings"] = [ 560 | setting._resolve() for setting in self.column_settings 561 | ] 562 | return table 563 | 564 | def _resolve_cell(self, cell: Union[RawText, RichTextObject]) -> Dict[str, Any]: 565 | """ 566 | Resolve a table cell to its JSON representation. 567 | 568 | RawText cells are resolved directly. 569 | RichTextObject cells are wrapped in a rich_text structure. 570 | """ 571 | if isinstance(cell, RawText): 572 | return cell._resolve() 573 | else: 574 | # Wrap RichTextObject in rich_text structure 575 | return {"type": "rich_text", "elements": [cell._resolve()]} 576 | --------------------------------------------------------------------------------