Skip to content

Commit 8df67b4

Browse files
committed
feat(blocks): add new block kit types {Card, Carousel, Alarm}
1 parent aa3a16d commit 8df67b4

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

slack_sdk/models/blocks/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@
6161
)
6262
from .blocks import (
6363
ActionsBlock,
64+
AlertBlock,
6465
Block,
6566
CallBlock,
67+
CardBlock,
68+
CarouselBlock,
6669
ContextActionsBlock,
6770
ContextBlock,
6871
DividerBlock,
@@ -129,8 +132,11 @@
129132
"RichTextQuoteElement",
130133
"RichTextSectionElement",
131134
"ActionsBlock",
135+
"AlertBlock",
132136
"Block",
133137
"CallBlock",
138+
"CardBlock",
139+
"CarouselBlock",
134140
"ContextActionsBlock",
135141
"ContextBlock",
136142
"DividerBlock",

slack_sdk/models/blocks/blocks.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
102102
return TaskCardBlock(**block)
103103
elif type == PlanBlock.type:
104104
return PlanBlock(**block)
105+
elif type == CardBlock.type:
106+
return CardBlock(**block)
107+
elif type == AlertBlock.type:
108+
return AlertBlock(**block)
109+
elif type == CarouselBlock.type:
110+
return CarouselBlock(**block)
105111
else:
106112
cls.logger.warning(f"Unknown block detected and skipped ({block})")
107113
return None
@@ -878,3 +884,152 @@ def __init__(
878884

879885
self.title = title
880886
self.tasks = tasks
887+
888+
889+
class CardBlock(Block):
890+
type = "card"
891+
title_max_length = 150
892+
subtitle_max_length = 150
893+
body_max_length = 200
894+
895+
@property
896+
def attributes(self) -> Set[str]: # type: ignore[override]
897+
return super().attributes.union(
898+
{
899+
"hero_image",
900+
"icon",
901+
"title",
902+
"subtitle",
903+
"body",
904+
"actions",
905+
}
906+
)
907+
908+
def __init__(
909+
self,
910+
*,
911+
block_id: Optional[str] = None,
912+
hero_image: Optional[Union[dict, ImageElement]] = None,
913+
icon: Optional[Union[dict, ImageElement]] = None,
914+
title: Optional[Union[str, dict, TextObject]] = None,
915+
subtitle: Optional[Union[str, dict, TextObject]] = None,
916+
body: Optional[Union[str, dict, TextObject]] = None,
917+
actions: Optional[Sequence[Union[dict, BlockElement]]] = None,
918+
**others: dict,
919+
):
920+
"""A rich display block for presenting structured content such as recommendations, results, or work items.
921+
https://docs.slack.dev/reference/block-kit/blocks/card-block
922+
923+
Args:
924+
block_id: A string acting as a unique identifier for a block. If not specified, one will be generated.
925+
Maximum length for this field is 255 characters.
926+
hero_image: A top banner image for the card. An image element with type, image_url, and alt_text.
927+
icon: A small icon next to the title/subtitle. An image element with type, image_url, and alt_text.
928+
title: The title of the card. Supports mrkdwn text. Maximum length is 150 characters.
929+
subtitle: The subtitle of the card. Supports mrkdwn text. Maximum length is 150 characters.
930+
body: The body text of the card. Supports mrkdwn text. Maximum length is 200 characters.
931+
actions: An array of button elements displayed at the bottom of the card.
932+
"""
933+
super().__init__(type=self.type, block_id=block_id)
934+
show_unknown_key_warning(self, others)
935+
936+
self.hero_image = BlockElement.parse(hero_image) # type: ignore[arg-type]
937+
self.icon = BlockElement.parse(icon) # type: ignore[arg-type]
938+
self.title = TextObject.parse(title, default_type=MarkdownTextObject.type) # type: ignore[arg-type]
939+
self.subtitle = TextObject.parse(subtitle, default_type=MarkdownTextObject.type) # type: ignore[arg-type]
940+
self.body = TextObject.parse(body, default_type=MarkdownTextObject.type) # type: ignore[arg-type]
941+
self.actions = BlockElement.parse_all(actions) if actions else None # type: ignore[arg-type]
942+
943+
@JsonValidator("At least one of hero_image, title, actions, or body is required")
944+
def _validate_content(self):
945+
return self.hero_image is not None or self.title is not None or self.actions is not None or self.body is not None
946+
947+
@JsonValidator(f"title attribute cannot exceed {title_max_length} characters")
948+
def _validate_title_length(self):
949+
return self.title is None or self.title.text is None or len(self.title.text) <= self.title_max_length
950+
951+
@JsonValidator(f"subtitle attribute cannot exceed {subtitle_max_length} characters")
952+
def _validate_subtitle_length(self):
953+
return self.subtitle is None or self.subtitle.text is None or len(self.subtitle.text) <= self.subtitle_max_length
954+
955+
@JsonValidator(f"body attribute cannot exceed {body_max_length} characters")
956+
def _validate_body_length(self):
957+
return self.body is None or self.body.text is None or len(self.body.text) <= self.body_max_length
958+
959+
960+
class AlertBlock(Block):
961+
type = "alert"
962+
valid_levels = {"default", "info", "warning", "error", "success"}
963+
964+
@property
965+
def attributes(self) -> Set[str]: # type: ignore[override]
966+
return super().attributes.union({"text", "level"})
967+
968+
def __init__(
969+
self,
970+
*,
971+
text: Union[str, dict, TextObject],
972+
level: Optional[str] = None,
973+
block_id: Optional[str] = None,
974+
**others: dict,
975+
):
976+
"""A prominent notice block for displaying warnings, status updates, or other important information.
977+
https://docs.slack.dev/reference/block-kit/blocks/alert-block
978+
979+
Args:
980+
text (required): The alert message content. Supports plain_text or mrkdwn.
981+
level: The severity level of the alert. One of "default", "info", "warning", "error", "success".
982+
Defaults to "default".
983+
block_id: A string acting as a unique identifier for a block. If not specified, one will be generated.
984+
Maximum length for this field is 255 characters.
985+
"""
986+
super().__init__(type=self.type, block_id=block_id)
987+
show_unknown_key_warning(self, others)
988+
989+
self.text = TextObject.parse(text) # type: ignore[arg-type]
990+
self.level = level
991+
992+
@JsonValidator("text attribute must be specified")
993+
def _validate_text(self):
994+
return self.text is not None
995+
996+
@JsonValidator("level must be a valid value (default, info, warning, error, success)")
997+
def _validate_level(self):
998+
return self.level is None or self.level in self.valid_levels
999+
1000+
1001+
class CarouselBlock(Block):
1002+
type = "carousel"
1003+
elements_max_length = 10
1004+
1005+
@property
1006+
def attributes(self) -> Set[str]: # type: ignore[override]
1007+
return super().attributes.union({"elements"})
1008+
1009+
def __init__(
1010+
self,
1011+
*,
1012+
elements: Sequence[Union[dict, "CardBlock"]],
1013+
block_id: Optional[str] = None,
1014+
**others: dict,
1015+
):
1016+
"""A horizontally scrollable collection of card blocks.
1017+
https://docs.slack.dev/reference/block-kit/blocks/carousel-block
1018+
1019+
Args:
1020+
elements (required): An array of card block objects. Minimum 1, maximum 10 cards.
1021+
block_id: A string acting as a unique identifier for a block. If not specified, one will be generated.
1022+
Maximum length for this field is 255 characters.
1023+
"""
1024+
super().__init__(type=self.type, block_id=block_id)
1025+
show_unknown_key_warning(self, others)
1026+
1027+
self.elements = Block.parse_all(elements)
1028+
1029+
@JsonValidator("elements attribute must contain at least 1 card")
1030+
def _validate_elements_present(self):
1031+
return self.elements is not None and len(self.elements) >= 1
1032+
1033+
@JsonValidator(f"elements attribute cannot exceed {elements_max_length} cards")
1034+
def _validate_elements_length(self):
1035+
return self.elements is None or len(self.elements) <= self.elements_max_length

tests/slack_sdk/models/test_blocks.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
from slack_sdk.errors import SlackObjectFormationError
55
from slack_sdk.models.blocks import (
66
ActionsBlock,
7+
AlertBlock,
78
Block,
89
ButtonElement,
910
CallBlock,
11+
CardBlock,
12+
CarouselBlock,
1013
ContextActionsBlock,
1114
ContextBlock,
1215
DividerBlock,
@@ -1551,3 +1554,153 @@ def test_with_raw_text_object_helper(self):
15511554
],
15521555
}
15531556
self.assertDictEqual(expected, block.to_dict())
1557+
1558+
1559+
class CardBlockTests(unittest.TestCase):
1560+
def test_document(self):
1561+
input = {
1562+
"type": "card",
1563+
"icon": {"type": "image", "image_url": "https://picsum.photos/36/36", "alt_text": "Icon"},
1564+
"title": {"type": "mrkdwn", "text": "Lumon Industries", "verbatim": False},
1565+
"subtitle": {"type": "mrkdwn", "text": "Committed to work-life balance", "verbatim": False},
1566+
"hero_image": {"type": "image", "image_url": "https://picsum.photos/400/300", "alt_text": "Sample hero image"},
1567+
"body": {"type": "mrkdwn", "text": "Please enjoy each card equally.", "verbatim": False},
1568+
"actions": [
1569+
{
1570+
"type": "button",
1571+
"text": {"type": "plain_text", "text": "Action Button", "emoji": False},
1572+
"action_id": "button_action",
1573+
}
1574+
],
1575+
}
1576+
self.assertDictEqual(input, CardBlock(**input).to_dict())
1577+
1578+
def test_parse(self):
1579+
input = {
1580+
"type": "card",
1581+
"title": {"type": "mrkdwn", "text": "Title"},
1582+
"body": {"type": "mrkdwn", "text": "Body text"},
1583+
}
1584+
parsed = Block.parse(input)
1585+
self.assertIsNotNone(parsed)
1586+
self.assertDictEqual(input, parsed.to_dict())
1587+
1588+
def test_minimal_with_title(self):
1589+
input = {
1590+
"type": "card",
1591+
"title": {"type": "mrkdwn", "text": "Just a title"},
1592+
}
1593+
self.assertDictEqual(input, CardBlock(**input).to_dict())
1594+
1595+
def test_minimal_with_body(self):
1596+
input = {
1597+
"type": "card",
1598+
"body": {"type": "mrkdwn", "text": "Just body text"},
1599+
}
1600+
self.assertDictEqual(input, CardBlock(**input).to_dict())
1601+
1602+
def test_validation_at_least_one_field(self):
1603+
with self.assertRaises(SlackObjectFormationError):
1604+
CardBlock().validate_json()
1605+
1606+
def test_title_length_validation(self):
1607+
with self.assertRaises(SlackObjectFormationError):
1608+
CardBlock(title={"type": "mrkdwn", "text": "a" * 151}).validate_json()
1609+
1610+
def test_subtitle_length_validation(self):
1611+
with self.assertRaises(SlackObjectFormationError):
1612+
CardBlock(
1613+
title={"type": "mrkdwn", "text": "Title"},
1614+
subtitle={"type": "mrkdwn", "text": "a" * 151},
1615+
).validate_json()
1616+
1617+
def test_body_length_validation(self):
1618+
with self.assertRaises(SlackObjectFormationError):
1619+
CardBlock(body={"type": "mrkdwn", "text": "a" * 201}).validate_json()
1620+
1621+
1622+
class AlertBlockTests(unittest.TestCase):
1623+
def test_document(self):
1624+
input = {
1625+
"type": "alert",
1626+
"text": {"type": "mrkdwn", "text": "The work is mysterious and important.", "verbatim": False},
1627+
"level": "info",
1628+
}
1629+
self.assertDictEqual(input, AlertBlock(**input).to_dict())
1630+
1631+
def test_parse(self):
1632+
input = {
1633+
"type": "alert",
1634+
"text": {"type": "mrkdwn", "text": "Notice"},
1635+
"level": "warning",
1636+
}
1637+
parsed = Block.parse(input)
1638+
self.assertIsNotNone(parsed)
1639+
self.assertDictEqual(input, parsed.to_dict())
1640+
1641+
def test_minimal(self):
1642+
input = {
1643+
"type": "alert",
1644+
"text": {"type": "plain_text", "text": "Simple alert"},
1645+
}
1646+
self.assertDictEqual(input, AlertBlock(**input).to_dict())
1647+
1648+
def test_all_levels(self):
1649+
for level in ["default", "info", "warning", "error", "success"]:
1650+
input = {
1651+
"type": "alert",
1652+
"text": {"type": "plain_text", "text": "Test"},
1653+
"level": level,
1654+
}
1655+
AlertBlock(**input).validate_json()
1656+
1657+
def test_invalid_level(self):
1658+
with self.assertRaises(SlackObjectFormationError):
1659+
AlertBlock(text={"type": "plain_text", "text": "Test"}, level="critical").validate_json()
1660+
1661+
def test_missing_text(self):
1662+
with self.assertRaises(SlackObjectFormationError):
1663+
AlertBlock(text="").validate_json()
1664+
1665+
1666+
class CarouselBlockTests(unittest.TestCase):
1667+
def test_document(self):
1668+
input = {
1669+
"type": "carousel",
1670+
"elements": [
1671+
{
1672+
"type": "card",
1673+
"title": {"type": "mrkdwn", "text": "Card 1"},
1674+
},
1675+
{
1676+
"type": "card",
1677+
"title": {"type": "mrkdwn", "text": "Card 2"},
1678+
"body": {"type": "mrkdwn", "text": "Some body text"},
1679+
},
1680+
],
1681+
}
1682+
self.assertDictEqual(input, CarouselBlock(**input).to_dict())
1683+
1684+
def test_parse(self):
1685+
input = {
1686+
"type": "carousel",
1687+
"elements": [
1688+
{"type": "card", "title": {"type": "mrkdwn", "text": "Card 1"}},
1689+
],
1690+
}
1691+
parsed = Block.parse(input)
1692+
self.assertIsNotNone(parsed)
1693+
self.assertDictEqual(input, parsed.to_dict())
1694+
1695+
def test_single_card(self):
1696+
input = {
1697+
"type": "carousel",
1698+
"elements": [
1699+
{"type": "card", "title": {"type": "mrkdwn", "text": "Only card"}},
1700+
],
1701+
}
1702+
self.assertDictEqual(input, CarouselBlock(**input).to_dict())
1703+
1704+
def test_empty_elements_validation(self):
1705+
with self.assertRaises(SlackObjectFormationError):
1706+
CarouselBlock(elements=[]).validate_json()

0 commit comments

Comments
 (0)