Edit on GitHub

Helpers for building robust Slack messages

  1"""Helpers for building robust Slack messages"""
  2
  3import json
  4import typing as t
  5from enum import Enum
  6from textwrap import dedent
  7
  8
  9SLACK_MAX_TEXT_LENGTH = 3000
 10SLACK_MAX_ALERT_PREVIEW_BLOCKS = 5
 11SLACK_MAX_ATTACHMENTS_BLOCKS = 50
 12CONTINUATION_SYMBOL = "..."
 13
 14
 15TSlackBlock = t.Dict[str, t.Any]
 16
 17
 18class TSlackBlocks(t.TypedDict):
 19    blocks: t.List[TSlackBlock]
 20
 21
 22class TSlackMessage(TSlackBlocks):
 23    attachments: t.List[TSlackBlocks]
 24    text: str
 25
 26
 27class SlackMessageComposer:
 28    """Builds Slack message with primary and secondary blocks"""
 29
 30    def __init__(self, initial_message: t.Optional[TSlackMessage] = None) -> None:
 31        """Initialize the Slack message builder"""
 32        self.slack_message: TSlackMessage = initial_message or {
 33            "text": "",
 34            "blocks": [],
 35            "attachments": [{"blocks": []}],
 36        }
 37
 38    def add_primary_blocks(self, *blocks: TSlackBlock) -> "SlackMessageComposer":
 39        """Add blocks to the message. Blocks are always displayed"""
 40        self.slack_message["blocks"].extend(blocks)
 41        return self
 42
 43    def add_secondary_blocks(self, *blocks: TSlackBlock) -> "SlackMessageComposer":
 44        """Add attachments to the message
 45
 46        Attachments are hidden behind "show more" button. The first 5 attachments
 47        are always displayed. NOTICE: attachments blocks are deprecated by Slack
 48        """
 49        self.slack_message["attachments"][0]["blocks"].extend(blocks)
 50        if len(self.slack_message["attachments"][0]["blocks"]) >= SLACK_MAX_ATTACHMENTS_BLOCKS:
 51            raise ValueError("Too many attachments")
 52        return self
 53
 54    def add_text(self, text: str) -> "SlackMessageComposer":
 55        """Add text to the message
 56
 57        This text is used in places where content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.
 58        """
 59        self.slack_message["text"] = normalize_message(text)
 60        return self
 61
 62    def _introspect(self) -> "SlackMessageComposer":
 63        """Print the message to stdout
 64
 65        This is a debugging method. Useful during composition of the message."""
 66        print(json.dumps(self.slack_message, indent=2))
 67        return self
 68
 69
 70def normalize_message(message: t.Union[str, t.List[str], t.Tuple[str], t.Set[str]]) -> str:
 71    """Normalize message to fit Slack's max text length"""
 72    if isinstance(message, (list, tuple, set)):
 73        message = "\n".join(message)
 74    dedented_message = dedent(message)
 75    if len(dedented_message) < SLACK_MAX_TEXT_LENGTH:
 76        return dedent(dedented_message)
 77    return dedent(
 78        dedented_message[: SLACK_MAX_TEXT_LENGTH - len(CONTINUATION_SYMBOL) - 3]
 79        + CONTINUATION_SYMBOL
 80        + dedented_message[-3:]
 81    )
 82
 83
 84def divider_block() -> dict:
 85    """Create a divider block"""
 86    return {"type": "divider"}
 87
 88
 89def fields_section_block(*messages: str) -> dict:
 90    """Create a section block with multiple markdown fields"""
 91    return {
 92        "type": "section",
 93        "fields": [
 94            {
 95                "type": "mrkdwn",
 96                "text": normalize_message(message),
 97            }
 98            for message in messages
 99        ],
100    }
101
102
103def text_section_block(message: str) -> dict:
104    """Create a section block with text"""
105    return {
106        "type": "section",
107        "text": {
108            "type": "mrkdwn",
109            "text": normalize_message(message),
110        },
111    }
112
113
114def preformatted_rich_text_block(message: str) -> dict:
115    """Create a "rich text" block with pre-formatted text (i.e.: a code block).
116
117    Note that this can also be acheived with text_section_block and using markdown's
118    triple backticks.
119    This function avoids issues with the text containing such backticks and also does
120    not require editing the message in order to insert such backticks.
121    """
122    return {
123        "type": "rich_text",
124        "elements": [
125            {
126                "type": "rich_text_preformatted",
127                "elements": [
128                    {
129                        "type": "text",
130                        "text": normalize_message(message),
131                    },
132                ],
133            },
134        ],
135    }
136
137
138def empty_section_block() -> dict:
139    """Create an empty section block"""
140    return {
141        "type": "section",
142        "text": {
143            "type": "mrkdwn",
144            "text": normalize_message("\t"),
145        },
146    }
147
148
149def context_block(*messages: str) -> dict:
150    """Create a context block with multiple fields"""
151    return {
152        "type": "context",
153        "elements": [
154            {
155                "type": "mrkdwn",
156                "text": normalize_message(message),
157            }
158            for message in messages
159        ],
160    }
161
162
163def header_block(message: str) -> dict:
164    """Create a header block"""
165    return {
166        "type": "header",
167        "text": {
168            "type": "plain_text",
169            "text": message,
170        },
171    }
172
173
174def button_action_block(text: str, url: str) -> dict:
175    """Create a button action block"""
176    return {
177        "type": "actions",
178        "elements": [
179            {
180                "type": "button",
181                "text": {"type": "plain_text", "text": text, "emoji": True},
182                "value": text,
183                "url": url,
184            }
185        ],
186    }
187
188
189def compacted_sections_blocks(*messages: str) -> t.List[dict]:
190    """Create a list of compacted sections blocks"""
191    return [
192        {
193            "type": "section",
194            "fields": [
195                {
196                    "type": "mrkdwn",
197                    "text": normalize_message(message),
198                }
199                for message in messages[i : i + 2]
200            ],
201        }
202        for i in range(0, len(messages), 2)
203    ]
204
205
206class SlackAlertIcon(str, Enum):
207    """Enum for status of the alert"""
208
209    # General statuses
210    X = ":x:"
211    OK = ":large_green_circle:"
212    START = ":arrow_forward:"
213    STOP = ":stop_button:"
214    WARN = ":warning:"
215    ALERT = ":rotating_light:"
216
217    # Execution statuses
218    FAILURE = ":rotating_light:"
219    SUCCESS = ":white_check_mark:"
220    WARNING = ":warning:"
221    SKIPPED = ":fast_forward:"
222    PASSED = ":white_check_mark:"
223
224    # Log levels
225    UNKNOWN = ":question:"
226    INFO = ":information_source:"
227    DEBUG = ":beetle:"
228    CRITICAL = ":fire:"
229    FATAL = ":skull_and_crossbones:"
230    EXCEPTION = ":boom:"
231
232    def __str__(self) -> str:
233        return self.value
234
235
236def stringify_list(list_variation: t.Union[t.List[str], str]) -> str:
237    """Prettify and deduplicate list of strings"""
238    if isinstance(list_variation, str):
239        return list_variation
240    if len(list_variation) == 1:
241        return list_variation[0]
242    return " ".join(list_variation)
243
244
245message = SlackMessageComposer
SLACK_MAX_TEXT_LENGTH = 3000
SLACK_MAX_ALERT_PREVIEW_BLOCKS = 5
SLACK_MAX_ATTACHMENTS_BLOCKS = 50
CONTINUATION_SYMBOL = '...'
TSlackBlock = typing.Dict[str, typing.Any]
class TSlackBlocks(typing.TypedDict):
19class TSlackBlocks(t.TypedDict):
20    blocks: t.List[TSlackBlock]
blocks: List[Dict[str, Any]]
class TSlackMessage(builtins.dict):
23class TSlackMessage(TSlackBlocks):
24    attachments: t.List[TSlackBlocks]
25    text: str
attachments: List[TSlackBlocks]
text: str
blocks: List[Dict[str, Any]]
Inherited Members
builtins.dict
get
setdefault
pop
popitem
keys
items
values
update
fromkeys
clear
copy
class SlackMessageComposer:
28class SlackMessageComposer:
29    """Builds Slack message with primary and secondary blocks"""
30
31    def __init__(self, initial_message: t.Optional[TSlackMessage] = None) -> None:
32        """Initialize the Slack message builder"""
33        self.slack_message: TSlackMessage = initial_message or {
34            "text": "",
35            "blocks": [],
36            "attachments": [{"blocks": []}],
37        }
38
39    def add_primary_blocks(self, *blocks: TSlackBlock) -> "SlackMessageComposer":
40        """Add blocks to the message. Blocks are always displayed"""
41        self.slack_message["blocks"].extend(blocks)
42        return self
43
44    def add_secondary_blocks(self, *blocks: TSlackBlock) -> "SlackMessageComposer":
45        """Add attachments to the message
46
47        Attachments are hidden behind "show more" button. The first 5 attachments
48        are always displayed. NOTICE: attachments blocks are deprecated by Slack
49        """
50        self.slack_message["attachments"][0]["blocks"].extend(blocks)
51        if len(self.slack_message["attachments"][0]["blocks"]) >= SLACK_MAX_ATTACHMENTS_BLOCKS:
52            raise ValueError("Too many attachments")
53        return self
54
55    def add_text(self, text: str) -> "SlackMessageComposer":
56        """Add text to the message
57
58        This text is used in places where content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.
59        """
60        self.slack_message["text"] = normalize_message(text)
61        return self
62
63    def _introspect(self) -> "SlackMessageComposer":
64        """Print the message to stdout
65
66        This is a debugging method. Useful during composition of the message."""
67        print(json.dumps(self.slack_message, indent=2))
68        return self

Builds Slack message with primary and secondary blocks

SlackMessageComposer( initial_message: Optional[TSlackMessage] = None)
31    def __init__(self, initial_message: t.Optional[TSlackMessage] = None) -> None:
32        """Initialize the Slack message builder"""
33        self.slack_message: TSlackMessage = initial_message or {
34            "text": "",
35            "blocks": [],
36            "attachments": [{"blocks": []}],
37        }

Initialize the Slack message builder

slack_message: TSlackMessage
def add_primary_blocks( self, *blocks: Dict[str, Any]) -> SlackMessageComposer:
39    def add_primary_blocks(self, *blocks: TSlackBlock) -> "SlackMessageComposer":
40        """Add blocks to the message. Blocks are always displayed"""
41        self.slack_message["blocks"].extend(blocks)
42        return self

Add blocks to the message. Blocks are always displayed

def add_secondary_blocks( self, *blocks: Dict[str, Any]) -> SlackMessageComposer:
44    def add_secondary_blocks(self, *blocks: TSlackBlock) -> "SlackMessageComposer":
45        """Add attachments to the message
46
47        Attachments are hidden behind "show more" button. The first 5 attachments
48        are always displayed. NOTICE: attachments blocks are deprecated by Slack
49        """
50        self.slack_message["attachments"][0]["blocks"].extend(blocks)
51        if len(self.slack_message["attachments"][0]["blocks"]) >= SLACK_MAX_ATTACHMENTS_BLOCKS:
52            raise ValueError("Too many attachments")
53        return self

Add attachments to the message

Attachments are hidden behind "show more" button. The first 5 attachments are always displayed. NOTICE: attachments blocks are deprecated by Slack

def add_text(self, text: str) -> SlackMessageComposer:
55    def add_text(self, text: str) -> "SlackMessageComposer":
56        """Add text to the message
57
58        This text is used in places where content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.
59        """
60        self.slack_message["text"] = normalize_message(text)
61        return self

Add text to the message

This text is used in places where content cannot be rendered such as: system push notifications, assistive technology such as screen readers, etc.

def normalize_message(message: Union[str, List[str], Tuple[str], Set[str]]) -> str:
71def normalize_message(message: t.Union[str, t.List[str], t.Tuple[str], t.Set[str]]) -> str:
72    """Normalize message to fit Slack's max text length"""
73    if isinstance(message, (list, tuple, set)):
74        message = "\n".join(message)
75    dedented_message = dedent(message)
76    if len(dedented_message) < SLACK_MAX_TEXT_LENGTH:
77        return dedent(dedented_message)
78    return dedent(
79        dedented_message[: SLACK_MAX_TEXT_LENGTH - len(CONTINUATION_SYMBOL) - 3]
80        + CONTINUATION_SYMBOL
81        + dedented_message[-3:]
82    )

Normalize message to fit Slack's max text length

def divider_block() -> dict:
85def divider_block() -> dict:
86    """Create a divider block"""
87    return {"type": "divider"}

Create a divider block

def fields_section_block(*messages: str) -> dict:
 90def fields_section_block(*messages: str) -> dict:
 91    """Create a section block with multiple markdown fields"""
 92    return {
 93        "type": "section",
 94        "fields": [
 95            {
 96                "type": "mrkdwn",
 97                "text": normalize_message(message),
 98            }
 99            for message in messages
100        ],
101    }

Create a section block with multiple markdown fields

def text_section_block(message: str) -> dict:
104def text_section_block(message: str) -> dict:
105    """Create a section block with text"""
106    return {
107        "type": "section",
108        "text": {
109            "type": "mrkdwn",
110            "text": normalize_message(message),
111        },
112    }

Create a section block with text

def preformatted_rich_text_block(message: str) -> dict:
115def preformatted_rich_text_block(message: str) -> dict:
116    """Create a "rich text" block with pre-formatted text (i.e.: a code block).
117
118    Note that this can also be acheived with text_section_block and using markdown's
119    triple backticks.
120    This function avoids issues with the text containing such backticks and also does
121    not require editing the message in order to insert such backticks.
122    """
123    return {
124        "type": "rich_text",
125        "elements": [
126            {
127                "type": "rich_text_preformatted",
128                "elements": [
129                    {
130                        "type": "text",
131                        "text": normalize_message(message),
132                    },
133                ],
134            },
135        ],
136    }

Create a "rich text" block with pre-formatted text (i.e.: a code block).

Note that this can also be acheived with text_section_block and using markdown's triple backticks. This function avoids issues with the text containing such backticks and also does not require editing the message in order to insert such backticks.

def empty_section_block() -> dict:
139def empty_section_block() -> dict:
140    """Create an empty section block"""
141    return {
142        "type": "section",
143        "text": {
144            "type": "mrkdwn",
145            "text": normalize_message("\t"),
146        },
147    }

Create an empty section block

def context_block(*messages: str) -> dict:
150def context_block(*messages: str) -> dict:
151    """Create a context block with multiple fields"""
152    return {
153        "type": "context",
154        "elements": [
155            {
156                "type": "mrkdwn",
157                "text": normalize_message(message),
158            }
159            for message in messages
160        ],
161    }

Create a context block with multiple fields

def header_block(message: str) -> dict:
164def header_block(message: str) -> dict:
165    """Create a header block"""
166    return {
167        "type": "header",
168        "text": {
169            "type": "plain_text",
170            "text": message,
171        },
172    }

Create a header block

def button_action_block(text: str, url: str) -> dict:
175def button_action_block(text: str, url: str) -> dict:
176    """Create a button action block"""
177    return {
178        "type": "actions",
179        "elements": [
180            {
181                "type": "button",
182                "text": {"type": "plain_text", "text": text, "emoji": True},
183                "value": text,
184                "url": url,
185            }
186        ],
187    }

Create a button action block

def compacted_sections_blocks(*messages: str) -> List[dict]:
190def compacted_sections_blocks(*messages: str) -> t.List[dict]:
191    """Create a list of compacted sections blocks"""
192    return [
193        {
194            "type": "section",
195            "fields": [
196                {
197                    "type": "mrkdwn",
198                    "text": normalize_message(message),
199                }
200                for message in messages[i : i + 2]
201            ],
202        }
203        for i in range(0, len(messages), 2)
204    ]

Create a list of compacted sections blocks

class SlackAlertIcon(builtins.str, enum.Enum):
207class SlackAlertIcon(str, Enum):
208    """Enum for status of the alert"""
209
210    # General statuses
211    X = ":x:"
212    OK = ":large_green_circle:"
213    START = ":arrow_forward:"
214    STOP = ":stop_button:"
215    WARN = ":warning:"
216    ALERT = ":rotating_light:"
217
218    # Execution statuses
219    FAILURE = ":rotating_light:"
220    SUCCESS = ":white_check_mark:"
221    WARNING = ":warning:"
222    SKIPPED = ":fast_forward:"
223    PASSED = ":white_check_mark:"
224
225    # Log levels
226    UNKNOWN = ":question:"
227    INFO = ":information_source:"
228    DEBUG = ":beetle:"
229    CRITICAL = ":fire:"
230    FATAL = ":skull_and_crossbones:"
231    EXCEPTION = ":boom:"
232
233    def __str__(self) -> str:
234        return self.value

Enum for status of the alert

X = <SlackAlertIcon.X: ':x:'>
OK = <SlackAlertIcon.OK: ':large_green_circle:'>
START = <SlackAlertIcon.START: ':arrow_forward:'>
STOP = <SlackAlertIcon.STOP: ':stop_button:'>
WARN = <SlackAlertIcon.WARN: ':warning:'>
ALERT = <SlackAlertIcon.ALERT: ':rotating_light:'>
FAILURE = <SlackAlertIcon.ALERT: ':rotating_light:'>
SUCCESS = <SlackAlertIcon.SUCCESS: ':white_check_mark:'>
WARNING = <SlackAlertIcon.WARN: ':warning:'>
SKIPPED = <SlackAlertIcon.SKIPPED: ':fast_forward:'>
PASSED = <SlackAlertIcon.SUCCESS: ':white_check_mark:'>
UNKNOWN = <SlackAlertIcon.UNKNOWN: ':question:'>
INFO = <SlackAlertIcon.INFO: ':information_source:'>
DEBUG = <SlackAlertIcon.DEBUG: ':beetle:'>
CRITICAL = <SlackAlertIcon.CRITICAL: ':fire:'>
FATAL = <SlackAlertIcon.FATAL: ':skull_and_crossbones:'>
EXCEPTION = <SlackAlertIcon.EXCEPTION: ':boom:'>
Inherited Members
enum.Enum
name
value
builtins.str
encode
replace
split
rsplit
join
capitalize
casefold
title
center
count
expandtabs
find
partition
index
ljust
lower
lstrip
rfind
rindex
rjust
rstrip
rpartition
splitlines
strip
swapcase
translate
upper
startswith
endswith
removeprefix
removesuffix
isascii
islower
isupper
istitle
isspace
isdecimal
isdigit
isnumeric
isalpha
isalnum
isidentifier
isprintable
zfill
format
format_map
maketrans
def stringify_list(list_variation: Union[List[str], str]) -> str:
237def stringify_list(list_variation: t.Union[t.List[str], str]) -> str:
238    """Prettify and deduplicate list of strings"""
239    if isinstance(list_variation, str):
240        return list_variation
241    if len(list_variation) == 1:
242        return list_variation[0]
243    return " ".join(list_variation)

Prettify and deduplicate list of strings

message = <class 'SlackMessageComposer'>