diff --git a/src/sentry/integrations/slack/__init__.py b/src/sentry/integrations/slack/__init__.py index 3b598f2faeb93e..dcb38d1d7c1f9a 100644 --- a/src/sentry/integrations/slack/__init__.py +++ b/src/sentry/integrations/slack/__init__.py @@ -10,7 +10,6 @@ from .message_builder.base.block import * # noqa: F401,F403 from .message_builder.disconnected import * # noqa: F401,F403 from .message_builder.discover import * # noqa: F401,F403 -from .message_builder.event import * # noqa: F401,F403 from .message_builder.help import * # noqa: F401,F403 from .message_builder.incidents import * # noqa: F401,F403 from .message_builder.issues import * # noqa: F401,F403 diff --git a/src/sentry/integrations/slack/actions/message_action.py b/src/sentry/integrations/slack/actions/message_action.py new file mode 100644 index 00000000000000..26a786b8fa68d5 --- /dev/null +++ b/src/sentry/integrations/slack/actions/message_action.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from sentry.notifications.utils.actions import BaseMessageAction + + +@dataclass +class SlackMessageAction(BaseMessageAction): + """ + Class that holds information about a Slack message action. + Has helper functions that can provide Slack specific outputs for the particular message action + """ + + label: str + + @staticmethod + def to_slack_message_action(original: BaseMessageAction) -> SlackMessageAction: + """ + Converts the original message into a specific SlackMessageAction object with the proper defaults + """ + return SlackMessageAction( + name=original.name, + type=original.type, + label=original.label if original.label else "", + url=original.url, + value=original.value, + action_id=original.action_id, + block_id=original.block_id, + option_groups=original.option_groups, + selected_options=original.selected_options, + ) + + def _get_button_text_value(self) -> str: + """ + Returns the proper text value for the button that should be displayed to the user. + Favors the label field if it is set, otherwise falls back to the name of the message action + """ + return self.label or self.name + + def _get_button_text(self) -> dict[str, str]: + """ + Returns the proper structure for the button text that Slack expects to display + """ + return {"type": "plain_text", "text": self._get_button_text_value()} + + def get_button(self) -> Mapping[str, Any]: + """ + Create a block kit supported button for this action to be used in a Slack message + """ + button = { + "type": "button", + "text": self._get_button_text(), + } + if self.value: + button["action_id"] = self.value + button["value"] = self.value + + if self.action_id: + button["action_id"] = self.action_id + + if self.url: + button["url"] = self.url + button["value"] = "link_clicked" + + return button diff --git a/src/sentry/integrations/slack/message_builder/base/base.py b/src/sentry/integrations/slack/message_builder/base/base.py index 4b696b7ba3472e..b5971b3ed82333 100644 --- a/src/sentry/integrations/slack/message_builder/base/base.py +++ b/src/sentry/integrations/slack/message_builder/base/base.py @@ -1,31 +1,10 @@ from __future__ import annotations from abc import ABC -from collections.abc import Mapping, MutableMapping -from typing import Any from sentry.eventstore.models import Event, GroupEvent from sentry.integrations.slack.message_builder import SlackBody from sentry.models.group import Group -from sentry.notifications.utils.actions import MessageAction - - -def get_slack_button(action: MessageAction) -> Mapping[str, Any]: - kwargs: MutableMapping[str, Any] = { - "text": action.label or action.name, - "name": action.name, - "type": action.type, - } - for field in ("style", "url", "value", "action_id"): - value = getattr(action, field, None) - if value: - kwargs[field] = value - - if action.type == "select": - kwargs["selected_options"] = action.selected_options or [] - kwargs["option_groups"] = action.option_groups or [] - - return kwargs class SlackMessageBuilder(ABC): @@ -33,17 +12,11 @@ def build(self) -> SlackBody: """Abstract `build` method that all inheritors must implement.""" raise NotImplementedError - def build_fallback_text(self, obj: Group | Event | GroupEvent, project_slug: str) -> str: + @classmethod + def build_fallback_text(cls, obj: Group | Event | GroupEvent, project_slug: str) -> str: """Fallback text is used in the message preview popup.""" title = obj.title if isinstance(obj, GroupEvent) and obj.occurrence is not None: title = obj.occurrence.issue_title return f"[{project_slug}] {title}" - - @property - def escape_text(self) -> bool: - """ - Returns True if we need to escape the text in the message. - """ - return False diff --git a/src/sentry/integrations/slack/message_builder/event.py b/src/sentry/integrations/slack/message_builder/event.py deleted file mode 100644 index bf12da7c39fa64..00000000000000 --- a/src/sentry/integrations/slack/message_builder/event.py +++ /dev/null @@ -1,19 +0,0 @@ -from collections.abc import Sequence - -from sentry.integrations.slack.message_builder import SlackBlock -from sentry.integrations.slack.message_builder.help import SlackHelpMessageBuilder - -from ..utils import logger -from .help import UNKNOWN_COMMAND_MESSAGE - - -class SlackEventMessageBuilder(SlackHelpMessageBuilder): - def get_header_blocks(self) -> Sequence[SlackBlock]: - blocks = [] - if self.command and self.command != "help": - logger.info("slack.event.unknown-command", extra={"command": self.command}) - blocks.append( - self.get_markdown_block(UNKNOWN_COMMAND_MESSAGE.format(command=self.command)) - ) - - return blocks diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index 1b9f9854538204..2ea3a85a08d789 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -20,6 +20,7 @@ format_actor_options, get_title_link, ) +from sentry.integrations.slack.actions.message_action import SlackMessageAction from sentry.integrations.slack.message_builder import ( ACTION_EMOJI, ACTIONED_CATEGORY_TO_EMOJI, @@ -372,13 +373,52 @@ def get_action_text(text: str, actions: Sequence[Any], identity: RpcIdentity) -> return action_text +def _ignore_action(group: Group) -> SlackMessageAction | None: + if group.issue_category == GroupCategory.FEEDBACK: + return None + + if group.get_status() == GroupStatus.IGNORED: + return SlackMessageAction( + name="status", label="Mark as Ongoing", value="unresolved:ongoing" + ) + + return SlackMessageAction(name="status", label="Archive", value="archive_dialog") + + +def _resolve_action(group: Group, project: Project) -> SlackMessageAction: + if group.get_status() == GroupStatus.RESOLVED: + return SlackMessageAction( + name="unresolved:ongoing", label="Unresolve", value="unresolved:ongoing" + ) + if not project.flags.has_releases: + return SlackMessageAction(name="status", label="Resolve", value="resolved") + + return SlackMessageAction( + name="status", + label="Resolve", + value="resolve_dialog", + ) + + +def _assign_action(group: Group) -> SlackMessageAction: + assignee = group.get_assignee() + assign_button = SlackMessageAction( + name="assign", + label="Select Assignee...", + type="select", + selected_options=format_actor_options([assignee], True) if assignee else [], + option_groups=get_option_groups_block_kit(group), + ) + return assign_button + + def build_actions( group: Group, project: Project, text: str, actions: Sequence[MessageAction] | None = None, identity: RpcIdentity | None = None, -) -> tuple[Sequence[MessageAction], str, bool]: +) -> tuple[Sequence[SlackMessageAction], str, bool]: """Having actions means a button will be shown on the Slack message e.g. ignore, resolve, assign.""" if actions and identity: text = get_action_text(text, actions, identity) @@ -387,50 +427,9 @@ def build_actions( return [], text, True return [], text, False - status = group.get_status() - - def _ignore_button() -> MessageAction | None: - if group.issue_category == GroupCategory.FEEDBACK: - return None - if status == GroupStatus.IGNORED: - return MessageAction(name="status", label="Mark as Ongoing", value="unresolved:ongoing") - - return MessageAction(name="status", label="Archive", value="archive_dialog") - - def _resolve_button() -> MessageAction: - if status == GroupStatus.RESOLVED: - return MessageAction( - name="unresolved:ongoing", label="Unresolve", value="unresolved:ongoing" - ) - if not project.flags.has_releases: - return MessageAction(name="status", label="Resolve", value="resolved") - - return MessageAction( - name="status", - label="Resolve", - value="resolve_dialog", - ) - - def _assign_button() -> MessageAction: - assignee = group.get_assignee() - assign_button = MessageAction( - name="assign", - label="Select Assignee...", - type="select", - selected_options=format_actor_options([assignee], True) if assignee else [], - option_groups=get_option_groups_block_kit(group), - ) - return assign_button - - action_list = [ - a - for a in [ - _resolve_button(), - _ignore_button(), - _assign_button(), - ] - if a is not None - ] + action_list = [_resolve_action(group=group, project=project), _assign_action(group=group)] + if (ignore_action := _ignore_action(group=group)) is not None: + action_list.append(ignore_action) return action_list, text, False @@ -596,7 +595,7 @@ def build(self, notification_uuid: str | None = None) -> SlackBlock: "Unresolve", "Resolve...", ): - actions.append(self.get_button_action(action)) + actions.append(action.get_button()) elif action.name == "assign": actions.append( self.get_external_select_action( diff --git a/src/sentry/integrations/slack/message_builder/notifications/base.py b/src/sentry/integrations/slack/message_builder/notifications/base.py index 1d81c2f7d78009..527f4c2b7be85f 100644 --- a/src/sentry/integrations/slack/message_builder/notifications/base.py +++ b/src/sentry/integrations/slack/message_builder/notifications/base.py @@ -1,8 +1,10 @@ from __future__ import annotations +import logging from collections.abc import Mapping from typing import Any +from sentry.integrations.slack.actions.message_action import SlackMessageAction from sentry.integrations.slack.message_builder import SlackBlock from sentry.integrations.slack.message_builder.base.block import BlockSlackMessageBuilder from sentry.integrations.slack.utils.escape import escape_slack_text @@ -11,6 +13,8 @@ from sentry.types.integrations import ExternalProviders from sentry.utils import json +_default_logger = logging.getLogger(__name__) + class SlackNotificationsMessageBuilder(BlockSlackMessageBuilder): def __init__( @@ -24,22 +28,36 @@ def __init__( self.context = context self.recipient = recipient - def build(self) -> SlackBlock: - callback_id_raw = self.notification.get_callback_data() - title = self.notification.build_attachment_title(self.recipient) - title_link = self.notification.get_title_link(self.recipient, ExternalProviders.SLACK) - text = self.notification.get_message_description(self.recipient, ExternalProviders.SLACK) - footer = self.notification.build_notification_footer( - self.recipient, ExternalProviders.SLACK - ) - actions = self.notification.get_message_actions(self.recipient, ExternalProviders.SLACK) - callback_id = ( - json.dumps_experimental("integrations.slack.enable-orjson", callback_id_raw) - if callback_id_raw - else None - ) + def _get_callback_id(self) -> str | None: + """ + Helper method to get the callback id used for the message sent to Slack + """ + callback_id_raw = None + try: + callback_id_raw = self.notification.get_callback_data() + callback_id = ( + json.dumps_experimental("integrations.slack.enable-orjson", callback_id_raw) + if callback_id_raw + else None + ) + except Exception as err: + _default_logger.info( + "There was an error trying to get the callback id", + exc_info=err, + extra={"raw_callback_id": callback_id_raw, "err_message": str(err)}, + ) + raise + return callback_id + + def _get_first_block_text(self) -> str: + """ + Helper method to get the first block text used for the message sent to Slack + """ first_block_text = "" + + title_link = self.notification.get_title_link(self.recipient, ExternalProviders.SLACK) + title = self.notification.build_attachment_title(self.recipient) if title_link: if title: first_block_text += f"<{title_link}|*{escape_slack_text(title)}*> \n" @@ -48,22 +66,49 @@ def build(self) -> SlackBlock: elif title: # ie. "ZeroDivisionError", first_block_text += f"*{escape_slack_text(title)}* \n" + text = self.notification.get_message_description(self.recipient, ExternalProviders.SLACK) if text: # ie. "division by zero", comments first_block_text += text + return first_block_text + + def _get_actions_block(self) -> SlackBlock | None: + """ + Helper method to get the actions block associated with the current Slack action message. + If no actions are found, returns None. + """ + actions_block = [] + actions = self.notification.get_message_actions(self.recipient, ExternalProviders.SLACK) + for action in actions: + slack_action_message = SlackMessageAction.to_slack_message_action(action) + actions_block.append(slack_action_message.get_button()) + + if not actions_block: + return None + + return {"type": "actions", "elements": actions_block} + + def build(self) -> SlackBlock: + """ + Method that builds the Slack block message. Leverages other helper methods to create smaller block parts + required for the message. + """ + # Holds the individual block elements that we want attached to our final Slack message blocks = [] - if first_block_text: + + if first_block_text := self._get_first_block_text(): blocks.append(self.get_markdown_block(text=first_block_text)) + + footer = self.notification.build_notification_footer( + self.recipient, ExternalProviders.SLACK + ) if footer: blocks.append(self.get_context_block(text=footer)) - actions_block = [] - for action in actions: - actions_block.append(self.get_button_action(action)) - - if actions_block: - blocks.append({"type": "actions", "elements": [action for action in actions_block]}) + if actions_block := self._get_actions_block(): + blocks.append(actions_block) + text = self.notification.get_message_description(self.recipient, ExternalProviders.SLACK) return self._build_blocks( - *blocks, fallback_text=text if text else None, callback_id=callback_id + *blocks, fallback_text=text if text else None, callback_id=self._get_callback_id() ) diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index 081949139a12d3..11f7520b18f263 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -19,6 +19,7 @@ from sentry.api.helpers.group_index import update_groups from sentry.auth.access import from_member from sentry.exceptions import UnableToAcceptMemberInvitationException +from sentry.integrations.slack.actions.message_action import SlackMessageAction from sentry.integrations.slack.client import SlackClient from sentry.integrations.slack.message_builder import SlackBody from sentry.integrations.slack.message_builder.issues import SlackIssuesMessageBuilder @@ -31,7 +32,7 @@ from sentry.models.group import Group from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.models.rule import Rule -from sentry.notifications.utils.actions import BlockKitMessageAction, MessageAction +from sentry.notifications.utils.actions import MessageAction from sentry.services.hybrid_cloud.integration import integration_service from sentry.services.hybrid_cloud.notifications import notifications_service from sentry.services.hybrid_cloud.organization import organization_service @@ -737,47 +738,54 @@ def get_action_option(cls, slack_request: SlackActionRequest) -> str | None: return action_option @classmethod - def get_action_list( - cls, slack_request: SlackActionRequest, use_block_kit: bool + def _get_default_action_list( + cls, slack_request_actions: list[dict[str, Any]] ) -> list[MessageAction]: - action_data = slack_request.data.get("actions") - if use_block_kit and action_data: - # XXX(CEO): this is here for backwards compatibility - if a user performs an action with an "older" - # style issue alert but the block kit flag is enabled, we don't want to fall into this code path - if action_data[0].get("action_id"): - action_list = [] - for action_data in action_data: - if action_data.get("type") in ("static_select", "external_select"): - action = BlockKitMessageAction( - name=action_data["action_id"], - label=action_data["selected_option"]["text"]["text"], - type=action_data["type"], - value=action_data["selected_option"]["value"], - action_id=action_data["action_id"], - block_id=action_data["block_id"], - selected_options=[ - {"value": action_data.get("selected_option", {}).get("value")} - ], - ) - # TODO: selected_options is kinda ridiculous, I think this is built to handle multi-select? - else: - action = BlockKitMessageAction( - name=action_data["action_id"], - label=action_data["text"]["text"], - type=action_data["type"], - value=action_data["value"], - action_id=action_data["action_id"], - block_id=action_data["block_id"], - ) - action_list.append(action) - - return action_list return [ MessageAction(**action_data) - for action_data in action_data or [] + for action_data in slack_request_actions if "name" in action_data ] + @classmethod + def get_action_list( + cls, slack_request: SlackActionRequest, use_block_kit: bool + ) -> list[MessageAction] | list[SlackMessageAction]: + slack_request_actions = slack_request.data.get("actions", []) + if not use_block_kit or not slack_request_actions: + return cls._get_default_action_list(slack_request_actions=slack_request_actions) + + # XXX(CEO): this is here for backwards compatibility - if a user performs an action with an "older" + # style issue alert but the block kit flag is enabled, we don't want to fall into this code path + if not slack_request_actions[0].get("action_id"): + return cls._get_default_action_list(slack_request_actions=slack_request_actions) + + action_list = [] + for slack_request_actions in slack_request_actions: + if slack_request_actions.get("type") in ("static_select", "external_select"): + label = slack_request_actions["selected_option"]["text"]["text"] + value = slack_request_actions["selected_option"]["value"] + selected_options = [ + {"value": slack_request_actions.get("selected_option", {}).get("value")} + ] + # TODO(CEO): selected_options is kinda ridiculous, I think this is built to handle multi-select? + else: + label = slack_request_actions["text"]["text"] + value = slack_request_actions["value"] + selected_options = None + + action = SlackMessageAction( + name=slack_request_actions["action_id"], + label=label, + type=slack_request_actions["type"], + value=value, + action_id=slack_request_actions["action_id"], + block_id=slack_request_actions["block_id"], + selected_options=selected_options, + ) + action_list.append(action) + return action_list + def post(self, request: Request) -> Response: try: slack_request = self.slack_request_class(request) diff --git a/src/sentry/notifications/utils/actions.py b/src/sentry/notifications/utils/actions.py index effcd8da7856f5..96aff6146cb1d7 100644 --- a/src/sentry/notifications/utils/actions.py +++ b/src/sentry/notifications/utils/actions.py @@ -5,40 +5,28 @@ from typing import Any, Literal -@dataclass -class MessageAction: - name: str - - # Optional label. This falls back to name. - label: str | None = None +@dataclass(kw_only=True) +class BaseMessageAction: + """ + Base class used to hold the fields for a notification message action + """ + name: str type: Literal["button", "select"] = "button" - - # If this is a button type, a url is required. + # Label is optional, if empty it falls back to name + label: str | None = None + # If the message action is a button type, the url is required url: str | None = None - - # If this is a select type, the selected value. + # If the message action is a select type, this is the selected value value: str | None = None - # Denotes the type of action action_id: str | None = None - - style: Literal["primary", "danger", "default"] | None = None - - # TODO(mgaeta): Refactor this to be provider-agnostic - selected_options: Sequence[Mapping[str, Any]] | None = None - option_groups: Sequence[Mapping[str, Any]] | None = None block_id: str | None = None - elements: Sequence[Mapping[str, Any]] | None = None + option_groups: Sequence[Mapping[str, Any]] | None = None + selected_options: Sequence[Mapping[str, Any]] | None = None @dataclass -class BlockKitMessageAction: - name: str - label: str - type: Literal["button", "select"] = "button" - url: str | None = None - value: str | None = None - action_id: str | None = None - block_id: str | None = None - selected_options: Sequence[Mapping[str, Any]] | None = None +class MessageAction(BaseMessageAction): + style: Literal["primary", "danger", "default"] | None = None + elements: Sequence[Mapping[str, Any]] | None = None diff --git a/tests/sentry/integrations/slack/actions/message_action/__init__.py b/tests/sentry/integrations/slack/actions/message_action/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/integrations/slack/actions/message_action/test_slack_message_action.py b/tests/sentry/integrations/slack/actions/message_action/test_slack_message_action.py new file mode 100644 index 00000000000000..1337c0456c3d1b --- /dev/null +++ b/tests/sentry/integrations/slack/actions/message_action/test_slack_message_action.py @@ -0,0 +1,176 @@ +from sentry.integrations.slack.actions.message_action import SlackMessageAction +from sentry.notifications.utils.actions import BaseMessageAction +from sentry.testutils.cases import TestCase + + +class TestToSlackMessageAction(TestCase): + def setUp(self) -> None: + self.base_action = BaseMessageAction( + name="test_action", + type="button", + label="Test Button", + url="https://example.com", + value="test_value", + action_id="test_action_id", + block_id="test_block_id", + option_groups=[{"test_group": "test_option_group"}], + selected_options=[{"test_option": "test_option_value"}], + ) + + def test_to_slack_message_action(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert isinstance(slack_action, SlackMessageAction) + + def test_name_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.name == self.base_action.name + + def test_type_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.type == self.base_action.type + + def test_label_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.label == self.base_action.label + + def test_url_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.url == self.base_action.url + + def test_value_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.value == self.base_action.value + + def test_action_id_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.action_id == self.base_action.action_id + + def test_block_id_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.block_id == self.base_action.block_id + + def test_option_groups_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.option_groups == self.base_action.option_groups + + def test_selected_options_conversion(self) -> None: + slack_action = SlackMessageAction.to_slack_message_action(self.base_action) + assert slack_action.selected_options == self.base_action.selected_options + + +class TestGetButtonTextValue(TestCase): + def test_uses_label_when_populated(self) -> None: + label = "Test Button" + slack_action = SlackMessageAction( + name="test_action", + type="button", + label=label, + url="https://example.com", + value="test_value", + action_id="test_action_id", + block_id="test_block_id", + option_groups=[{"test_group": "test_option_group"}], + selected_options=[{"test_option": "test_option_value"}], + ) + button_text = slack_action._get_button_text_value() + assert button_text == label + + def test_uses_name_when_label_is_empty(self) -> None: + name = "test_action" + slack_action = SlackMessageAction( + name=name, + type="button", + label="", + url="https://example.com", + value="test_value", + action_id="test_action_id", + block_id="test_block_id", + option_groups=[{"test_group": "test_option_group"}], + selected_options=[{"test_option": "test_option_value"}], + ) + button_text = slack_action._get_button_text_value() + assert button_text == name + + +class _BaseTestSlackMessageAction(TestCase): + def setUp(self) -> None: + self.default_slack_action = SlackMessageAction( + name="test_action", + type="button", + label="Test Button", + url="https://example.com", + value="test_value", + action_id="test_action_id", + block_id="test_block_id", + option_groups=[{"test_group": "test_option_group"}], + selected_options=[{"test_option": "test_option_value"}], + ) + + +class TestGetButtonTest(_BaseTestSlackMessageAction): + def test_has_type_key(self) -> None: + text_obj = self.default_slack_action._get_button_text() + assert "type" in text_obj + + def test_has_correct_type_value(self) -> None: + text_obj = self.default_slack_action._get_button_text() + assert text_obj["type"] == "plain_text" + + def test_has_text_key(self) -> None: + text_obj = self.default_slack_action._get_button_text() + assert "text" in text_obj + + def test_has_correct_text_value(self) -> None: + text_obj = self.default_slack_action._get_button_text() + assert text_obj["text"] == self.default_slack_action._get_button_text_value() + + +class TestGetButton(_BaseTestSlackMessageAction): + def test_type_key_exists(self) -> None: + button = self.default_slack_action.get_button() + assert "type" in button + + def test_type_is_button(self) -> None: + button = self.default_slack_action.get_button() + button_type = button["type"] + assert button_type == "button" + + def test_text_key_exists(self) -> None: + button = self.default_slack_action.get_button() + assert "text" in button + + def test_button_action_id_overwrites_value(self) -> None: + slack_action = SlackMessageAction( + name="test_action", + type="button", + label="Test Button", + url=None, + value="test_value", + action_id="test_action_id", + block_id="test_block_id", + option_groups=[{"test_group": "test_option_group"}], + selected_options=[{"test_option": "test_option_value"}], + ) + button = slack_action.get_button() + assert button["action_id"] == "test_action_id" + + def test_button_url_overwrites_value(self) -> None: + slack_action = SlackMessageAction( + name="test_action", + type="button", + label="Test Button", + url="https://example.com", + value="test_value", + action_id=None, + block_id="test_block_id", + option_groups=[{"test_group": "test_option_group"}], + selected_options=[{"test_option": "test_option_value"}], + ) + button = slack_action.get_button() + assert button["value"] == "link_clicked" + + def test_all_overwrites_at_once(self) -> None: + button = self.default_slack_action.get_button() + assert button["url"] == "https://example.com" + assert button["value"] == "link_clicked" + assert button["action_id"] == "test_action_id"