Repository: getsentry/sentry
Pull request: feat(discord): Implement issue alerts
Sorry for big diff 🥲, did some refactoring and wrote a bunch of tests

Problem statement:
Implement Discord issue alerts
This is where we register our Discord issue alert notification handler. See how Slack and msteams handle this.

This is working if we can register Discord and send an issue alert message to Discord.

Keep in mind we need to gracefully handle the case where the integration is broken.

As we learn more, we may want to decompose this into smaller tickets.

Hints from issue discussion:
This issue has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you label it `Status: Backlog` or `Status: In Progress`, I will leave it alone ... forever!

----

"A weed is but an unloved flower." ― _Ella Wheeler Wilcox_ 🥀

Commit message: Use warning instead of warn

Patch to review:
diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py
index c406f1416f3d95..0ebd570785e073 100644
--- a/src/sentry/api/helpers/group_index/update.py
+++ b/src/sentry/api/helpers/group_index/update.py
@@ -173,7 +173,7 @@ def get_current_release_version_of_group(
 
 def update_groups(
     request: Request,
-    group_ids: Sequence[Group],
+    group_ids: Sequence[int],
     projects: Sequence[Project],
     organization_id: int,
     search_fn: SearchFunction | None,
diff --git a/src/sentry/integrations/discord/__init__.py b/src/sentry/integrations/discord/__init__.py
index 984e00885a5e11..1a6baa7f850df2 100644
--- a/src/sentry/integrations/discord/__init__.py
+++ b/src/sentry/integrations/discord/__init__.py
@@ -1,5 +1,14 @@
+from sentry.rules import rules
+
+from .actions import *  # noqa: F401,F403
+from .actions import DiscordNotifyServiceAction
+from .client import *  # noqa: F401,F403
 from .commands import *  # noqa: F401,F403
 from .integration import *  # noqa: F401,F403
+from .message_builder.base import *  # noqa: F401,F403
+from .message_builder.issues import *  # noqa: F401,F403
 from .urls import *  # noqa: F401,F403
 from .utils import *  # noqa: F401,F403
 from .views import *  # noqa: F401,F403
+
+rules.add(DiscordNotifyServiceAction)  # type: ignore
diff --git a/src/sentry/integrations/discord/actions/__init__.py b/src/sentry/integrations/discord/actions/__init__.py
new file mode 100644
index 00000000000000..79d2ad18c6d1d0
--- /dev/null
+++ b/src/sentry/integrations/discord/actions/__init__.py
@@ -0,0 +1,2 @@
+from .form import *  # noqa: F401, F403
+from .notification import *  # noqa: F401, F403
diff --git a/src/sentry/integrations/discord/actions/form.py b/src/sentry/integrations/discord/actions/form.py
new file mode 100644
index 00000000000000..5f3d610aca4f56
--- /dev/null
+++ b/src/sentry/integrations/discord/actions/form.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from typing import Any
+
+from django import forms
+from django.core.exceptions import ValidationError
+from django.forms.fields import ChoiceField
+
+from sentry.integrations.discord.utils.channel import validate_channel_id
+from sentry.services.hybrid_cloud.integration import integration_service
+
+
+class DiscordNotifyServiceForm(forms.Form):
+    server = forms.ChoiceField(choices=(), widget=forms.Select())
+    channel_id = forms.CharField(widget=forms.TextInput())
+    tags = forms.CharField(required=False, widget=forms.TextInput())
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        server_list = [(i.id, i.name) for i in kwargs.pop("integrations")]
+
+        super().__init__(*args, **kwargs)
+
+        if server_list:
+            assert isinstance(self.fields["server"], ChoiceField)
+            self.fields["server"].initial = server_list[0][0]
+            self.fields["server"].choices = server_list
+            self.fields["server"].widget.choices = server_list
+
+    def _format_discord_error_message(self, message: str) -> str:
+        return f"Discord: {message}"
+
+    def clean(self) -> dict[str, object] | None:
+        cleaned_data: dict[str, object] = super().clean() or {}
+        channel_id = cleaned_data.get("channel_id")
+        server = cleaned_data.get("server")
+        integration = integration_service.get_integration(integration_id=server)
+
+        if not server or not integration:
+            raise forms.ValidationError(
+                self._format_discord_error_message("Server is a required field."),
+                code="invalid",
+            )
+
+        if channel_id and isinstance(channel_id, str):
+            try:
+                validate_channel_id(
+                    channel_id=channel_id,
+                    guild_id=integration.external_id,
+                    integration_id=integration.id,
+                )
+            except ValidationError as e:
+                raise forms.ValidationError(
+                    self._format_discord_error_message("; ".join(e.messages)),
+                    code="invalid",
+                )
+
+        return cleaned_data
diff --git a/src/sentry/integrations/discord/actions/notification.py b/src/sentry/integrations/discord/actions/notification.py
new file mode 100644
index 00000000000000..e8017ced39796f
--- /dev/null
+++ b/src/sentry/integrations/discord/actions/notification.py
@@ -0,0 +1,79 @@
+from typing import Any, Generator, Sequence
+
+from sentry.eventstore.models import GroupEvent
+from sentry.integrations.discord.actions.form import DiscordNotifyServiceForm
+from sentry.integrations.discord.client import DiscordClient
+from sentry.integrations.discord.message_builder.issues import DiscordIssuesMessageBuilder
+from sentry.rules.actions import IntegrationEventAction
+from sentry.rules.base import CallbackFuture, EventState
+from sentry.shared_integrations.exceptions.base import ApiError
+from sentry.types.rules import RuleFuture
+from sentry.utils import metrics
+
+
+class DiscordNotifyServiceAction(IntegrationEventAction):
+    id = "sentry.integrations.discord.notify_action.DiscordNotifyServiceAction"
+    form_cls = DiscordNotifyServiceForm
+    label = "Send a notification to the {server} Discord server in the channel with ID: {channel_id} and show tags {tags} in the notification."
+    prompt = "Send a Discord notification"
+    provider = "discord"
+    integration_key = "server"
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+        self.form_fields = {
+            "server": {
+                "type": "choice",
+                "choices": [(i.id, i.name) for i in self.get_integrations()],
+            },
+            "channel_id": {"type": "string", "placeholder": "e.g., 1134274732116676679"},
+            "tags": {"type": "string", "placeholder": "e.g., environment,user,my_tag"},
+        }
+
+    def after(self, event: GroupEvent, state: EventState) -> Generator[CallbackFuture, None, None]:
+        channel_id = self.get_option("channel_id")
+        tags = set(self.get_tags_list())
+
+        integration = self.get_integration()
+        if not integration:
+            # Integration removed, but rule still active
+            return
+
+        def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None:
+            rules = [f.rule for f in futures]
+            message = DiscordIssuesMessageBuilder(event.group, event=event, tags=tags, rules=rules)
+
+            client = DiscordClient(integration_id=integration.id)
+            try:
+                client.send_message(channel_id, message)
+            except ApiError as e:
+                self.logger.info(
+                    "rule.fail.discord_post",
+                    extra={
+                        "error": str(e),
+                        "project_id": event.project_id,
+                        "event_id": event.event_id,
+                        "guild_id": integration.external_id,
+                        "channel_id": channel_id,
+                    },
+                )
+
+        key = f"discord:{integration.id}:{channel_id}"
+
+        metrics.incr("notifications.sent", instance="discord.notifications", skip_internal=False)
+        yield self.future(send_notification, key=key)
+
+    def render_label(self) -> str:
+        tags = self.get_tags_list()
+
+        return self.label.format(
+            server=self.get_integration_name(),
+            channel_id=self.get_option("channel_id"),
+            tags="[{}]".format(", ".join(tags)),
+        )
+
+    def get_tags_list(self) -> Sequence[str]:
+        return [s.strip() for s in self.get_option("tags", "").split(",")]
+
+    def get_form_instance(self) -> Any:
+        return self.form_cls(self.data, integrations=self.get_integrations())
diff --git a/src/sentry/integrations/discord/client.py b/src/sentry/integrations/discord/client.py
index 6a8053c2efd9c2..33cd6dc4656e43 100644
--- a/src/sentry/integrations/discord/client.py
+++ b/src/sentry/integrations/discord/client.py
@@ -1,13 +1,17 @@
 from __future__ import annotations
 
+# to avoid a circular import
+import logging
+
 from requests import PreparedRequest
 
 from sentry import options
+from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
 from sentry.services.hybrid_cloud.util import control_silo_function
 from sentry.shared_integrations.client.proxy import IntegrationProxyClient, infer_org_integration
 from sentry.utils.json import JSONData
 
-from .utils import logger
+logger = logging.getLogger("sentry.integrations.discord")
 
 
 class DiscordClient(IntegrationProxyClient):
@@ -21,7 +25,13 @@ class DiscordClient(IntegrationProxyClient):
     USERS_GUILD_URL = "/users/@me/guilds/{guild_id}"
 
     # https://discord.com/developers/docs/interactions/application-commands#get-global-application-commands
-    APPLICATION_COMMANDS = "/applications/{application_id}/commands"
+    APPLICATION_COMMANDS_URL = "/applications/{application_id}/commands"
+
+    # https://discord.com/developers/docs/resources/channel#get-channel
+    CHANNEL_URL = "/channels/{channel_id}"
+
+    # https://discord.com/developers/docs/resources/channel#create-message
+    MESSAGE_URL = "/channels/{channel_id}/messages"
 
     def __init__(
         self,
@@ -58,6 +68,18 @@ def leave_guild(self, guild_id: str) -> None:
 
     def overwrite_application_commands(self, commands: list[object]) -> None:
         self.put(
-            self.APPLICATION_COMMANDS.format(application_id=self.application_id),
+            self.APPLICATION_COMMANDS_URL.format(application_id=self.application_id),
             data=commands,
         )
+
+    def get_channel(self, channel_id: str) -> object | None:
+        """
+        Get a channel by id.
+        """
+        return self.get(self.CHANNEL_URL.format(channel_id=channel_id))
+
+    def send_message(self, channel_id: str, message: DiscordMessageBuilder) -> None:
+        """
+        Send a message to the specified channel.
+        """
+        self.post(self.MESSAGE_URL.format(channel_id=channel_id), data=message.build())
diff --git a/src/sentry/integrations/discord/message_builder/__init__.py b/src/sentry/integrations/discord/message_builder/__init__.py
index 019dfa3ffed4db..20e26d83c3e896 100644
--- a/src/sentry/integrations/discord/message_builder/__init__.py
+++ b/src/sentry/integrations/discord/message_builder/__init__.py
@@ -16,3 +16,5 @@
     "Warning": "warning",
     "Critical": "fatal",
 }
+
+DISCORD_URL_FORMAT = "[{text}]({url})"
diff --git a/src/sentry/integrations/discord/message_builder/base/base.py b/src/sentry/integrations/discord/message_builder/base/base.py
index 9a28a1d9e6780e..02c2b2b28e3636 100644
--- a/src/sentry/integrations/discord/message_builder/base/base.py
+++ b/src/sentry/integrations/discord/message_builder/base/base.py
@@ -17,7 +17,7 @@ class DiscordMessageBuilder(AbstractMessageBuilder):
 
     def __init__(
         self,
-        content: str | None = None,
+        content: str = "",
         embeds: list[DiscordMessageEmbed] | None = None,
         components: list[DiscordMessageComponent] | None = None,
         flags: DiscordMessageFlags | None = None,
@@ -37,7 +37,7 @@ def build(self) -> dict[str, object]:
 
     def _build(
         self,
-        content: str | None = None,
+        content: str = "",
         embeds: list[DiscordMessageEmbed] | None = None,
         components: list[DiscordMessageComponent] | None = None,
         flags: DiscordMessageFlags | None = None,
@@ -46,17 +46,11 @@ def _build(
         Helper method for building arbitrary Discord messages.
         """
         message: dict[str, object] = {}
-
-        if content is not None:
-            message["content"] = content
-
-        if embeds is not None:
-            message["embeds"] = [embed.build() for embed in embeds]
-
-        if components is not None:
-            message["components"] = [component.build() for component in components]
-
+        message["content"] = content
+        message["embeds"] = [] if embeds is None else [embed.build() for embed in embeds]
+        message["components"] = (
+            [] if components is None else [component.build() for component in components]
+        )
         if flags is not None:
             message["flags"] = flags.value
-
         return message
diff --git a/src/sentry/integrations/discord/message_builder/base/component/__init__.py b/src/sentry/integrations/discord/message_builder/base/component/__init__.py
index b8d4601c2a04de..6bcf57ccbabe9d 100644
--- a/src/sentry/integrations/discord/message_builder/base/component/__init__.py
+++ b/src/sentry/integrations/discord/message_builder/base/component/__init__.py
@@ -1,3 +1,21 @@
 from .action_row import DiscordActionRow  # noqa: F401,F403
 from .base import *  # noqa: F401,F403
 from .button import *  # noqa: F401,F403
+
+
+class DiscordComponentCustomIds:
+    """
+    Constant to track these ids across modules
+
+    A custom_id must have ':{group_id}' appended to it, so we can track the
+    group across interactions. This may need to be changed once we extend to
+    other notification types.
+    """
+
+    ARCHIVE = "archive"
+    ASSIGN_DIALOG = "assign_dialog"
+    RESOLVE_DIALOG = "resolve_dialog"
+    ASSIGN = "assign"
+    RESOLVE = "resolve"
+    UNRESOLVE = "unresolve"
+    MARK_ONGOING = "mark_ongoing"
diff --git a/src/sentry/integrations/discord/message_builder/base/component/base.py b/src/sentry/integrations/discord/message_builder/base/component/base.py
index b939cdb7f9e60d..a83272e0902a87 100644
--- a/src/sentry/integrations/discord/message_builder/base/component/base.py
+++ b/src/sentry/integrations/discord/message_builder/base/component/base.py
@@ -8,9 +8,6 @@ class DiscordMessageComponent:
     Child classes should override the constructor with necessary fields for
     the component type.
 
-    This class' build method should work for children also, The one exception
-    to this being the ActionRow component, which needs to build its children.
-
     https://discord.com/developers/docs/interactions/message-components#component-object
     """
 
diff --git a/src/sentry/integrations/discord/message_builder/base/component/button.py b/src/sentry/integrations/discord/message_builder/base/component/button.py
index 9786c1ea2b3044..17794c5d85c64a 100644
--- a/src/sentry/integrations/discord/message_builder/base/component/button.py
+++ b/src/sentry/integrations/discord/message_builder/base/component/button.py
@@ -15,8 +15,8 @@ class DiscordButton(DiscordMessageComponent):
     # Note that buttons must be contained in an ActionRow!
     def __init__(
         self,
-        style: int,
         custom_id: str,
+        style: int = DiscordButtonStyle.SECONDARY,
         label: str | None = None,
         url: str | None = None,
         disabled: bool = False,
diff --git a/src/sentry/integrations/discord/message_builder/base/component/select_menu.py b/src/sentry/integrations/discord/message_builder/base/component/select_menu.py
new file mode 100644
index 00000000000000..afb31d00ae4f0d
--- /dev/null
+++ b/src/sentry/integrations/discord/message_builder/base/component/select_menu.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from sentry.integrations.discord.message_builder.base.component.base import DiscordMessageComponent
+
+
+class DiscordSelectMenuOption:
+    """
+    An option for a DiscordSelectMenu.
+    """
+
+    def __init__(
+        self, label: str, value: str, description: str | None = None, default: bool = False
+    ) -> None:
+        self.label = label
+        self.value = value
+        self.description = description
+        self.default = default
+
+    def build(self) -> dict[str, object]:
+        attributes = vars(self).items()
+        return {k: v for k, v in attributes if v is not None}
+
+
+class DiscordSelectMenu(DiscordMessageComponent):
+    """
+    A Discord select menu message component. We are only implementing the
+    string select variation because the other types are not currently required.
+
+    https://discord.com/developers/docs/interactions/message-components#select-menu-object
+    """
+
+    def __init__(
+        self,
+        custom_id: str,
+        options: Iterable[DiscordSelectMenuOption],
+        placeholder: str | None = None,
+        min_values: int = 1,
+        max_values: int = 1,
+        disabled: bool = False,
+    ) -> None:
+        super().__init__(type=3)
+        self.custom_id = custom_id
+        self.options = options
+        self.placeholder = placeholder
+        self.min_values = min_values
+        self.max_values = max_values
+        self.disabled = disabled
+
+    def build(self) -> dict[str, object]:
+        select_menu = super().build()
+        select_menu["options"] = [o.build() for o in self.options]
+        return select_menu
diff --git a/src/sentry/integrations/discord/message_builder/base/embed/base.py b/src/sentry/integrations/discord/message_builder/base/embed/base.py
index 2ea456248563cb..64c98cac812a72 100644
--- a/src/sentry/integrations/discord/message_builder/base/embed/base.py
+++ b/src/sentry/integrations/discord/message_builder/base/embed/base.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from collections.abc import Iterable
+from datetime import datetime
 
 from sentry.integrations.discord.message_builder.base.embed.field import DiscordMessageEmbedField
 from sentry.integrations.discord.message_builder.base.embed.footer import DiscordMessageEmbedFooter
@@ -23,6 +24,7 @@ def __init__(
         color: int | None = None,
         footer: DiscordMessageEmbedFooter | None = None,
         fields: Iterable[DiscordMessageEmbedField] | None = None,
+        timestamp: datetime | None = None,
     ) -> None:
         self.title = title
         self.description = description
@@ -30,6 +32,7 @@ def __init__(
         self.color = color
         self.footer = footer
         self.fields = fields
+        self.timestamp = timestamp
 
     def build(self) -> dict[str, object]:
         attributes = vars(self).items()
@@ -41,4 +44,7 @@ def build(self) -> dict[str, object]:
         if self.fields is not None:
             embed["fields"] = [field.build() for field in self.fields]
 
+        if self.timestamp is not None:
+            embed["timestamp"] = self.timestamp.isoformat()
+
         return embed
diff --git a/src/sentry/integrations/discord/message_builder/issues.py b/src/sentry/integrations/discord/message_builder/issues.py
new file mode 100644
index 00000000000000..962ce984f2d687
--- /dev/null
+++ b/src/sentry/integrations/discord/message_builder/issues.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+from sentry import tagstore
+from sentry.eventstore.models import GroupEvent
+from sentry.integrations.discord.message_builder import LEVEL_TO_COLOR
+from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
+from sentry.integrations.discord.message_builder.base.component.action_row import DiscordActionRow
+from sentry.integrations.discord.message_builder.base.component.base import DiscordMessageComponent
+from sentry.integrations.discord.message_builder.base.component.button import DiscordButton
+from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
+from sentry.integrations.discord.message_builder.base.embed.field import DiscordMessageEmbedField
+from sentry.integrations.discord.message_builder.base.embed.footer import DiscordMessageEmbedFooter
+from sentry.integrations.message_builder import (
+    build_attachment_text,
+    build_attachment_title,
+    build_footer,
+    get_title_link,
+    has_releases,
+)
+from sentry.models.group import Group, GroupStatus
+from sentry.models.project import Project
+from sentry.models.rule import Rule
+from sentry.notifications.notifications.base import ProjectNotification
+from sentry.types.integrations import ExternalProviders
+
+from ..message_builder.base.component import DiscordComponentCustomIds as CustomIds
+
+
+class DiscordIssuesMessageBuilder(DiscordMessageBuilder):
+    def __init__(
+        self,
+        group: Group,
+        event: GroupEvent | None = None,
+        tags: set[str] | None = None,
+        rules: list[Rule] | None = None,
+        link_to_event: bool = False,
+        issue_details: bool = False,
+        notification: ProjectNotification | None = None,
+    ) -> None:
+        self.group = group
+        self.event = event
+        self.tags = tags
+        self.rules = rules
+        self.link_to_event = link_to_event
+        self.issue_details = issue_details
+        self.notification = notification
+
+    def build(self) -> dict[str, object]:
+        project = Project.objects.get_from_cache(id=self.group.project_id)
+        event_for_tags = self.event or self.group.get_latest_event()
+        timestamp = (
+            max(self.group.last_seen, self.event.datetime) if self.event else self.group.last_seen
+        )
+        obj: Group | GroupEvent = self.event if self.event is not None else self.group
+        rule_id = None
+        if self.rules:
+            rule_id = self.rules[0].id
+
+        embeds = [
+            DiscordMessageEmbed(
+                title=build_attachment_title(obj),
+                description=build_attachment_text(self.group, self.event) or None,
+                url=get_title_link(
+                    self.group,
+                    self.event,
+                    self.link_to_event,
+                    self.issue_details,
+                    self.notification,
+                    ExternalProviders.DISCORD,
+                    rule_id,
+                ),
+                color=LEVEL_TO_COLOR["info"],
+                # We can't embed urls in Discord embed footers.
+                footer=DiscordMessageEmbedFooter(
+                    build_footer(self.group, project, self.rules, "{text}")
+                ),
+                fields=build_tag_fields(event_for_tags, self.tags),
+                timestamp=timestamp,
+            )
+        ]
+
+        components = build_components(self.group, project)
+
+        return self._build(embeds=embeds, components=components)
+
+
+def build_tag_fields(
+    event_for_tags: GroupEvent | None, tags: set[str] | None = None
+) -> list[DiscordMessageEmbedField]:
+    fields: list[DiscordMessageEmbedField] = []
+    if tags:
+        event_tags = event_for_tags.tags if event_for_tags else []
+        for key, value in event_tags:
+            std_key = tagstore.get_standardized_key(key)  # type: ignore
+            if std_key not in tags:
+                continue
+
+            labeled_value = tagstore.get_tag_value_label(key, value)  # type: ignore
+            fields.append(
+                DiscordMessageEmbedField(
+                    std_key,
+                    labeled_value,
+                    inline=True,
+                )
+            )
+    return fields
+
+
+def build_components(
+    group: Group,
+    project: Project,
+) -> list[DiscordMessageComponent]:
+
+    archive_button = DiscordButton(
+        custom_id=f"{CustomIds.ARCHIVE}:{group.id}",
+        label="Archive",
+    )
+
+    resolve_button = DiscordButton(
+        custom_id=f"{CustomIds.RESOLVE_DIALOG}:{group.id}", label="Resolve..."
+    )
+
+    assign_button = DiscordButton(
+        custom_id=f"{CustomIds.ASSIGN_DIALOG}:{group.id}", label="Assign..."
+    )
+
+    status = group.get_status()
+
+    if not has_releases(project):
+        resolve_button = DiscordButton(
+            custom_id=f"{CustomIds.RESOLVE}:{group.id}",
+            label="Resolve",
+        )
+
+    if status == GroupStatus.RESOLVED:
+        resolve_button = DiscordButton(
+            custom_id=f"{CustomIds.UNRESOLVE}:{group.id}",
+            label="Unresolve",
+        )
+
+    if status == GroupStatus.IGNORED:
+        archive_button = DiscordButton(
+            custom_id=f"{CustomIds.MARK_ONGOING}:{group.id}",
+            label="Mark as Ongoing",
+        )
+
+    return [
+        DiscordActionRow(components=[resolve_button, archive_button, assign_button]),
+    ]
diff --git a/src/sentry/integrations/discord/requests/base.py b/src/sentry/integrations/discord/requests/base.py
index 68fd2ca812ef36..32f3f373b0c982 100644
--- a/src/sentry/integrations/discord/requests/base.py
+++ b/src/sentry/integrations/discord/requests/base.py
@@ -29,6 +29,15 @@ class DiscordRequestError(Exception):
 class DiscordRequestTypes:
     PING = 1
     COMMAND = 2
+    MESSAGE_COMPONENT = 3
+    MODAL_SUBMIT = 5
+
+
+class DiscordMessageComponentTypes:
+    ACTION_ROW = 1
+    BUTTON = 2
+    SELECT = 3
+    TEXT_INPUT = 4
 
 
 class DiscordRequest:
@@ -44,7 +53,7 @@ class DiscordRequest:
     def __init__(self, request: Request):
         self.request = request
         self._integration: RpcIntegration | None = None
-        self._data: Mapping[str, object] = {}
+        self._data: Mapping[str, object] = self.request.data
         self._identity: RpcIdentity | None = None
         self.user: RpcUser | None = None
 
@@ -54,24 +63,23 @@ def integration(self) -> RpcIntegration | None:
 
     @property
     def data(self) -> Mapping[str, object]:
-        if not self._data:
-            self._validate_data()
-        return self._data
+        """This is the data object nested within request.data"""
+        return self._data.get("data") or {}  # type: ignore
 
     @property
     def guild_id(self) -> str | None:
-        guild_id = self.data.get("guild_id")
+        guild_id = self._data.get("guild_id")
         return str(guild_id) if guild_id else None
 
     @property
     def channel_id(self) -> str | None:
-        channel_id = self.data.get("channel_id")
+        channel_id = self._data.get("channel_id")
         return str(channel_id) if channel_id else None
 
     @property
     def user_id(self) -> str | None:
         try:
-            return self.data.get("member")["user"]["id"]  # type: ignore
+            return self._data.get("member")["user"]["id"]  # type: ignore
         except (AttributeError, TypeError):
             return None
 
@@ -87,11 +95,16 @@ def logging_data(self) -> Mapping[str, str | int]:
             data["integration_id"] = self.integration.id
         if self.user_id:
             data["discord_user_id"] = self.user_id
+        if self.has_identity():
+            data["identity"] = self.get_identity_str()
+        if self.is_command():
+            data["command"] = self.get_command_name()
+        if self.is_message_component():
+            data["component_custom_id"] = self.get_component_custom_id()
 
         return {k: v for k, v in data.items() if v}
 
     def validate(self) -> None:
-        self._validate_data()
         self._log_request()
         self.authorize()
         self.validate_integration()
@@ -108,12 +121,6 @@ def authorize(self) -> None:
 
         raise DiscordRequestError(status=status.HTTP_401_UNAUTHORIZED)
 
-    def _validate_data(self) -> None:
-        try:
-            self._data = self.request.data
-        except (ValueError, TypeError):
-            raise DiscordRequestError(status=status.HTTP_400_BAD_REQUEST)
-
     def _validate_identity(self) -> None:
         self.user = self.get_identity_user()
 
@@ -163,7 +170,26 @@ def is_ping(self) -> bool:
     def is_command(self) -> bool:
         return self._data.get("type", 0) == DiscordRequestTypes.COMMAND
 
+    def is_message_component(self) -> bool:
+        return self._data.get("type", 0) == DiscordRequestTypes.MESSAGE_COMPONENT
+
+    def is_modal_submit(self) -> bool:
+        return self._data.get("type", 0) == DiscordRequestTypes.MODAL_SUBMIT
+
     def get_command_name(self) -> str:
         if not self.is_command():
             return ""
-        return self._data.get("data")["name"]  # type: ignore
+        return self.data["name"]  # type: ignore
+
+    def get_component_custom_id(self) -> str:
+        if not self.is_message_component():
+            return ""
+        return self.data["custom_id"]  # type: ignore
+
+    def is_select_component(self) -> bool:
+        return self.data["component_type"] == DiscordMessageComponentTypes.SELECT
+
+    def get_selected_options(self) -> list[str]:
+        if not self.is_select_component():
+            return []
+        return self.data["values"]  # type: ignore
diff --git a/src/sentry/integrations/discord/utils/__init__.py b/src/sentry/integrations/discord/utils/__init__.py
index a506edaebbba7b..5ada2cc6f9f37c 100644
--- a/src/sentry/integrations/discord/utils/__init__.py
+++ b/src/sentry/integrations/discord/utils/__init__.py
@@ -3,3 +3,4 @@
 logger = logging.getLogger("sentry.integrations.discord")
 
 from .auth import *  # noqa: F401,F403
+from .channel import *  # noqa: F401,F403
diff --git a/src/sentry/integrations/discord/utils/channel.py b/src/sentry/integrations/discord/utils/channel.py
new file mode 100644
index 00000000000000..79620a2717ac28
--- /dev/null
+++ b/src/sentry/integrations/discord/utils/channel.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from django.core.exceptions import ValidationError
+
+from sentry.integrations.discord.client import DiscordClient
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.shared_integrations.exceptions.base import ApiError
+
+from . import logger
+
+NO_CHANNEL_MESSAGE = (
+    "We couldn't find a channel with that ID. Make sure you have the correct server selected."
+)
+
+
+def validate_channel_id(channel_id: str, guild_id: str, integration_id: int | None) -> None:
+    """
+    Make sure that for this integration, the channel exists, belongs to this
+    integration, and our bot has access to it.
+    """
+    client = DiscordClient(integration_id=integration_id)
+    try:
+        result = client.get_channel(channel_id)
+    except ApiError as e:
+        if e.code == 404:
+            logger.info("rule.discord.channel_info_failed", extra={"error": str(e)})
+            raise ValidationError(NO_CHANNEL_MESSAGE)
+        logger.error("rule.discord.channel_info_failed", extra={"error": str(e)})
+        raise IntegrationError("Could not retrieve Discord channel information.")
+
+    if not isinstance(result, dict):
+        raise IntegrationError("Bad response from Discord channel lookup.")
+
+    if result["guild_id"] != guild_id:
+        # The channel exists and we have access to it, but it does not belong
+        # to the specified guild! We'll use the same message as generic 404,
+        # so we don't expose other guilds' channel IDs.
+        logger.info(
+            "rule.discord.wrong_guild_for_channel",
+            extra={"guild_id": guild_id, "channel_belongs_to": result["guild_id"]},
+        )
+        raise ValidationError(NO_CHANNEL_MESSAGE)
diff --git a/src/sentry/integrations/discord/webhooks/__init__.py b/src/sentry/integrations/discord/webhooks/__init__.py
index dcc1898e6c7b49..66f93df248690c 100644
--- a/src/sentry/integrations/discord/webhooks/__init__.py
+++ b/src/sentry/integrations/discord/webhooks/__init__.py
@@ -1,3 +1,5 @@
-from .base import DiscordInteractionsEndpoint
-
-__all__ = ("DiscordInteractionsEndpoint",)
+from .base import *  # noqa: F401,F403
+from .command import *  # noqa: F401,F403
+from .handler import *  # noqa: F401,F403
+from .message_component import *  # noqa: F401,F403
+from .types import *  # noqa: F401, F403
diff --git a/src/sentry/integrations/discord/webhooks/base.py b/src/sentry/integrations/discord/webhooks/base.py
index b6024932103e51..1422f0c7ae4207 100644
--- a/src/sentry/integrations/discord/webhooks/base.py
+++ b/src/sentry/integrations/discord/webhooks/base.py
@@ -1,33 +1,16 @@
 from __future__ import annotations
 
 from django.views.decorators.csrf import csrf_exempt
-from rest_framework import status
 from rest_framework.request import Request
 from rest_framework.response import Response
 
 from sentry.api.base import Endpoint, region_silo_endpoint
-from sentry.integrations.discord.message_builder.base import (
-    DiscordMessageBuilder,
-    DiscordMessageFlags,
-)
 from sentry.integrations.discord.requests.base import DiscordRequest, DiscordRequestError
-from sentry.integrations.discord.views.link_identity import build_linking_url
-from sentry.integrations.discord.views.unlink_identity import build_unlinking_url
+from sentry.integrations.discord.webhooks.command import DiscordCommandHandler
+from sentry.integrations.discord.webhooks.message_component import DiscordMessageComponentHandler
 from sentry.web.decorators import transaction_start
 
-from ..utils import logger
-
-LINK_USER_MESSAGE = "[Click here]({url}) to link your Discord account to your Sentry account."
-ALREADY_LINKED_MESSAGE = "You are already linked to the Sentry account with email: `{email}`."
-UNLINK_USER_MESSAGE = "[Click here]({url}) to unlink your Discord account from your Sentry Account."
-NOT_LINKED_MESSAGE = (
-    "Your Discord account is not linked to a Sentry account. Use `/link` to link your accounts."
-)
-HELP_MESSAGE = """
-`/help`: View this message.
-`/link`: Link your Discord account to your Sentry account to perform actions on Sentry notifications.
-`/unlink`: Unlink your Discord account from your Sentry account.
-"""
+from .types import DiscordResponseTypes
 
 
 @region_silo_endpoint
@@ -45,103 +28,27 @@ class DiscordInteractionsEndpoint(Endpoint):
 
     def __init__(self) -> None:
         super().__init__()
-        self.discord_request: DiscordRequest
 
     @csrf_exempt
     @transaction_start("DiscordInteractionsEndpoint")
     def post(self, request: Request) -> Response:
         try:
-            self.discord_request = self.discord_request_class(request)
-            self.discord_request.validate()
+            discord_request = self.discord_request_class(request)
+            discord_request.validate()
 
-            if self.discord_request.is_ping():
+            if discord_request.is_ping():
                 # https://discord.com/developers/docs/tutorials/upgrading-to-application-commands#adding-an-interactions-endpoint-url
-                return self.respond({"type": 1}, status=200)
+                return self.respond({"type": DiscordResponseTypes.PONG}, status=200)
+
+            elif discord_request.is_command():
+                return DiscordCommandHandler(discord_request).handle()
 
-            elif self.discord_request.is_command():
-                return self.handle_command()
+            elif discord_request.is_message_component():
+                return DiscordMessageComponentHandler(discord_request).handle()
 
         except DiscordRequestError as e:
             return self.respond(status=e.status)
 
         # This isn't an interaction type that we need to worry about, so we'll
-        # just return 200
+        # just return 200.
         return self.respond(status=200)
-
-    def reply(self, message: DiscordMessageBuilder) -> Response:
-        return self.respond(
-            {"type": 4, "data": message.build()},
-            headers={"Content-Type": "application/json"},
-            status=200,
-        )
-
-    def handle_command(self) -> Response:
-        command_name = self.discord_request.get_command_name()
-        logging_data = self.discord_request.logging_data
-
-        if command_name == "link":
-            logger.info("discord.interaction.command.link", extra={**logging_data})
-            return self.link_user()
-        elif command_name == "unlink":
-            logger.info("discord.interaction.command.unlink", extra={**logging_data})
-            return self.unlink_user()
-        elif command_name == "help":
-            logger.info("discord.interaction.command.help", extra={**logging_data})
-            return self.help()
-
-        logger.info(
-            "discord.interaction.command.unknown", extra={"command": command_name, **logging_data}
-        )
-        return self.help()
-
-    def link_user(self) -> Response:
-        if self.discord_request.has_identity():
-            message = DiscordMessageBuilder(
-                content=ALREADY_LINKED_MESSAGE.format(
-                    email=self.discord_request.get_identity_str()
-                ),
-                flags=DiscordMessageFlags().set_ephemeral(),
-            )
-            return self.reply(message)
-
-        if not self.discord_request.integration or not self.discord_request.user_id:
-            raise DiscordRequestError(status=status.HTTP_400_BAD_REQUEST)
-
-        link_url = build_linking_url(
-            integration=self.discord_request.integration,
-            discord_id=self.discord_request.user_id,
-        )
-
-        message = DiscordMessageBuilder(
-            content=LINK_USER_MESSAGE.format(url=link_url),
-            flags=DiscordMessageFlags().set_ephemeral(),
-        )
-        return self.reply(message)
-
-    def unlink_user(self) -> Response:
-        if not self.discord_request.has_identity():
-            message = DiscordMessageBuilder(
-                content=NOT_LINKED_MESSAGE, flags=DiscordMessageFlags().set_ephemeral()
-            )
-            return self.reply(message)
-
-        # if self.discord_request.has_identity() then these must not be None
-        assert self.discord_request.integration is not None
-        assert self.discord_request.user_id is not None
-
-        unlink_url = build_unlinking_url(
-            integration=self.discord_request.integration,
-            discord_id=self.discord_request.user_id,
-        )
-
-        message = DiscordMessageBuilder(
-            content=UNLINK_USER_MESSAGE.format(url=unlink_url),
-            flags=DiscordMessageFlags().set_ephemeral(),
-        )
-        return self.reply(message)
-
-    def help(self) -> Response:
-        message = DiscordMessageBuilder(
-            content=HELP_MESSAGE, flags=DiscordMessageFlags().set_ephemeral()
-        )
-        return self.reply(message)
diff --git a/src/sentry/integrations/discord/webhooks/command.py b/src/sentry/integrations/discord/webhooks/command.py
new file mode 100644
index 00000000000000..b4ff6b2744dc88
--- /dev/null
+++ b/src/sentry/integrations/discord/webhooks/command.py
@@ -0,0 +1,88 @@
+from rest_framework import status
+from rest_framework.response import Response
+
+from sentry.integrations.discord.requests.base import DiscordRequestError
+from sentry.integrations.discord.views.link_identity import build_linking_url
+from sentry.integrations.discord.views.unlink_identity import build_unlinking_url
+from sentry.integrations.discord.webhooks.handler import DiscordInteractionHandler
+
+from ..utils import logger
+
+LINK_USER_MESSAGE = "[Click here]({url}) to link your Discord account to your Sentry account."
+ALREADY_LINKED_MESSAGE = "You are already linked to the Sentry account with email: `{email}`."
+UNLINK_USER_MESSAGE = "[Click here]({url}) to unlink your Discord account from your Sentry Account."
+NOT_LINKED_MESSAGE = (
+    "Your Discord account is not linked to a Sentry account. Use `/link` to link your accounts."
+)
+HELP_MESSAGE = """
+`/help`: View this message.
+`/link`: Link your Discord account to your Sentry account to perform actions on Sentry notifications.
+`/unlink`: Unlink your Discord account from your Sentry account.
+"""
+
+
+class DiscordCommandNames:
+    LINK = "link"
+    UNLINK = "unlink"
+    HELP = "help"
+
+
+class DiscordCommandHandler(DiscordInteractionHandler):
+    """
+    Handles logic for Discord Command interactions.
+
+    Request passed in constructor must be command interaction.
+    """
+
+    def handle(self) -> Response:
+        command_name = self.request.get_command_name()
+        logging_data = self.request.logging_data
+
+        if command_name == DiscordCommandNames.LINK:
+            logger.info("discord.interaction.command.link", extra={**logging_data})
+            return self.link_user()
+        elif command_name == DiscordCommandNames.UNLINK:
+            logger.info("discord.interaction.command.unlink", extra={**logging_data})
+            return self.unlink_user()
+        elif command_name == DiscordCommandNames.HELP:
+            logger.info("discord.interaction.command.help", extra={**logging_data})
+            return self.help()
+
+        logger.info(
+            "discord.interaction.command.unknown", extra={"command": command_name, **logging_data}
+        )
+        return self.help()
+
+    def link_user(self) -> Response:
+        if self.request.has_identity():
+            return self.send_message(
+                ALREADY_LINKED_MESSAGE.format(email=self.request.get_identity_str())
+            )
+
+        if not self.request.integration or not self.request.user_id:
+            raise DiscordRequestError(status=status.HTTP_400_BAD_REQUEST)
+
+        link_url = build_linking_url(
+            integration=self.request.integration,
+            discord_id=self.request.user_id,
+        )
+
+        return self.send_message(LINK_USER_MESSAGE.format(url=link_url))
+
+    def unlink_user(self) -> Response:
+        if not self.request.has_identity():
+            return self.send_message(NOT_LINKED_MESSAGE)
+
+        # if self.request.has_identity() then these must not be None
+        assert self.request.integration is not None
+        assert self.request.user_id is not None
+
+        unlink_url = build_unlinking_url(
+            integration=self.request.integration,
+            discord_id=self.request.user_id,
+        )
+
+        return self.send_message(UNLINK_USER_MESSAGE.format(url=unlink_url))
+
+    def help(self) -> Response:
+        return self.send_message(HELP_MESSAGE)
diff --git a/src/sentry/integrations/discord/webhooks/handler.py b/src/sentry/integrations/discord/webhooks/handler.py
new file mode 100644
index 00000000000000..4387a4b178f23f
--- /dev/null
+++ b/src/sentry/integrations/discord/webhooks/handler.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from rest_framework.response import Response
+
+from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
+from sentry.integrations.discord.message_builder.base.flags import DiscordMessageFlags
+from sentry.integrations.discord.requests.base import DiscordRequest
+
+from .types import DiscordResponseTypes
+
+
+class DiscordInteractionHandler:
+    """
+    Abstract class defining the shared interface of interaction handlers,
+    along with some helper methods.
+    """
+
+    def __init__(self, request: DiscordRequest) -> None:
+        """
+        Request must be *verified*.
+        """
+        self.request: DiscordRequest = request
+
+    def send_message(self, message: str | DiscordMessageBuilder, update: bool = False) -> Response:
+        """Sends a new follow up message."""
+        response_type = DiscordResponseTypes.UPDATE if update else DiscordResponseTypes.MESSAGE
+
+        if isinstance(message, str):
+            message = DiscordMessageBuilder(
+                content=message, flags=DiscordMessageFlags().set_ephemeral()
+            )
+        return Response(
+            {
+                "type": response_type,
+                "data": message.build(),
+            },
+            status=200,
+        )
+
+    def handle(self) -> Response:
+        raise NotImplementedError
diff --git a/src/sentry/integrations/discord/webhooks/message_component.py b/src/sentry/integrations/discord/webhooks/message_component.py
new file mode 100644
index 00000000000000..21ac921d45ee86
--- /dev/null
+++ b/src/sentry/integrations/discord/webhooks/message_component.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+from rest_framework.response import Response
+
+from sentry.api.helpers.group_index.update import update_groups
+from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
+from sentry.integrations.discord.message_builder.base.component import (
+    DiscordComponentCustomIds as CustomIds,
+)
+from sentry.integrations.discord.message_builder.base.component.action_row import DiscordActionRow
+from sentry.integrations.discord.message_builder.base.component.select_menu import (
+    DiscordSelectMenu,
+    DiscordSelectMenuOption,
+)
+from sentry.integrations.discord.message_builder.base.flags import DiscordMessageFlags
+from sentry.integrations.discord.requests.base import DiscordRequest
+from sentry.integrations.discord.webhooks.handler import DiscordInteractionHandler
+from sentry.models.activity import ActivityIntegration
+from sentry.models.group import Group
+from sentry.models.grouphistory import STATUS_TO_STRING_LOOKUP, GroupHistoryStatus
+from sentry.services.hybrid_cloud.user.model import RpcUser
+from sentry.types.group import SUBSTATUS_TO_STR, GroupSubStatus
+
+from ..utils import logger
+
+NO_IDENTITY = "You need to link your Discord account to your Sentry account to do that. You can do this with `/link`!"
+NOT_IN_ORG = "You must be a member of the org this issue belongs to in order to act on it."
+ASSIGNEE_UPDATED = "Assignee has been updated."
+RESOLVE_DIALOG_OPTIONS = [
+    DiscordSelectMenuOption("Immediately", ""),
+    DiscordSelectMenuOption("In the next release", "inNextRelease"),
+    DiscordSelectMenuOption("In the current release", "inCurrentRelease"),
+]
+RESOLVED = "The issue has been resolved."
+RESOLVED_IN_NEXT_RELEASE = "The issue will be resolved in the next release."
+RESOLVED_IN_CURRENT_RELEASE = "The issue will be resolved in the current release."
+UNRESOLVED = "The issue has been unresolved."
+MARKED_ONGOING = "The issue has been marked as ongoing."
+IGNORE_UNTIL_ESCALATES = "The issue will be ignored until it escalates."
+
+
+class DiscordMessageComponentHandler(DiscordInteractionHandler):
+    """
+    Handles logic for Discord Message Component interactions.
+
+    Request passed in constructor must be a Message Component interaction.
+    """
+
+    def __init__(self, request: DiscordRequest) -> None:
+        super().__init__(request)
+        self.custom_id: str = request.get_component_custom_id()
+        self.user: RpcUser
+        # Everything after the colon is the group id in a custom_id
+        self.group_id: str = self.custom_id.split(":")[1]
+        self.group: Group = Group.objects.get(id=self.group_id)
+
+    def handle(self) -> Response:
+        logging_data = self.request.logging_data
+
+        if self.request.user is None:
+            logger.warning("discord.interaction.component.not_linked", extra={**logging_data})
+            return self.send_message(NO_IDENTITY)
+        self.user = self.request.user
+
+        if not self.group.organization.has_access(self.user):
+            logger.warning(
+                "discord.interaction.component.not_in_org",
+                extra={"org_slug": self.group.organization.slug, **logging_data},
+            )
+            return self.send_message(NOT_IN_ORG)
+
+        if self.custom_id.startswith(CustomIds.ASSIGN_DIALOG):
+            logger.info("discord.interaction.component.assign_dialog", extra={**logging_data})
+            return self.assign_dialog()
+
+        elif self.custom_id.startswith(CustomIds.ASSIGN):
+            logger.info(
+                "discord.interaction.component.assign",
+                extra={**logging_data, "assign_to": self.request.get_selected_options()[0]},
+            )
+            return self.assign()
+
+        elif self.custom_id.startswith(CustomIds.RESOLVE_DIALOG):
+            logger.info("discord.interaction.component.resolve_dialog", extra={**logging_data})
+            return self.resolve_dialog()
+
+        elif self.custom_id.startswith(CustomIds.RESOLVE):
+            logger.info("discord.interaction.component.resolve", extra={**logging_data})
+            return self.resolve()
+
+        elif self.custom_id.startswith(CustomIds.UNRESOLVE):
+            logger.info("discord.interaction.component.unresolve", extra={**logging_data})
+            return self.unresolve()
+
+        elif self.custom_id.startswith(CustomIds.MARK_ONGOING):
+            logger.info("discord.interaction.component.mark_ongoing", extra={**logging_data})
+            return self.unresolve(from_mark_ongoing=True)
+
+        elif self.custom_id.startswith(CustomIds.ARCHIVE):
+            logger.info("discord.interaction.component.archive", extra={**logging_data})
+            return self.archive()
+
+        logger.warning("discord.interaction.component.unknown_custom_id", extra={**logging_data})
+        return Response(status=404)
+
+    def assign_dialog(self) -> Response:
+        assign_selector = DiscordSelectMenu(
+            custom_id=f"{CustomIds.ASSIGN}:{self.group_id}",
+            placeholder="Select Assignee...",
+            options=get_assign_selector_options(self.group),
+        )
+        message = DiscordMessageBuilder(
+            components=[DiscordActionRow([assign_selector])],
+            flags=DiscordMessageFlags().set_ephemeral(),
+        )
+        return self.send_message(message)
+
+    def assign(self) -> Response:
+        assignee = self.request.get_selected_options()[0]
+
+        self.update_group(
+            {
+                "assignedTo": assignee,
+                "integration": ActivityIntegration.DISCORD.value,
+            }
+        )
+
+        message = DiscordMessageBuilder(
+            content=ASSIGNEE_UPDATED,
+            flags=DiscordMessageFlags().set_ephemeral(),
+        )
+        return self.send_message(message, update=True)
+
+    def resolve_dialog(self) -> Response:
+        resolve_selector = DiscordSelectMenu(
+            custom_id=f"{CustomIds.RESOLVE}:{self.group_id}",
+            placeholder="Select the resolution target",
+            options=RESOLVE_DIALOG_OPTIONS,
+        )
+        message = DiscordMessageBuilder(
+            components=[DiscordActionRow([resolve_selector])],
+            flags=DiscordMessageFlags().set_ephemeral(),
+        )
+        return self.send_message(message)
+
+    def resolve(self) -> Response:
+        status: dict[str, object] = {
+            "status": STATUS_TO_STRING_LOOKUP[GroupHistoryStatus.RESOLVED],
+        }
+        message = RESOLVED
+
+        selected_option = ""
+        if self.request.is_select_component():
+            selected_option = self.request.get_selected_options()[0]
+
+        if selected_option == "inNextRelease":
+            status["statusDetails"] = {"inNextRelease": True}
+            message = RESOLVED_IN_NEXT_RELEASE
+        elif selected_option == "inCurrentRelease":
+            status["statusDetails"] = {"inRelease": "latest"}
+            message = RESOLVED_IN_CURRENT_RELEASE
+
+        self.update_group(status)
+        return self.send_message(message, update=self.request.is_select_component())
+
+    def unresolve(self, from_mark_ongoing: bool = False) -> Response:
+        self.update_group(
+            {
+                "status": STATUS_TO_STRING_LOOKUP[GroupHistoryStatus.UNRESOLVED],
+                "substatus": SUBSTATUS_TO_STR[GroupSubStatus.ONGOING],
+            }
+        )
+
+        if from_mark_ongoing:
+            return self.send_message(MARKED_ONGOING)
+        return self.send_message(UNRESOLVED)
+
+    def archive(self) -> Response:
+        self.update_group(
+            {
+                "status": STATUS_TO_STRING_LOOKUP[GroupHistoryStatus.IGNORED],
+                "substatus": SUBSTATUS_TO_STR[GroupSubStatus.UNTIL_ESCALATING],
+            }
+        )
+        return self.send_message(IGNORE_UNTIL_ESCALATES)
+
+    def update_group(self, data: Mapping[str, object]) -> None:
+        update_groups(
+            request=self.request.request,
+            group_ids=[self.group.id],
+            projects=[self.group.project],
+            organization_id=self.group.organization.id,
+            search_fn=None,
+            user=self.user,  # type: ignore
+            data=data,
+        )
+
+
+def get_assign_selector_options(group: Group) -> list[DiscordSelectMenuOption]:
+    """
+    Helper function for building the new assignee dropdown.
+    """
+    all_members = group.project.get_members_as_rpc_users()
+    members = list({m.id: m for m in all_members}.values())
+    teams = group.project.teams.all()
+
+    assignee = group.get_assignee()
+
+    options = []
+    # We don't have the luxury of option groups like Slack has, so we will just
+    # list all the teams and then all the members.
+    if teams:
+        team_options = [
+            DiscordSelectMenuOption(
+                label=f"#{team.slug}", value=f"team:{team.id}", default=(team == assignee)
+            )
+            for team in teams
+        ]
+        options.extend(sorted(team_options, key=lambda t: t.label))
+    if members:
+        member_options = [
+            DiscordSelectMenuOption(
+                label=member.get_display_name(),
+                value=f"user:{member.id}",
+                default=(member == assignee),
+            )
+            for member in members
+        ]
+        options.extend(sorted(member_options, key=lambda m: m.label))
+
+    return options
diff --git a/src/sentry/integrations/discord/webhooks/types.py b/src/sentry/integrations/discord/webhooks/types.py
new file mode 100644
index 00000000000000..bd75d87ec97ee4
--- /dev/null
+++ b/src/sentry/integrations/discord/webhooks/types.py
@@ -0,0 +1,13 @@
+class DiscordResponseTypes:
+    """
+    For more description of these types see
+    https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type
+    """
+
+    PONG = 1
+    MESSAGE = 4
+    DEFERRED_MESSAGE = 5
+    DEFERRED_UPDATE = 6
+    UPDATE = 7
+    AUTOCOMPLETE_RESULT = 8
+    MODAL = 9
diff --git a/src/sentry/integrations/message_builder.py b/src/sentry/integrations/message_builder.py
index 7008574eaffb41..93934701839c96 100644
--- a/src/sentry/integrations/message_builder.py
+++ b/src/sentry/integrations/message_builder.py
@@ -3,13 +3,17 @@
 from abc import ABC
 from typing import Any, Callable, Mapping, Sequence
 
+from django.core.cache import cache
+
 from sentry import features
 from sentry.eventstore.models import GroupEvent
 from sentry.integrations.slack.message_builder import SLACK_URL_FORMAT
 from sentry.models import Group, Project, Rule, Team
+from sentry.models.release import ReleaseProject
 from sentry.notifications.notifications.base import BaseNotification
 from sentry.services.hybrid_cloud.user import RpcUser
 from sentry.types.integrations import EXTERNAL_PROVIDERS, ExternalProviders
+from sentry.utils.dates import to_timestamp
 from sentry.utils.http import absolute_uri
 
 
@@ -139,9 +143,29 @@ def build_footer(
     footer = f"{group.qualified_short_id}"
     if rules:
         rule_url = build_rule_url(rules[0], group, project)
-        footer += f" via {url_format.format(text=rules[0].label, url=rule_url)}"
+        # If this notification is triggered via the "Send Test Notification"
+        # button then the label is not defined, but the url works.
+        text = rules[0].label if rules[0].label else "Test Alert"
+        footer += f" via {url_format.format(text=text, url=rule_url)}"
 
         if len(rules) > 1:
             footer += f" (+{len(rules) - 1} other)"
 
     return footer
+
+
+def has_releases(project: Project) -> bool:
+    cache_key = f"has_releases:2:{project.id}"
+    has_releases_option: bool | None = cache.get(cache_key)
+    if has_releases_option is None:
+        has_releases_option = ReleaseProject.objects.filter(project_id=project.id).exists()
+        if has_releases_option:
+            cache.set(cache_key, True, 3600)
+        else:
+            cache.set(cache_key, False, 60)
+    return has_releases_option
+
+
+def get_timestamp(group: Group, event: GroupEvent | None) -> float:
+    ts = group.last_seen
+    return to_timestamp(max(ts, event.datetime) if event else ts)
diff --git a/src/sentry/integrations/slack/actions/notification.py b/src/sentry/integrations/slack/actions/notification.py
index f6416d8f713cd7..002cd4fc1d3828 100644
--- a/src/sentry/integrations/slack/actions/notification.py
+++ b/src/sentry/integrations/slack/actions/notification.py
@@ -33,9 +33,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
                 "type": "choice",
                 "choices": [(i.id, i.name) for i in self.get_integrations()],
             },
-            "channel": {"type": "string", "placeholder": "i.e #critical, Jane Schmidt"},
-            "channel_id": {"type": "string", "placeholder": "i.e. CA2FRA079 or UA1J9RTE1"},
-            "tags": {"type": "string", "placeholder": "i.e environment,user,my_tag"},
+            "channel": {"type": "string", "placeholder": "e.g., #critical, Jane Schmidt"},
+            "channel_id": {"type": "string", "placeholder": "e.g., CA2FRA079 or UA1J9RTE1"},
+            "tags": {"type": "string", "placeholder": "e.g., environment,user,my_tag"},
         }
 
     def after(self, event: GroupEvent, state: EventState) -> Generator[CallbackFuture, None, None]:
diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py
index dc9ffcbc451a7c..d7e2be4dcc3823 100644
--- a/src/sentry/integrations/slack/message_builder/issues.py
+++ b/src/sentry/integrations/slack/message_builder/issues.py
@@ -2,8 +2,6 @@
 
 from typing import Any, Mapping, Sequence
 
-from django.core.cache import cache
-
 from sentry import features, tagstore
 from sentry.eventstore.models import GroupEvent
 from sentry.integrations.message_builder import (
@@ -12,13 +10,15 @@
     build_attachment_title,
     build_footer,
     format_actor_options,
+    get_timestamp,
     get_title_link,
+    has_releases,
 )
 from sentry.integrations.slack.message_builder import LEVEL_TO_COLOR, SLACK_URL_FORMAT, SlackBody
 from sentry.integrations.slack.message_builder.base.base import SlackMessageBuilder
 from sentry.integrations.slack.utils.escape import escape_slack_text
 from sentry.issues.grouptype import GroupCategory
-from sentry.models import ActorTuple, Group, GroupStatus, Project, ReleaseProject, Rule, Team, User
+from sentry.models import ActorTuple, Group, GroupStatus, Project, Rule, Team, User
 from sentry.notifications.notifications.base import BaseNotification, ProjectNotification
 from sentry.notifications.notifications.rules import AlertRuleNotification
 from sentry.notifications.utils.actions import MessageAction
@@ -26,7 +26,6 @@
 from sentry.services.hybrid_cloud.identity import RpcIdentity, identity_service
 from sentry.types.integrations import ExternalProviders
 from sentry.utils import json
-from sentry.utils.dates import to_timestamp
 
 STATUSES = {"resolved": "resolved", "ignored": "ignored", "unresolved": "re-opened"}
 
@@ -116,18 +115,6 @@ def get_option_groups(group: Group) -> Sequence[Mapping[str, Any]]:
     return option_groups
 
 
-def has_releases(project: Project) -> bool:
-    cache_key = f"has_releases:2:{project.id}"
-    has_releases_option: bool | None = cache.get(cache_key)
-    if has_releases_option is None:
-        has_releases_option = ReleaseProject.objects.filter(project_id=project.id).exists()
-        if has_releases_option:
-            cache.set(cache_key, True, 3600)
-        else:
-            cache.set(cache_key, False, 60)
-    return has_releases_option
-
-
 def get_action_text(
     text: str, actions: Sequence[Any], identity: RpcIdentity, has_escalating: bool = False
 ) -> str:
@@ -208,11 +195,6 @@ def build_actions(
     return [resolve_button, ignore_button, assign_button], text, color
 
 
-def get_timestamp(group: Group, event: GroupEvent | None) -> float:
-    ts = group.last_seen
-    return to_timestamp(max(ts, event.datetime) if event else ts)
-
-
 def get_color(
     event_for_tags: GroupEvent | None, notification: BaseNotification | None, group: Group
 ) -> str:
diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py
index 32fb889e95c0a4..83e4e7009c2637 100644
--- a/src/sentry/models/activity.py
+++ b/src/sentry/models/activity.py
@@ -163,4 +163,5 @@ class ActivityIntegration(Enum):
     PROJECT_OWNERSHIP = "projectOwnership"
     SLACK = "slack"
     MSTEAMS = "msteams"
+    DISCORD = "discord"
     SUSPECT_COMMITTER = "suspectCommitter"
diff --git a/src/sentry/notifications/notifications/base.py b/src/sentry/notifications/notifications/base.py
index 04517c0c258d60..b07a1de5e864fe 100644
--- a/src/sentry/notifications/notifications/base.py
+++ b/src/sentry/notifications/notifications/base.py
@@ -23,6 +23,7 @@ class BaseNotification(abc.ABC):
     provider_to_url_format = {
         ExternalProviders.SLACK: "<{url}|{text}>",
         ExternalProviders.MSTEAMS: "[{text}]({url})",
+        ExternalProviders.DISCORD: "[{text}]({url})",
     }
     message_builder = "SlackNotificationsMessageBuilder"
     # some notifications have no settings for it
diff --git a/tests/sentry/api/endpoints/test_project_rules_configuration.py b/tests/sentry/api/endpoints/test_project_rules_configuration.py
index 4be4d599554e64..8b9a0a61b5a245 100644
--- a/tests/sentry/api/endpoints/test_project_rules_configuration.py
+++ b/tests/sentry/api/endpoints/test_project_rules_configuration.py
@@ -25,7 +25,7 @@ def test_simple(self):
         self.create_project(teams=[team], name="baz")
 
         response = self.get_success_response(self.organization.slug, project1.slug)
-        assert len(response.data["actions"]) == 8
+        assert len(response.data["actions"]) == 9
         assert len(response.data["conditions"]) == 7
         assert len(response.data["filters"]) == 8
 
@@ -117,7 +117,7 @@ def test_sentry_app_alertable_webhook(self):
 
         response = self.get_success_response(self.organization.slug, project1.slug)
 
-        assert len(response.data["actions"]) == 9
+        assert len(response.data["actions"]) == 10
         assert {
             "id": "sentry.rules.actions.notify_event_service.NotifyEventServiceAction",
             "label": "Send a notification via {service}",
@@ -147,7 +147,7 @@ def test_sentry_app_alert_rules(self, mock_sentry_app_components_preparer):
         )
         response = self.get_success_response(self.organization.slug, project1.slug)
 
-        assert len(response.data["actions"]) == 9
+        assert len(response.data["actions"]) == 10
         assert {
             "id": SENTRY_APP_ALERT_ACTION,
             "service": sentry_app.slug,
@@ -163,7 +163,7 @@ def test_sentry_app_alert_rules(self, mock_sentry_app_components_preparer):
 
     def test_issue_type_and_category_filter_feature(self):
         response = self.get_success_response(self.organization.slug, self.project.slug)
-        assert len(response.data["actions"]) == 8
+        assert len(response.data["actions"]) == 9
         assert len(response.data["conditions"]) == 7
         assert len(response.data["filters"]) == 8
 
diff --git a/tests/sentry/integrations/discord/message_builder/test_action_row.py b/tests/sentry/integrations/discord/message_builder/test_action_row.py
new file mode 100644
index 00000000000000..41d3cc18dac98a
--- /dev/null
+++ b/tests/sentry/integrations/discord/message_builder/test_action_row.py
@@ -0,0 +1,62 @@
+import pytest
+
+from sentry.integrations.discord.message_builder.base.component.action_row import (
+    DiscordActionRow,
+    DiscordActionRowError,
+)
+from sentry.integrations.discord.message_builder.base.component.base import DiscordMessageComponent
+from sentry.integrations.discord.message_builder.base.component.button import (
+    DiscordButton,
+    DiscordButtonStyle,
+)
+from sentry.testutils.cases import TestCase
+
+
+class TestDiscordActionRow(TestCase):
+    def test_empty(self):
+        action_row = DiscordActionRow([])
+        result = action_row.build()
+        assert result == {
+            "type": 1,
+            "components": [],
+        }
+
+    def test_non_empty(self):
+        button = DiscordButton(
+            style=DiscordButtonStyle.PRIMARY,
+            custom_id="test_button",
+            label="button label",
+            url="https://sentry.io",
+            disabled=True,
+        )
+        custom_component = DiscordMessageComponent(
+            type=9
+        )  # not a real type number, just testing custom component
+        action_row = DiscordActionRow(
+            [
+                button,
+                custom_component,
+            ]
+        )
+        result = action_row.build()
+        assert result == {
+            "type": 1,
+            "components": [
+                {
+                    "type": 2,
+                    "style": 1,
+                    "custom_id": "test_button",
+                    "label": "button label",
+                    "url": "https://sentry.io",
+                    "disabled": True,
+                },
+                {
+                    "type": 9,
+                },
+            ],
+        }
+
+    def test_action_row_error(self):
+        nested_row = DiscordActionRow([])
+        with pytest.raises(DiscordActionRowError):
+            DiscordActionRow([nested_row])
diff --git a/tests/sentry/integrations/discord/message_builder/test_builder.py b/tests/sentry/integrations/discord/message_builder/test_builder.py
new file mode 100644
index 00000000000000..16f3d961290b07
--- /dev/null
+++ b/tests/sentry/integrations/discord/message_builder/test_builder.py
@@ -0,0 +1,144 @@
+from sentry.integrations.discord.message_builder import LEVEL_TO_COLOR
+from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
+from sentry.integrations.discord.message_builder.base.component.action_row import DiscordActionRow
+from sentry.integrations.discord.message_builder.base.component.button import (
+    DiscordButton,
+    DiscordButtonStyle,
+)
+from sentry.integrations.discord.message_builder.base.component.select_menu import (
+    DiscordSelectMenu,
+    DiscordSelectMenuOption,
+)
+from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
+from sentry.integrations.discord.message_builder.base.flags import DiscordMessageFlags
+from sentry.integrations.discord.requests.base import DiscordMessageComponentTypes
+from sentry.testutils.cases import TestCase
+
+
+class TestDiscordMessageBuilder(TestCase):
+    def test_empty(self):
+        message = DiscordMessageBuilder()
+        result = message.build()
+        assert result == {
+            "content": "",
+            "components": [],
+            "embeds": [],
+        }
+
+    def test_some(self):
+        flags = DiscordMessageFlags().set_ephemeral()
+        message = DiscordMessageBuilder(
+            content="message content",
+            flags=flags,
+        )
+        result = message.build()
+        assert result == {
+            "content": "message content",
+            "components": [],
+            "embeds": [],
+            "flags": 1 << 6,
+        }
+
+    def test_all(self):
+        embed = DiscordMessageEmbed(
+            title="Title",
+            description="description",
+            url="https://sentry.io",
+            color=LEVEL_TO_COLOR["warning"],
+        )
+        other_embed = DiscordMessageEmbed(
+            title="Other title",
+            description="other description",
+            color=LEVEL_TO_COLOR["info"],
+        )
+        button = DiscordButton(
+            style=DiscordButtonStyle.PRIMARY,
+            custom_id="test_button",
+            label="button label",
+        )
+        other_button = DiscordButton(
+            style=DiscordButtonStyle.DANGER,
+            custom_id="danger_button",
+            label="delete",
+        )
+        component = DiscordActionRow([button, other_button])
+        option = DiscordSelectMenuOption("option", "first", "descriptionnn", True)
+        other_option = DiscordSelectMenuOption("other", "second")
+        menu = DiscordSelectMenu(
+            "custom-id", [option, other_option], placeholder="place being held"
+        )
+        other_component = DiscordActionRow([menu])
+
+        flags = DiscordMessageFlags().set_ephemeral()
+
+        message = DiscordMessageBuilder(
+            content="message content",
+            embeds=[embed, other_embed],
+            components=[component, other_component],
+            flags=flags,
+        )
+        result = message.build()
+        assert result == {
+            "content": "message content",
+            "embeds": [
+                {
+                    "title": "Title",
+                    "description": "description",
+                    "url": "https://sentry.io",
+                    "color": LEVEL_TO_COLOR["warning"],
+                },
+                {
+                    "title": "Other title",
+                    "description": "other description",
+                    "color": LEVEL_TO_COLOR["info"],
+                },
+            ],
+            "components": [
+                {
+                    "type": DiscordMessageComponentTypes.ACTION_ROW,
+                    "components": [
+                        {
+                            "type": DiscordMessageComponentTypes.BUTTON,
+                            "style": DiscordButtonStyle.PRIMARY,
+                            "custom_id": "test_button",
+                            "label": "button label",
+                            "disabled": False,
+                        },
+                        {
+                            "type": DiscordMessageComponentTypes.BUTTON,
+                            "style": DiscordButtonStyle.DANGER,
+                            "custom_id": "danger_button",
+                            "label": "delete",
+                            "disabled": False,
+                        },
+                    ],
+                },
+                {
+                    "type": DiscordMessageComponentTypes.ACTION_ROW,
+                    "components": [
+                        {
+                            "type": DiscordMessageComponentTypes.SELECT,
+                            "custom_id": "custom-id",
+                            "options": [
+                                {
+                                    "label": "option",
+                                    "value": "first",
+                                    "description": "descriptionnn",
+                                    "default": True,
+                                },
+                                {
+                                    "label": "other",
+                                    "value": "second",
+                                    "default": False,
+                                },
+                            ],
+                            "placeholder": "place being held",
+                            "min_values": 1,
+                            "max_values": 1,
+                            "disabled": False,
+                        }
+                    ],
+                },
+            ],
+            "flags": 1 << 6,
+        }
diff --git a/tests/sentry/integrations/discord/message_builder/test_button.py b/tests/sentry/integrations/discord/message_builder/test_button.py
new file mode 100644
index 00000000000000..615f0186031a53
--- /dev/null
+++ b/tests/sentry/integrations/discord/message_builder/test_button.py
@@ -0,0 +1,40 @@
+from sentry.integrations.discord.message_builder.base.component.button import (
+    DiscordButton,
+    DiscordButtonStyle,
+)
+from sentry.testutils.cases import TestCase
+
+
+class TestDiscordButton(TestCase):
+    def test_some(self):
+        button = DiscordButton(
+            style=DiscordButtonStyle.PRIMARY,
+            custom_id="test_button",
+            label="button label",
+        )
+        result = button.build()
+        assert result == {
+            "type": 2,
+            "style": 1,
+            "custom_id": "test_button",
+            "label": "button label",
+            "disabled": False,
+        }
+
+    def test_all(self):
+        button = DiscordButton(
+            style=DiscordButtonStyle.PRIMARY,
+            custom_id="test_button",
+            label="button label",
+            url="https://sentry.io",
+            disabled=True,
+        )
+        result = button.build()
+        assert result == {
+            "type": 2,
+            "style": 1,
+            "custom_id": "test_button",
+            "label": "button label",
+            "url": "https://sentry.io",
+            "disabled": True,
+        }
diff --git a/tests/sentry/integrations/discord/message_builder/test_embed.py b/tests/sentry/integrations/discord/message_builder/test_embed.py
new file mode 100644
index 00000000000000..182018b916c551
--- /dev/null
+++ b/tests/sentry/integrations/discord/message_builder/test_embed.py
@@ -0,0 +1,97 @@
+from sentry.integrations.discord.message_builder import LEVEL_TO_COLOR
+from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
+from sentry.integrations.discord.message_builder.base.embed.field import DiscordMessageEmbedField
+from sentry.integrations.discord.message_builder.base.embed.footer import DiscordMessageEmbedFooter
+from sentry.testutils.cases import TestCase
+
+
+class TestDiscordMessageEmbed(TestCase):
+    def test_empty(self):
+        embed = DiscordMessageEmbed()
+        result = embed.build()
+        assert result == {}
+
+    def test_some(self):
+        embed = DiscordMessageEmbed(
+            title="Title",
+            url="https://sentry.io",
+            color=LEVEL_TO_COLOR["warning"],
+        )
+        result = embed.build()
+        assert result == {
+            "title": "Title",
+            "url": "https://sentry.io",
+            "color": 16761383,
+        }
+
+    def test_footer(self):
+        footer = DiscordMessageEmbedFooter(text="footer text", icon_url="https://sentry.io")
+        embed = DiscordMessageEmbed(footer=footer)
+        result = embed.build()
+        assert result == {
+            "footer": {
+                "text": "footer text",
+                "icon_url": "https://sentry.io",
+            }
+        }
+
+    def test_fields(self):
+        field = DiscordMessageEmbedField(
+            "field name",
+            "field value",
+            True,
+        )
+        other_field = DiscordMessageEmbedField(
+            "other field name",
+            "other field value",
+        )
+        embed = DiscordMessageEmbed(fields=[field, other_field])
+        result = embed.build()
+        assert result == {
+            "fields": [
+                {
+                    "name": "field name",
+                    "value": "field value",
+                    "inline": True,
+                },
+                {
+                    "name": "other field name",
+                    "value": "other field value",
+                    "inline": False,
+                },
+            ]
+        }
+
+    def test_all(self):
+        footer = DiscordMessageEmbedFooter(text="footer text", icon_url="https://sentry.io")
+        field = DiscordMessageEmbedField(
+            "field name",
+            "field value",
+            True,
+        )
+        embed = DiscordMessageEmbed(
+            title="Title",
+            description="description",
+            url="https://sentry.io",
+            color=LEVEL_TO_COLOR["warning"],
+            footer=footer,
+            fields=[field],
+        )
+        result = embed.build()
+        assert result == {
+            "title": "Title",
+            "description": "description",
+            "url": "https://sentry.io",
+            "color": 16761383,
+            "footer": {
+                "text": "footer text",
+                "icon_url": "https://sentry.io",
+            },
+            "fields": [
+                {
+                    "name": "field name",
+                    "value": "field value",
+                    "inline": True,
+                }
+            ],
+        }
diff --git a/tests/sentry/integrations/discord/message_builder/test_flags.py b/tests/sentry/integrations/discord/message_builder/test_flags.py
new file mode 100644
index 00000000000000..19c3bc458dada7
--- /dev/null
+++ b/tests/sentry/integrations/discord/message_builder/test_flags.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from sentry.integrations.discord.message_builder.base.flags import DiscordMessageFlags
+from sentry.testutils.cases import TestCase
+
+
+class TestDiscordMessageFlags(TestCase):
+    def assert_bits_are_set(self, value: int, bits: list[int]) -> None:
+        expected = 0
+        for bit in bits:
+            expected = expected | 1 << bit
+            assert (value & 1 << bit) == 1 << bit
+        assert expected == value
+
+    def test_none(self):
+        flags = DiscordMessageFlags()
+        assert flags.value == 0
+
+    def test_ephemeral(self):
+        flags = DiscordMessageFlags().set_ephemeral()
+        self.assert_bits_are_set(flags.value, [6])
+
+    def test_loading(self):
+        flags = DiscordMessageFlags().set_loading()
+        self.assert_bits_are_set(flags.value, [7])
+
+    def test_suppress_notifications(self):
+        flags = DiscordMessageFlags().set_suppress_notifications()
+        self.assert_bits_are_set(flags.value, [12])
+
+    def test_all(self):
+        flags = DiscordMessageFlags()
+        flags.set_ephemeral()
+        flags.set_loading()
+        flags.set_suppress_notifications()
+        self.assert_bits_are_set(flags.value, [6, 7, 12])
diff --git a/tests/sentry/integrations/discord/message_builder/test_select_menu.py b/tests/sentry/integrations/discord/message_builder/test_select_menu.py
new file mode 100644
index 00000000000000..8b97ab478d733f
--- /dev/null
+++ b/tests/sentry/integrations/discord/message_builder/test_select_menu.py
@@ -0,0 +1,61 @@
+from sentry.integrations.discord.message_builder.base.component.select_menu import (
+    DiscordSelectMenu,
+    DiscordSelectMenuOption,
+)
+from sentry.integrations.discord.requests.base import DiscordMessageComponentTypes
+from sentry.testutils.cases import TestCase
+
+
+class TestDiscordSelectMenu(TestCase):
+    def test_empty(self):
+        menu = DiscordSelectMenu("custom-id", [])
+        result = menu.build()
+        assert result == {
+            "type": DiscordMessageComponentTypes.SELECT,
+            "custom_id": "custom-id",
+            "options": [],
+            "min_values": 1,
+            "max_values": 1,
+            "disabled": False,
+        }
+
+    def test_disabled(self):
+        menu = DiscordSelectMenu("custom-id", [], disabled=True)
+        result = menu.build()
+        assert result == {
+            "type": DiscordMessageComponentTypes.SELECT,
+            "custom_id": "custom-id",
+            "options": [],
+            "min_values": 1,
+            "max_values": 1,
+            "disabled": True,
+        }
+
+    def test_non_empty(self):
+        option = DiscordSelectMenuOption("option", "first", "descriptionnn", True)
+        other_option = DiscordSelectMenuOption("other", "second")
+        menu = DiscordSelectMenu(
+            "custom-id", [option, other_option], placeholder="place being held"
+        )
+        result = menu.build()
+        assert result == {
+            "type": DiscordMessageComponentTypes.SELECT,
+            "custom_id": "custom-id",
+            "options": [
+                {
+                    "label": "option",
+                    "value": "first",
+                    "description": "descriptionnn",
+                    "default": True,
+                },
+                {
+                    "label": "other",
+                    "value": "second",
+                    "default": False,
+                },
+            ],
+            "placeholder": "place being held",
+            "min_values": 1,
+            "max_values": 1,
+            "disabled": False,
+        }
diff --git a/tests/sentry/integrations/discord/test_integration.py b/tests/sentry/integrations/discord/test_integration.py
index b2a2ce41e6bf4d..92d425517ac9b8 100644
--- a/tests/sentry/integrations/discord/test_integration.py
+++ b/tests/sentry/integrations/discord/test_integration.py
@@ -153,7 +153,7 @@ def test_get_guild_name_failure(self):
     def test_setup(self):
         provider = self.provider()
 
-        url = f"{DiscordClient.base_url}{DiscordClient.APPLICATION_COMMANDS.format(application_id=self.application_id)}"
+        url = f"{DiscordClient.base_url}{DiscordClient.APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}"
         responses.add(
             responses.PUT,
             url=url,
@@ -170,7 +170,7 @@ def test_setup_failure(self, mock_log_error):
         mock_log_error.return_value = None
         provider = self.provider()
 
-        url = f"{DiscordClient.base_url}{DiscordClient.APPLICATION_COMMANDS.format(application_id=self.application_id)}"
+        url = f"{DiscordClient.base_url}{DiscordClient.APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}"
         responses.add(
             responses.PUT,
             url=url,
@@ -186,7 +186,7 @@ def test_setup_failure(self, mock_log_error):
     def test_setup_cache(self):
         provider = self.provider()
 
-        url = f"{DiscordClient.base_url}{DiscordClient.APPLICATION_COMMANDS.format(application_id=self.application_id)}"
+        url = f"{DiscordClient.base_url}{DiscordClient.APPLICATION_COMMANDS_URL.format(application_id=self.application_id)}"
         responses.add(
             responses.PUT,
             url=url,
diff --git a/tests/sentry/integrations/discord/test_issue_alert.py b/tests/sentry/integrations/discord/test_issue_alert.py
new file mode 100644
index 00000000000000..fd61b61a814bff
--- /dev/null
+++ b/tests/sentry/integrations/discord/test_issue_alert.py
@@ -0,0 +1,283 @@
+from unittest import mock
+
+import responses
+from django.core.exceptions import ValidationError
+
+from sentry.integrations.discord.actions.form import DiscordNotifyServiceForm
+from sentry.integrations.discord.actions.notification import DiscordNotifyServiceAction
+from sentry.integrations.discord.client import DiscordClient
+from sentry.integrations.discord.message_builder import LEVEL_TO_COLOR
+from sentry.integrations.discord.message_builder.base.component import DiscordComponentCustomIds
+from sentry.integrations.message_builder import build_attachment_title, build_footer, get_title_link
+from sentry.models.group import GroupStatus
+from sentry.models.release import Release
+from sentry.services.hybrid_cloud.integration import integration_service
+from sentry.testutils.cases import RuleTestCase, TestCase
+from sentry.testutils.helpers.datetime import before_now, iso_format
+from sentry.types.integrations import ExternalProviders
+from sentry.utils import json
+
+
+class DiscordIssueAlertTest(RuleTestCase):
+    rule_cls = DiscordNotifyServiceAction
+
+    def setUp(self):
+        self.guild_id = "guild-id"
+        self.channel_id = "channel-id"
+        self.discord_user_id = "user1234"
+        self.discord_integration = self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=self.guild_id,
+            organization=self.organization,
+        )
+        self.provider = self.create_identity_provider(integration=self.discord_integration)
+        self.identity = self.create_identity(
+            user=self.user, identity_provider=self.provider, external_id=self.discord_user_id
+        )
+        self.event = self.store_event(
+            data={
+                "event_id": "a" * 32,
+                "message": "Event message",
+                "timestamp": iso_format(before_now(seconds=1)),
+            },
+            project_id=self.project.id,
+        )
+        self.tags = "environment, user"
+        self.rule = self.get_rule(
+            data={
+                "server": self.discord_integration.id,
+                "channel_id": self.channel_id,
+                "tags": self.tags,
+            }
+        )
+
+        responses.add(
+            method=responses.POST,
+            url=f"{DiscordClient.MESSAGE_URL.format(channel_id=self.channel_id)}",
+            status=200,
+        )
+
+    @responses.activate
+    def test_basic(self):
+        results = list(self.rule.after(self.event, self.get_state()))
+        assert len(results) == 1
+
+        results[0].callback(self.event, futures=[])
+        body = responses.calls[0].request.body
+        data = json.loads(bytes.decode(body, "utf-8"))
+
+        embed = data["embeds"][0]
+        assert embed == {
+            "title": build_attachment_title(self.event.group),
+            "url": get_title_link(
+                self.event.group,
+                self.event,
+                False,
+                False,
+                None,
+                ExternalProviders.DISCORD,
+            ),
+            "color": LEVEL_TO_COLOR["info"],
+            "footer": {"text": build_footer(self.event.group, self.event.project, None, "{text}")},
+            "fields": [],
+            "timestamp": self.event.timestamp,
+        }
+
+        buttons = data["components"][0]["components"]
+        assert (
+            buttons[0]["custom_id"] == f"{DiscordComponentCustomIds.RESOLVE}:{self.event.group.id}"
+        )
+        assert (
+            buttons[1]["custom_id"] == f"{DiscordComponentCustomIds.ARCHIVE}:{self.event.group.id}"
+        )
+        assert (
+            buttons[2]["custom_id"]
+            == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}"
+        )
+
+    @responses.activate
+    def test_has_releases(self):
+        release = Release.objects.create(
+            organization_id=self.organization.id,
+            version="1.0",
+        )
+        release.add_project(self.project)
+
+        results = list(self.rule.after(self.event, self.get_state()))
+        assert len(results) == 1
+
+        results[0].callback(self.event, futures=[])
+        body = responses.calls[0].request.body
+        data = json.loads(bytes.decode(body, "utf-8"))
+
+        buttons = data["components"][0]["components"]
+        assert (
+            buttons[0]["custom_id"]
+            == f"{DiscordComponentCustomIds.RESOLVE_DIALOG}:{self.event.group.id}"
+        )
+        assert (
+            buttons[1]["custom_id"] == f"{DiscordComponentCustomIds.ARCHIVE}:{self.event.group.id}"
+        )
+        assert (
+            buttons[2]["custom_id"]
+            == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}"
+        )
+
+    @responses.activate
+    @mock.patch(
+        "sentry.integrations.discord.message_builder.issues.Group.get_status",
+        return_value=GroupStatus.RESOLVED,
+    )
+    def test_resolved(self, mock_get_status):
+        results = list(self.rule.after(self.event, self.get_state()))
+        assert len(results) == 1
+
+        results[0].callback(self.event, futures=[])
+        body = responses.calls[0].request.body
+        data = json.loads(bytes.decode(body, "utf-8"))
+
+        buttons = data["components"][0]["components"]
+        assert (
+            buttons[0]["custom_id"]
+            == f"{DiscordComponentCustomIds.UNRESOLVE}:{self.event.group.id}"
+        )
+        assert (
+            buttons[1]["custom_id"] == f"{DiscordComponentCustomIds.ARCHIVE}:{self.event.group.id}"
+        )
+        assert (
+            buttons[2]["custom_id"]
+            == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}"
+        )
+
+    @responses.activate
+    @mock.patch(
+        "sentry.integrations.discord.message_builder.issues.Group.get_status",
+        return_value=GroupStatus.IGNORED,
+    )
+    def test_ignored(self, mock_get_status):
+        results = list(self.rule.after(self.event, self.get_state()))
+        assert len(results) == 1
+
+        results[0].callback(self.event, futures=[])
+        body = responses.calls[0].request.body
+        data = json.loads(bytes.decode(body, "utf-8"))
+
+        buttons = data["components"][0]["components"]
+        assert (
+            buttons[0]["custom_id"] == f"{DiscordComponentCustomIds.RESOLVE}:{self.event.group.id}"
+        )
+        assert (
+            buttons[1]["custom_id"]
+            == f"{DiscordComponentCustomIds.MARK_ONGOING}:{self.event.group.id}"
+        )
+        assert (
+            buttons[2]["custom_id"]
+            == f"{DiscordComponentCustomIds.ASSIGN_DIALOG}:{self.event.group.id}"
+        )
+
+    def test_integration_removed(self):
+        integration_service.delete_integration(integration_id=self.discord_integration.id)
+        results = list(self.rule.after(self.event, self.get_state()))
+        assert len(results) == 0
+
+    @mock.patch("sentry.integrations.discord.actions.form.validate_channel_id")
+    def test_get_form_instance(self, mock_validate_channel_id):
+        form = self.rule.get_form_instance()
+        form.full_clean()
+        assert form.is_valid()
+        assert int(form.cleaned_data["server"]) == self.discord_integration.id
+        assert form.cleaned_data["channel_id"] == self.channel_id
+        assert form.cleaned_data["tags"] == self.tags
+
+    def test_label(self):
+        label = self.rule.render_label()
+        assert (
+            label
+            == "Send a notification to the Cool server Discord server in the channel with ID: channel-id and show tags [environment, user] in the notification."
+        )
+
+
+class DiscordNotifyServiceFormTest(TestCase):
+    def setUp(self):
+        self.guild_id = "guild-id"
+        self.channel_id = "channel-id"
+        self.discord_integration = self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=self.guild_id,
+            organization=self.organization,
+        )
+        self.other_integration = self.create_integration(
+            provider="discord",
+            name="Uncool server",
+            external_id="different-guild-id",
+            organization=self.organization,
+        )
+        self.integrations = [self.discord_integration, self.other_integration]
+
+    def test_has_choices(self):
+        form = DiscordNotifyServiceForm(integrations=self.integrations)
+        assert form.fields["server"].choices == [  # type: ignore
+            (self.discord_integration.id, self.discord_integration.name),
+            (self.other_integration.id, self.other_integration.name),
+        ]
+
+    @mock.patch("sentry.integrations.discord.actions.form.validate_channel_id", return_value=None)
+    def test_valid(self, mock_validate_channel_id):
+        form = DiscordNotifyServiceForm(
+            data={
+                "server": self.discord_integration.id,
+                "channel_id": self.channel_id,
+                "tags": "environment",
+            },
+            integrations=self.integrations,
+        )
+
+        form.full_clean()
+        assert form.is_valid()
+        assert mock_validate_channel_id.call_count == 1
+
+    def test_no_channel_id(self):
+        form = DiscordNotifyServiceForm(
+            data={"server": self.discord_integration.id},
+            integrations=self.integrations,
+        )
+        form.full_clean()
+        assert not form.is_valid()
+
+    def test_no_server(self):
+        form = DiscordNotifyServiceForm(integrations=self.integrations)
+        form.full_clean()
+        assert not form.is_valid()
+
+    @mock.patch("sentry.integrations.discord.actions.form.validate_channel_id", return_value=None)
+    def test_no_tags(self, mock_validate_channel_id):
+        form = DiscordNotifyServiceForm(
+            data={
+                "server": self.discord_integration.id,
+                "channel_id": self.channel_id,
+            },
+            integrations=self.integrations,
+        )
+
+        form.full_clean()
+        assert form.is_valid()
+        assert mock_validate_channel_id.call_count == 1
+
+    @mock.patch(
+        "sentry.integrations.discord.actions.form.validate_channel_id",
+        side_effect=ValidationError("bad"),
+    )
+    def test_invalid_channel_id(self, mock_validate_channel_id):
+        form = DiscordNotifyServiceForm(
+            data={
+                "server": self.discord_integration.id,
+                "channel_id": self.channel_id,
+            },
+            integrations=self.integrations,
+        )
+
+        form.full_clean()
+        assert not form.is_valid()
+        assert mock_validate_channel_id.call_count == 1
diff --git a/tests/sentry/integrations/discord/test_message_builder.py b/tests/sentry/integrations/discord/test_message_builder.py
deleted file mode 100644
index 8fa51e9bb36474..00000000000000
--- a/tests/sentry/integrations/discord/test_message_builder.py
+++ /dev/null
@@ -1,319 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from sentry.integrations.discord.message_builder import LEVEL_TO_COLOR
-from sentry.integrations.discord.message_builder.base.base import DiscordMessageBuilder
-from sentry.integrations.discord.message_builder.base.component.action_row import (
-    DiscordActionRow,
-    DiscordActionRowError,
-)
-from sentry.integrations.discord.message_builder.base.component.base import DiscordMessageComponent
-from sentry.integrations.discord.message_builder.base.component.button import (
-    DiscordButton,
-    DiscordButtonStyle,
-)
-from sentry.integrations.discord.message_builder.base.embed.base import DiscordMessageEmbed
-from sentry.integrations.discord.message_builder.base.embed.field import DiscordMessageEmbedField
-from sentry.integrations.discord.message_builder.base.embed.footer import DiscordMessageEmbedFooter
-from sentry.integrations.discord.message_builder.base.flags import DiscordMessageFlags
-from sentry.testutils.cases import TestCase
-
-
-class TestDiscordMessageBuilder(TestCase):
-    def test_empty(self):
-        message = DiscordMessageBuilder()
-        result = message.build()
-        assert result == {}
-
-    def test_some(self):
-        flags = DiscordMessageFlags().set_ephemeral()
-        message = DiscordMessageBuilder(
-            content="message content",
-            flags=flags,
-        )
-        result = message.build()
-        assert result == {
-            "content": "message content",
-            "flags": 1 << 6,
-        }
-
-    def test_all(self):
-        embed = DiscordMessageEmbed(
-            title="Title",
-            description="description",
-            url="https://sentry.io",
-            color=LEVEL_TO_COLOR["warning"],
-        )
-        other_embed = DiscordMessageEmbed(
-            title="Other title",
-            description="other description",
-            color=LEVEL_TO_COLOR["info"],
-        )
-        button = DiscordButton(
-            style=DiscordButtonStyle.PRIMARY,
-            custom_id="test_button",
-            label="button label",
-        )
-        other_button = DiscordButton(
-            style=DiscordButtonStyle.DANGER,
-            custom_id="danger_button",
-            label="delete",
-        )
-        component = DiscordActionRow([button, other_button])
-        flags = DiscordMessageFlags().set_ephemeral()
-
-        message = DiscordMessageBuilder(
-            content="message content",
-            embeds=[embed, other_embed],
-            components=[component],
-            flags=flags,
-        )
-        result = message.build()
-        assert result == {
-            "content": "message content",
-            "embeds": [
-                {
-                    "title": "Title",
-                    "description": "description",
-                    "url": "https://sentry.io",
-                    "color": LEVEL_TO_COLOR["warning"],
-                },
-                {
-                    "title": "Other title",
-                    "description": "other description",
-                    "color": LEVEL_TO_COLOR["info"],
-                },
-            ],
-            "components": [
-                {
-                    "type": 1,
-                    "components": [
-                        {
-                            "type": 2,
-                            "style": DiscordButtonStyle.PRIMARY,
-                            "custom_id": "test_button",
-                            "label": "button label",
-                            "disabled": False,
-                        },
-                        {
-                            "type": 2,
-                            "style": DiscordButtonStyle.DANGER,
-                            "custom_id": "danger_button",
-                            "label": "delete",
-                            "disabled": False,
-                        },
-                    ],
-                }
-            ],
-            "flags": 1 << 6,
-        }
-
-
-class TestDiscordMessageFlags(TestCase):
-    def assert_bits_are_set(self, value: int, bits: list[int]) -> None:
-        expected = 0
-        for bit in bits:
-            expected = expected | 1 << bit
-            assert (value & 1 << bit) == 1 << bit
-        assert expected == value
-
-    def test_none(self):
-        flags = DiscordMessageFlags()
-        assert flags.value == 0
-
-    def test_ephemeral(self):
-        flags = DiscordMessageFlags().set_ephemeral()
-        self.assert_bits_are_set(flags.value, [6])
-
-    def test_loading(self):
-        flags = DiscordMessageFlags().set_loading()
-        self.assert_bits_are_set(flags.value, [7])
-
-    def test_suppress_notifications(self):
-        flags = DiscordMessageFlags().set_suppress_notifications()
-        self.assert_bits_are_set(flags.value, [12])
-
-    def test_all(self):
-        flags = DiscordMessageFlags()
-        flags.set_ephemeral()
-        flags.set_loading()
-        flags.set_suppress_notifications()
-        self.assert_bits_are_set(flags.value, [6, 7, 12])
-
-
-class TestDiscordButton(TestCase):
-    def test_some(self):
-        button = DiscordButton(
-            style=DiscordButtonStyle.PRIMARY,
-            custom_id="test_button",
-            label="button label",
-        )
-        result = button.build()
-        assert result == {
-            "type": 2,
-            "style": 1,
-            "custom_id": "test_button",
-            "label": "button label",
-            "disabled": False,
-        }
-
-    def test_all(self):
-        button = DiscordButton(
-            style=DiscordButtonStyle.PRIMARY,
-            custom_id="test_button",
-            label="button label",
-            url="https://sentry.io",
-            disabled=True,
-        )
-        result = button.build()
-        assert result == {
-            "type": 2,
-            "style": 1,
-            "custom_id": "test_button",
-            "label": "button label",
-            "url": "https://sentry.io",
-            "disabled": True,
-        }
-
-
-class TestDiscordActionRow(TestCase):
-    def test_empty(self):
-        action_row = DiscordActionRow([])
-        result = action_row.build()
-        assert result == {
-            "type": 1,
-            "components": [],
-        }
-
-    def test_non_empty(self):
-        button = DiscordButton(
-            style=DiscordButtonStyle.PRIMARY,
-            custom_id="test_button",
-            label="button label",
-            url="https://sentry.io",
-            disabled=True,
-        )
-        custom_component = DiscordMessageComponent(
-            type=9
-        )  # not a real type number, just testing custom component
-        action_row = DiscordActionRow(
-            [
-                button,
-                custom_component,
-            ]
-        )
-        result = action_row.build()
-        assert result == {
-            "type": 1,
-            "components": [
-                {
-                    "type": 2,
-                    "style": 1,
-                    "custom_id": "test_button",
-                    "label": "button label",
-                    "url": "https://sentry.io",
-                    "disabled": True,
-                },
-                {
-                    "type": 9,
-                },
-            ],
-        }
-
-    def test_action_row_error(self):
-        nested_row = DiscordActionRow([])
-        with pytest.raises(DiscordActionRowError):
-            DiscordActionRow([nested_row])
-
-
-class TestDiscordMessageEmbed(TestCase):
-    def test_empty(self):
-        embed = DiscordMessageEmbed()
-        result = embed.build()
-        assert result == {}
-
-    def test_some(self):
-        embed = DiscordMessageEmbed(
-            title="Title",
-            url="https://sentry.io",
-            color=LEVEL_TO_COLOR["warning"],
-        )
-        result = embed.build()
-        assert result == {
-            "title": "Title",
-            "url": "https://sentry.io",
-            "color": 16761383,
-        }
-
-    def test_footer(self):
-        footer = DiscordMessageEmbedFooter(text="footer text", icon_url="https://sentry.io")
-        embed = DiscordMessageEmbed(footer=footer)
-        result = embed.build()
-        assert result == {
-            "footer": {
-                "text": "footer text",
-                "icon_url": "https://sentry.io",
-            }
-        }
-
-    def test_fields(self):
-        field = DiscordMessageEmbedField(
-            "field name",
-            "field value",
-            True,
-        )
-        other_field = DiscordMessageEmbedField(
-            "other field name",
-            "other field value",
-        )
-        embed = DiscordMessageEmbed(fields=[field, other_field])
-        result = embed.build()
-        assert result == {
-            "fields": [
-                {
-                    "name": "field name",
-                    "value": "field value",
-                    "inline": True,
-                },
-                {
-                    "name": "other field name",
-                    "value": "other field value",
-                    "inline": False,
-                },
-            ]
-        }
-
-    def test_all(self):
-        footer = DiscordMessageEmbedFooter(text="footer text", icon_url="https://sentry.io")
-        field = DiscordMessageEmbedField(
-            "field name",
-            "field value",
-            True,
-        )
-        embed = DiscordMessageEmbed(
-            title="Title",
-            description="description",
-            url="https://sentry.io",
-            color=LEVEL_TO_COLOR["warning"],
-            footer=footer,
-            fields=[field],
-        )
-        result = embed.build()
-        assert result == {
-            "title": "Title",
-            "description": "description",
-            "url": "https://sentry.io",
-            "color": 16761383,
-            "footer": {
-                "text": "footer text",
-                "icon_url": "https://sentry.io",
-            },
-            "fields": [
-                {
-                    "name": "field name",
-                    "value": "field value",
-                    "inline": True,
-                }
-            ],
-        }
diff --git a/tests/sentry/integrations/discord/test_requests.py b/tests/sentry/integrations/discord/test_requests.py
index dd7f216b83759e..1c1b6a43df545c 100644
--- a/tests/sentry/integrations/discord/test_requests.py
+++ b/tests/sentry/integrations/discord/test_requests.py
@@ -3,9 +3,7 @@
 from unittest import mock
 from urllib.parse import urlencode
 
-import pytest
-
-from sentry.integrations.discord.requests.base import DiscordRequest, DiscordRequestError
+from sentry.integrations.discord.requests.base import DiscordRequest
 from sentry.services.hybrid_cloud.integration.model import RpcIntegration
 from sentry.testutils.cases import TestCase
 from sentry.testutils.silo import control_silo_test
@@ -36,21 +34,10 @@ def mock_request(self, request_data: dict | None = None) -> DiscordRequest:
         }
         return DiscordRequest(self.request)
 
-    def test_exposes_data(self):
-        discord_request = self.mock_request()
-        assert discord_request.data["type"] == 1
-
     def test_exposes_guild_id(self):
         discord_request = self.mock_request()
         assert discord_request.guild_id == "guild-id"
 
-    def test_validate_data_returns_400(self):
-        discord_request = self.mock_request()
-        type(self.request).data = mock.PropertyMock(side_effect=ValueError())
-        with pytest.raises(DiscordRequestError) as e:
-            discord_request.validate()
-            assert e.value.status == 400
-
     def test_collects_logging_data(self):
         discord_request = self.mock_request()
         assert discord_request.logging_data == {
@@ -112,13 +99,11 @@ def test_get_command_name(self):
                 },
             }
         )
-        discord_request._validate_data()
         res = discord_request.get_command_name()
         assert res == "test_command"
 
     def test_get_command_name_not_command(self):
         discord_request = self.mock_request()
-        discord_request._validate_data()
         res = discord_request.get_command_name()
         assert res == ""
 
diff --git a/tests/sentry/integrations/discord/test_utils.py b/tests/sentry/integrations/discord/test_utils.py
index f9cf7f0a213e68..cee5192676a298 100644
--- a/tests/sentry/integrations/discord/test_utils.py
+++ b/tests/sentry/integrations/discord/test_utils.py
@@ -1,4 +1,12 @@
+from unittest import mock
+
+from django.core.exceptions import ValidationError
+from pytest import raises
+
 from sentry.integrations.discord.utils.auth import verify_signature
+from sentry.integrations.discord.utils.channel import validate_channel_id
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.shared_integrations.exceptions.base import ApiError
 from sentry.testutils.cases import TestCase
 
 
@@ -24,3 +32,38 @@ def test_verify_signature_invalid(self):
         result = verify_signature(public_key_string, signature, message)
 
         assert not result
+
+
+class ValidateChannelTest(TestCase):
+    guild_id = "guild-id"
+    channel_id = "channel-id"
+    integration_id = 1234
+
+    @mock.patch("sentry.integrations.discord.utils.channel.DiscordClient.get_channel")
+    def test_happy_path(self, mock_get_channel):
+        mock_get_channel.return_value = {"guild_id": self.guild_id}
+        validate_channel_id(self.channel_id, self.guild_id, self.integration_id)
+
+    @mock.patch("sentry.integrations.discord.utils.channel.DiscordClient.get_channel")
+    def test_404(self, mock_get_channel):
+        mock_get_channel.side_effect = ApiError(code=404, text="")
+        with raises(ValidationError):
+            validate_channel_id(self.channel_id, self.guild_id, self.integration_id)
+
+    @mock.patch("sentry.integrations.discord.utils.channel.DiscordClient.get_channel")
+    def test_api_error(self, mock_get_channel):
+        mock_get_channel.side_effect = ApiError(code=401, text="")
+        with raises(IntegrationError):
+            validate_channel_id(self.channel_id, self.guild_id, self.integration_id)
+
+    @mock.patch("sentry.integrations.discord.utils.channel.DiscordClient.get_channel")
+    def test_bad_response(self, mock_get_channel):
+        mock_get_channel.return_value = ""
+        with raises(IntegrationError):
+            validate_channel_id(self.channel_id, self.guild_id, self.integration_id)
+
+    @mock.patch("sentry.integrations.discord.utils.channel.DiscordClient.get_channel")
+    def test_not_guild_member(self, mock_get_channel):
+        mock_get_channel.return_value = {"guild_id": "not-my-guild"}
+        with raises(ValidationError):
+            validate_channel_id(self.channel_id, self.guild_id, self.integration_id)
diff --git a/tests/sentry/integrations/discord/test_webhook.py b/tests/sentry/integrations/discord/webhooks/test_command.py
similarity index 76%
rename from tests/sentry/integrations/discord/test_webhook.py
rename to tests/sentry/integrations/discord/webhooks/test_command.py
index f4d2a4cca024cc..dd8effa3a5fe54 100644
--- a/tests/sentry/integrations/discord/test_webhook.py
+++ b/tests/sentry/integrations/discord/webhooks/test_command.py
@@ -8,24 +8,7 @@
 
 
 @region_silo_test(stable=True)
-class DiscordWebhookTest(APITestCase):
-    @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
-    def test_ping_interaction(self, mock_verify_signature):
-        mock_verify_signature.return_value = True
-        resp = self.client.post(
-            path=WEBHOOK_URL,
-            data={
-                "type": 1,
-            },
-            format="json",
-            HTTP_X_SIGNATURE_ED25519="signature",
-            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
-        )
-
-        assert resp.status_code == 200
-        assert resp.json()["type"] == 1
-        assert mock_verify_signature.call_count == 1
-
+class DiscordCommandInteractionTest(APITestCase):
     @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
     def test_command_interaction(self, mock_verify_signature):
         mock_verify_signature.return_value = True
@@ -40,60 +23,6 @@ def test_command_interaction(self, mock_verify_signature):
         assert resp.status_code == 200
         assert resp.json()["type"] == 4
 
-    @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
-    def test_unknown_interaction(self, mock_verify_signature):
-        mock_verify_signature.return_value = True
-        resp = self.client.post(
-            path=WEBHOOK_URL,
-            data={
-                "type": -1,
-            },
-            format="json",
-            HTTP_X_SIGNATURE_ED25519="signature",
-            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
-        )
-
-        assert resp.status_code == 200
-
-    @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
-    def test_unauthorized_interaction(self, mock_verify_signature):
-        mock_verify_signature.return_value = False
-        resp = self.client.post(
-            path=WEBHOOK_URL,
-            data={
-                "type": -1,
-            },
-            format="json",
-            HTTP_X_SIGNATURE_ED25519="signature",
-            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
-        )
-
-        assert resp.status_code == 401
-
-    def test_missing_signature(self):
-        resp = self.client.post(
-            path=WEBHOOK_URL,
-            data={
-                "type": -1,
-            },
-            format="json",
-            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
-        )
-
-        assert resp.status_code == 401
-
-    def test_missing_timestamp(self):
-        resp = self.client.post(
-            path=WEBHOOK_URL,
-            data={
-                "type": -1,
-            },
-            format="json",
-            HTTP_X_SIGNATURE_ED25519="signature",
-        )
-
-        assert resp.status_code == 401
-
     def test_link_no_integration(self):
         with mock.patch(
             "sentry.integrations.discord.requests.base.verify_signature", return_value=True
@@ -279,5 +208,4 @@ def test_help(self):
                 HTTP_X_SIGNATURE_ED25519="signature",
                 HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
             )
-
         assert resp.status_code == 200
diff --git a/tests/sentry/integrations/discord/webhooks/test_endpoint.py b/tests/sentry/integrations/discord/webhooks/test_endpoint.py
new file mode 100644
index 00000000000000..11097d7fae924f
--- /dev/null
+++ b/tests/sentry/integrations/discord/webhooks/test_endpoint.py
@@ -0,0 +1,80 @@
+from unittest import mock
+
+from sentry.testutils.cases import APITestCase
+from sentry.testutils.silo import region_silo_test
+
+WEBHOOK_URL = "/extensions/discord/interactions/"
+
+
+@region_silo_test(stable=True)
+class DiscordWebhookTest(APITestCase):
+    @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
+    def test_ping_interaction(self, mock_verify_signature):
+        mock_verify_signature.return_value = True
+        resp = self.client.post(
+            path=WEBHOOK_URL,
+            data={
+                "type": 1,
+            },
+            format="json",
+            HTTP_X_SIGNATURE_ED25519="signature",
+            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+        )
+
+        assert resp.status_code == 200
+        assert resp.json()["type"] == 1
+        assert mock_verify_signature.call_count == 1
+
+    @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
+    def test_unknown_interaction(self, mock_verify_signature):
+        mock_verify_signature.return_value = True
+        resp = self.client.post(
+            path=WEBHOOK_URL,
+            data={
+                "type": -1,
+            },
+            format="json",
+            HTTP_X_SIGNATURE_ED25519="signature",
+            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+        )
+
+        assert resp.status_code == 200
+
+    @mock.patch("sentry.integrations.discord.requests.base.verify_signature")
+    def test_unauthorized_interaction(self, mock_verify_signature):
+        mock_verify_signature.return_value = False
+        resp = self.client.post(
+            path=WEBHOOK_URL,
+            data={
+                "type": -1,
+            },
+            format="json",
+            HTTP_X_SIGNATURE_ED25519="signature",
+            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+        )
+
+        assert resp.status_code == 401
+
+    def test_missing_signature(self):
+        resp = self.client.post(
+            path=WEBHOOK_URL,
+            data={
+                "type": -1,
+            },
+            format="json",
+            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+        )
+
+        assert resp.status_code == 401
+
+    def test_missing_timestamp(self):
+        resp = self.client.post(
+            path=WEBHOOK_URL,
+            data={
+                "type": -1,
+            },
+            format="json",
+            HTTP_X_SIGNATURE_ED25519="signature",
+        )
+
+        assert resp.status_code == 401
diff --git a/tests/sentry/integrations/discord/webhooks/test_message_component.py b/tests/sentry/integrations/discord/webhooks/test_message_component.py
new file mode 100644
index 00000000000000..351ee827355a30
--- /dev/null
+++ b/tests/sentry/integrations/discord/webhooks/test_message_component.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from typing import Any
+from unittest import mock
+
+from sentry.integrations.discord.message_builder.base.component import (
+    DiscordComponentCustomIds as CustomIds,
+)
+from sentry.integrations.discord.requests.base import (
+    DiscordMessageComponentTypes,
+    DiscordRequestTypes,
+)
+from sentry.integrations.discord.webhooks.message_component import (
+    ASSIGNEE_UPDATED,
+    IGNORE_UNTIL_ESCALATES,
+    MARKED_ONGOING,
+    NO_IDENTITY,
+    NOT_IN_ORG,
+    RESOLVE_DIALOG_OPTIONS,
+    RESOLVED,
+    RESOLVED_IN_CURRENT_RELEASE,
+    RESOLVED_IN_NEXT_RELEASE,
+    UNRESOLVED,
+)
+from sentry.models.release import Release
+from sentry.testutils.cases import APITestCase
+
+WEBHOOK_URL = "/extensions/discord/interactions/"
+
+
+class DiscordMessageComponentInteractionTest(APITestCase):
+    def setUp(self):
+        patcher = mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        )
+        patcher.start()
+
+        self.guild_id = "guild-id"
+        self.channel_id = "channel-id"
+        self.discord_user_id = "user1234"
+
+        self.discord_integration = self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=self.guild_id,
+            organization=self.organization,
+        )
+        self.provider = self.create_identity_provider(integration=self.discord_integration)
+        self.create_identity(
+            user=self.user, identity_provider=self.provider, external_id=self.discord_user_id
+        )
+
+    def send_interaction(self, data: Any | None = None, member: Any | None = None):
+        if data is None:
+            data = {"custom_id": f"unknown:{self.group.id}"}
+        if member is None:
+            member = {"user": {"id": self.discord_user_id}}
+
+        return self.client.post(
+            path=WEBHOOK_URL,
+            data={
+                "type": DiscordRequestTypes.MESSAGE_COMPONENT,
+                "guild_id": self.guild_id,
+                "channel_id": self.channel_id,
+                "data": data,
+                "member": member,
+            },
+            format="json",
+            HTTP_X_SIGNATURE_ED25519="signature",
+            HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+        )
+
+    def get_message_content(self, response: Any) -> str:
+        return response.json()["data"]["content"]
+
+    def get_message_components(self, response: Any) -> Any:
+        return response.json()["data"]["components"]
+
+    def get_select_options(self, response: Any) -> Any:
+        return self.get_message_components(response)[0]["components"][0]["options"]
+
+    def test_unknown_id_interaction(self):
+        response = self.send_interaction({"custom_id": f"unknown:{self.group.id}"})
+        assert response.status_code == 404
+
+    def test_no_user(self):
+        response = self.send_interaction(member={"user": {"id": "not-our-user"}})
+        assert self.get_message_content(response) == NO_IDENTITY
+
+    def test_not_in_org(self):
+        other_user = self.create_user()
+        other_user_discord_id = "other-user1234"
+        other_org = self.create_organization()
+        self.discord_integration.add_organization(other_org)
+        self.create_identity(
+            user=other_user, identity_provider=self.provider, external_id=other_user_discord_id
+        )
+
+        response = self.send_interaction(member={"user": {"id": other_user_discord_id}})
+
+        assert self.get_message_content(response) == NOT_IN_ORG
+
+    def test_assign_dialog(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.BUTTON,
+                "custom_id": f"{CustomIds.ASSIGN_DIALOG}:{self.group.id}",
+            }
+        )
+        assert self.get_select_options(response) == [
+            {"label": f"#{self.team.slug}", "value": f"team:{self.team.id}", "default": False},
+            {"label": self.user.email, "value": f"user:{self.user.id}", "default": False},
+        ]
+
+    def test_assign(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.SELECT,
+                "custom_id": f"{CustomIds.ASSIGN}:{self.group.id}",
+                "values": [f"user:{self.user.id}"],
+            }
+        )
+        assert self.get_message_content(response) == ASSIGNEE_UPDATED
+
+    def test_resolve_dialog(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.BUTTON,
+                "custom_id": f"{CustomIds.RESOLVE_DIALOG}:{self.group.id}",
+            }
+        )
+        assert self.get_select_options(response) == [
+            option.build() for option in RESOLVE_DIALOG_OPTIONS
+        ]
+
+    def test_resolve_non_dialog(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.BUTTON,
+                "custom_id": f"{CustomIds.RESOLVE}:{self.group.id}",
+            }
+        )
+        assert self.get_message_content(response) == RESOLVED
+
+    def test_resolve_now_from_dialog(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.SELECT,
+                "custom_id": f"{CustomIds.RESOLVE}:{self.group.id}",
+                "values": [""],
+            }
+        )
+        assert self.get_message_content(response) == RESOLVED
+
+    def test_resolve_in_next_release(self):
+        release = Release.objects.create(
+            organization_id=self.organization.id,
+            version="1.0",
+        )
+        release.add_project(self.project)
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.SELECT,
+                "custom_id": f"{CustomIds.RESOLVE}:{self.group.id}",
+                "values": ["inNextRelease"],
+            }
+        )
+        assert self.get_message_content(response) == RESOLVED_IN_NEXT_RELEASE
+
+    def test_resolve_in_current_release(self):
+        release = Release.objects.create(
+            organization_id=self.organization.id,
+            version="1.0",
+        )
+        release.add_project(self.project)
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.SELECT,
+                "custom_id": f"{CustomIds.RESOLVE}:{self.group.id}",
+                "values": ["inCurrentRelease"],
+            }
+        )
+        assert self.get_message_content(response) == RESOLVED_IN_CURRENT_RELEASE
+
+    def test_unresolve(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.BUTTON,
+                "custom_id": f"{CustomIds.UNRESOLVE}:{self.group.id}",
+            }
+        )
+        assert self.get_message_content(response) == UNRESOLVED
+
+    def test_mark_ongoing(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.BUTTON,
+                "custom_id": f"{CustomIds.MARK_ONGOING}:{self.group.id}",
+            }
+        )
+        assert self.get_message_content(response) == MARKED_ONGOING
+
+    def test_archive(self):
+        response = self.send_interaction(
+            {
+                "component_type": DiscordMessageComponentTypes.BUTTON,
+                "custom_id": f"{CustomIds.ARCHIVE}:{self.group.id}",
+            }
+        )
+        assert self.get_message_content(response) == IGNORE_UNTIL_ESCALATES


Code review comments:
diff_hunk: @@ -139,9 +143,29 @@ def build_footer(
     footer = f"{group.qualified_short_id}"
     if rules:
         rule_url = build_rule_url(rules[0], group, project)
-        footer += f" via {url_format.format(text=rules[0].label, url=rule_url)}"
+        # If this notification is triggered via the "Send Test Notification"
+        # button then the label is not defined, but the url works.
+        text = rules[0].label if rules[0].label else "Test Alert"
+        footer += f" via {url_format.format(text=text, url=rule_url)}"
 
         if len(rules) > 1:
             footer += f" (+{len(rules) - 1} other)"
 
     return footer
+
+
+def has_releases(project: Project) -> bool:
path: src/sentry/integrations/message_builder.py
text: @user1:
I think we have a project flag for this `project.flags.has_releases` and it was backfilled [here](https://github.com/getsentry/sentry/blob/f9f07c369f43cc40ee82e9c995f5405962fd0a67/src/sentry/migrations/0290_fix_project_has_releases.py#L9). I'd double check that it's still in use, but this should do what we want