diff --git a/slack_sdk/models/blocks/__init__.py b/slack_sdk/models/blocks/__init__.py index b8592c2e9..6a26ed958 100644 --- a/slack_sdk/models/blocks/__init__.py +++ b/slack_sdk/models/blocks/__init__.py @@ -61,8 +61,11 @@ ) from .blocks import ( ActionsBlock, + AlertBlock, Block, CallBlock, + CardBlock, + CarouselBlock, ContextActionsBlock, ContextBlock, DividerBlock, @@ -129,8 +132,11 @@ "RichTextQuoteElement", "RichTextSectionElement", "ActionsBlock", + "AlertBlock", "Block", "CallBlock", + "CardBlock", + "CarouselBlock", "ContextActionsBlock", "ContextBlock", "DividerBlock", diff --git a/slack_sdk/models/blocks/blocks.py b/slack_sdk/models/blocks/blocks.py index 7d7f937f5..db4de1f3a 100644 --- a/slack_sdk/models/blocks/blocks.py +++ b/slack_sdk/models/blocks/blocks.py @@ -102,6 +102,12 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]: return TaskCardBlock(**block) elif type == PlanBlock.type: return PlanBlock(**block) + elif type == CardBlock.type: + return CardBlock(**block) + elif type == AlertBlock.type: + return AlertBlock(**block) + elif type == CarouselBlock.type: + return CarouselBlock(**block) else: cls.logger.warning(f"Unknown block detected and skipped ({block})") return None @@ -878,3 +884,149 @@ def __init__( self.title = title self.tasks = tasks + + +class AlertBlock(Block): + type = "alert" + valid_levels = {"default", "info", "warning", "error", "success"} + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union({"text", "level"}) + + def __init__( + self, + *, + text: Union[str, dict, TextObject], + level: Optional[str] = None, + block_id: Optional[str] = None, + **others: dict, + ): + """Displays alerts, warnings, and informational messages. + https://docs.slack.dev/reference/block-kit/blocks/alert-block + + Args: + text (required): The alert message, using plain_text or mrkdwn formatting. + level: One of "default", "info", "warning", "error", or "success". + Will be "default" if omitted. + block_id: A unique identifier for a block. If not specified, a block_id will be generated. + """ + super().__init__(type=self.type, block_id=block_id) + show_unknown_key_warning(self, others) + + self.text = TextObject.parse(text) + self.level = level + + @JsonValidator("text attribute must be specified") + def _validate_text(self): + return self.text is not None + + @JsonValidator("level must be a valid value (default, info, warning, error, success)") + def _validate_level(self): + return self.level is None or self.level in self.valid_levels + + +class CardBlock(Block): + type = "card" + title_max_length = 150 + subtitle_max_length = 150 + body_max_length = 200 + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union( + { + "hero_image", + "icon", + "title", + "subtitle", + "body", + "actions", + } + ) + + def __init__( + self, + *, + block_id: Optional[str] = None, + hero_image: Optional[str] = None, + icon: Optional[str] = None, + title: Optional[Union[str, dict, TextObject]] = None, + subtitle: Optional[Union[str, dict, TextObject]] = None, + body: Optional[Union[str, dict, TextObject]] = None, + actions: Optional[Sequence[Union[dict, BlockElement]]] = None, + **others: dict, + ): + """Displays content in a card. + https://docs.slack.dev/reference/block-kit/blocks/card-block + + Args: + block_id: A unique identifier for a block. If not specified, a block_id will be generated. + hero_image: Link to the top image used on the card. + icon: Link to the small image used next to the card's title and subtitle. + title: Title of the card. 150 characters max. + subtitle: Subtitle of the card. 150 characters max. + body: Content of the card. 200 characters max. + actions: Action buttons shown at the bottom of the card. + """ + super().__init__(type=self.type, block_id=block_id) + show_unknown_key_warning(self, others) + + self.hero_image = hero_image + self.icon = icon + self.title = TextObject.parse(title, default_type=MarkdownTextObject.type) # type: ignore[arg-type] + self.subtitle = TextObject.parse(subtitle, default_type=MarkdownTextObject.type) # type: ignore[arg-type] + self.body = TextObject.parse(body, default_type=MarkdownTextObject.type) # type: ignore[arg-type] + self.actions = BlockElement.parse_all(actions) if actions else None + + @JsonValidator("At least one of hero_image, title, actions, or body is required") + def _validate_content(self): + 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 + + @JsonValidator(f"title attribute cannot exceed {title_max_length} characters") + def _validate_title_length(self): + return self.title is None or self.title.text is None or len(self.title.text) <= self.title_max_length + + @JsonValidator(f"subtitle attribute cannot exceed {subtitle_max_length} characters") + def _validate_subtitle_length(self): + return self.subtitle is None or self.subtitle.text is None or len(self.subtitle.text) <= self.subtitle_max_length + + @JsonValidator(f"body attribute cannot exceed {body_max_length} characters") + def _validate_body_length(self): + return self.body is None or self.body.text is None or len(self.body.text) <= self.body_max_length + + +class CarouselBlock(Block): + type = "carousel" + elements_max_length = 10 + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union({"elements"}) + + def __init__( + self, + *, + elements: Sequence[Union[dict, CardBlock]], + block_id: Optional[str] = None, + **others: dict, + ): + """Displays related card blocks in a horizontally-scrolling container. + https://docs.slack.dev/reference/block-kit/blocks/carousel-block + + Args: + elements (required): A list of cards. Minimum 1, maximum 10 cards. + block_id: A unique identifier for a block. If not specified, a block_id will be generated. + """ + super().__init__(type=self.type, block_id=block_id) + show_unknown_key_warning(self, others) + + self.elements = Block.parse_all(elements) + + @JsonValidator("elements attribute must contain at least 1 card") + def _validate_elements_present(self): + return self.elements is not None and len(self.elements) >= 1 + + @JsonValidator(f"elements attribute cannot exceed {elements_max_length} cards") + def _validate_elements_length(self): + return self.elements is None or len(self.elements) <= self.elements_max_length diff --git a/tests/slack_sdk/models/test_blocks.py b/tests/slack_sdk/models/test_blocks.py index 531ebe057..fc9ff3266 100644 --- a/tests/slack_sdk/models/test_blocks.py +++ b/tests/slack_sdk/models/test_blocks.py @@ -4,9 +4,12 @@ from slack_sdk.errors import SlackObjectFormationError from slack_sdk.models.blocks import ( ActionsBlock, + AlertBlock, Block, ButtonElement, CallBlock, + CardBlock, + CarouselBlock, ContextActionsBlock, ContextBlock, DividerBlock, @@ -1551,3 +1554,153 @@ def test_with_raw_text_object_helper(self): ], } self.assertDictEqual(expected, block.to_dict()) + + +class CardBlockTests(unittest.TestCase): + def test_document(self): + input = { + "type": "card", + "icon": "https://picsum.photos/36/36", + "title": {"type": "mrkdwn", "text": "Lumon Industries", "verbatim": False}, + "subtitle": {"type": "mrkdwn", "text": "Committed to work-life balance", "verbatim": False}, + "hero_image": "https://picsum.photos/400/300", + "body": {"type": "mrkdwn", "text": "Please enjoy each card equally.", "verbatim": False}, + "actions": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Action Button", "emoji": False}, + "action_id": "button_action", + } + ], + } + self.assertDictEqual(input, CardBlock(**input).to_dict()) + + def test_parse(self): + input = { + "type": "card", + "title": {"type": "mrkdwn", "text": "Title"}, + "body": {"type": "mrkdwn", "text": "Body text"}, + } + parsed = Block.parse(input) + self.assertIsNotNone(parsed) + self.assertDictEqual(input, parsed.to_dict()) + + def test_minimal_with_title(self): + input = { + "type": "card", + "title": {"type": "mrkdwn", "text": "Just a title"}, + } + self.assertDictEqual(input, CardBlock(**input).to_dict()) + + def test_minimal_with_body(self): + input = { + "type": "card", + "body": {"type": "mrkdwn", "text": "Just body text"}, + } + self.assertDictEqual(input, CardBlock(**input).to_dict()) + + def test_validation_at_least_one_field(self): + with self.assertRaises(SlackObjectFormationError): + CardBlock().validate_json() + + def test_title_length_validation(self): + with self.assertRaises(SlackObjectFormationError): + CardBlock(title={"type": "mrkdwn", "text": "a" * 151}).validate_json() + + def test_subtitle_length_validation(self): + with self.assertRaises(SlackObjectFormationError): + CardBlock( + title={"type": "mrkdwn", "text": "Title"}, + subtitle={"type": "mrkdwn", "text": "a" * 151}, + ).validate_json() + + def test_body_length_validation(self): + with self.assertRaises(SlackObjectFormationError): + CardBlock(body={"type": "mrkdwn", "text": "a" * 201}).validate_json() + + +class AlertBlockTests(unittest.TestCase): + def test_document(self): + input = { + "type": "alert", + "text": {"type": "mrkdwn", "text": "The work is mysterious and important.", "verbatim": False}, + "level": "info", + } + self.assertDictEqual(input, AlertBlock(**input).to_dict()) + + def test_parse(self): + input = { + "type": "alert", + "text": {"type": "mrkdwn", "text": "Notice"}, + "level": "warning", + } + parsed = Block.parse(input) + self.assertIsNotNone(parsed) + self.assertDictEqual(input, parsed.to_dict()) + + def test_minimal(self): + input = { + "type": "alert", + "text": {"type": "plain_text", "text": "Simple alert"}, + } + self.assertDictEqual(input, AlertBlock(**input).to_dict()) + + def test_all_levels(self): + for level in ["default", "info", "warning", "error", "success"]: + input = { + "type": "alert", + "text": {"type": "plain_text", "text": "Test"}, + "level": level, + } + AlertBlock(**input).validate_json() + + def test_invalid_level(self): + with self.assertRaises(SlackObjectFormationError): + AlertBlock(text={"type": "plain_text", "text": "Test"}, level="critical").validate_json() + + def test_missing_text(self): + with self.assertRaises(SlackObjectFormationError): + AlertBlock(text="").validate_json() + + +class CarouselBlockTests(unittest.TestCase): + def test_document(self): + input = { + "type": "carousel", + "elements": [ + { + "type": "card", + "title": {"type": "mrkdwn", "text": "Card 1"}, + }, + { + "type": "card", + "title": {"type": "mrkdwn", "text": "Card 2"}, + "body": {"type": "mrkdwn", "text": "Some body text"}, + }, + ], + } + self.assertDictEqual(input, CarouselBlock(**input).to_dict()) + + def test_parse(self): + input = { + "type": "carousel", + "elements": [ + {"type": "card", "title": {"type": "mrkdwn", "text": "Card 1"}}, + ], + } + parsed = Block.parse(input) + self.assertIsNotNone(parsed) + self.assertDictEqual(input, parsed.to_dict()) + + def test_single_card(self): + input = { + "type": "carousel", + "elements": [ + {"type": "card", "title": {"type": "mrkdwn", "text": "Only card"}}, + ], + } + self.assertDictEqual(input, CarouselBlock(**input).to_dict()) + + def test_empty_elements_validation(self): + with self.assertRaises(SlackObjectFormationError): + CarouselBlock(elements=[]).validate_json()