Repository: getsentry/sentry
Pull request: feat(discord): Implement Discord identity linking/unlinking
Create Discord Identity provider. Identity for installing user will be created on integration installation by completing the OAuth2 flow to get this user's ID. After this, other Discord users will be able to link their identities through the /link command, and unlink through the /unlink command. No OAuth2 flow is needed for users after the first, we will just link their Discord user id to their Sentry user.

Problem statement:
Implement Discord identity linking and unlinking via slash command
- [x] Add commands to `IntegrationProvider.setup()`
- [x] Implement handler (see `Identity` model and how Slack does this)
- [x] Make sure handler is called when we receive the relevant events at our interactions endpoint

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: Merge branch 'master' into spalmurray/discord-identity-linking

Patch to review:
diff --git a/src/sentry/identity/__init__.py b/src/sentry/identity/__init__.py
index cd7533cbeebc8a..10f0d19a9adafe 100644
--- a/src/sentry/identity/__init__.py
+++ b/src/sentry/identity/__init__.py
@@ -1,5 +1,6 @@
 from .base import *  # NOQA
 from .bitbucket import *  # NOQA
+from .discord import *  # NOQA
 from .github import *  # NOQA
 from .github_enterprise import *  # NOQA
 from .gitlab import *  # NOQA
@@ -30,3 +31,4 @@
 register(BitbucketIdentityProvider)  # NOQA
 register(GitlabIdentityProvider)  # NOQA
 register(GoogleIdentityProvider)  # NOQA
+register(DiscordIdentityProvider)  # NOQA
diff --git a/src/sentry/identity/discord/__init__.py b/src/sentry/identity/discord/__init__.py
new file mode 100644
index 00000000000000..c38d82c019ceeb
--- /dev/null
+++ b/src/sentry/identity/discord/__init__.py
@@ -0,0 +1 @@
+from .provider import *  # noqa: F401,F403
diff --git a/src/sentry/identity/discord/provider.py b/src/sentry/identity/discord/provider.py
new file mode 100644
index 00000000000000..75b9c281f3ac65
--- /dev/null
+++ b/src/sentry/identity/discord/provider.py
@@ -0,0 +1,6 @@
+from sentry.auth.provider import Provider
+
+
+class DiscordIdentityProvider(Provider):
+    key = "discord"
+    name = "Discord"
diff --git a/src/sentry/integrations/discord/__init__.py b/src/sentry/integrations/discord/__init__.py
index 000d9723ed1fda..984e00885a5e11 100644
--- a/src/sentry/integrations/discord/__init__.py
+++ b/src/sentry/integrations/discord/__init__.py
@@ -2,3 +2,4 @@
 from .integration import *  # noqa: F401,F403
 from .urls import *  # noqa: F401,F403
 from .utils import *  # noqa: F401,F403
+from .views import *  # noqa: F401,F403
diff --git a/src/sentry/integrations/discord/commands.py b/src/sentry/integrations/discord/commands.py
index 1e8953eceeef83..413eba46f193ab 100644
--- a/src/sentry/integrations/discord/commands.py
+++ b/src/sentry/integrations/discord/commands.py
@@ -14,8 +14,18 @@ class DiscordCommandManager:
     # See Discord docs for the structure of a command https://discord.com/developers/docs/interactions/application-commands#application-command-object
     COMMANDS: list[object] = [
         {
-            "name": "example",
-            "description": "An example slash command!",
+            "name": "link",
+            "description": "Link your Discord account to your Sentry account to perform actions on Sentry notifications.",
+            "type": 1,
+        },
+        {
+            "name": "unlink",
+            "description": "Unlink your Discord account from your Sentry account.",
+            "type": 1,
+        },
+        {
+            "name": "help",
+            "description": "View a list of Sentry bot commands and what they do",
             "type": 1,
         },
     ]
diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py
index 5e71bfce220433..7d50f0e812deb0 100644
--- a/src/sentry/integrations/discord/integration.py
+++ b/src/sentry/integrations/discord/integration.py
@@ -2,6 +2,7 @@
 
 from collections.abc import Mapping, Sequence
 
+import requests
 from django.utils.translation import gettext_lazy as _
 
 from sentry import options
@@ -16,6 +17,7 @@
 from sentry.integrations.discord.client import DiscordClient
 from sentry.integrations.discord.commands import DiscordCommandManager
 from sentry.pipeline.views.base import PipelineView
+from sentry.shared_integrations.exceptions import IntegrationError
 from sentry.shared_integrations.exceptions.base import ApiError
 from sentry.utils.http import absolute_uri
 
@@ -97,7 +99,7 @@ class DiscordIntegrationProvider(IntegrationProvider):
     requires_feature_flag = True  # remove this when we remove the discord feature flag
 
     # https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes
-    oauth_scopes = frozenset(["applications.commands", "bot"])
+    oauth_scopes = frozenset(["applications.commands", "bot", "identify"])
 
     # Visit the bot tab of your app in the Discord developer portal for a tool to generate this
     bot_permissions = 2048
@@ -108,40 +110,85 @@ def __init__(self) -> None:
         self.application_id = options.get("discord.application-id")
         self.public_key = options.get("discord.public-key")
         self.bot_token = options.get("discord.bot-token")
+        self.client_secret = options.get("discord.client-secret")
+        self.client = DiscordClient()
+        self.setup_url = absolute_uri("extensions/discord/setup/")
         super().__init__()
 
     def get_pipeline_views(self) -> Sequence[PipelineView]:
-        return [DiscordInstallPipeline(self.get_bot_install_url())]
+        return [DiscordInstallPipeline(self._get_bot_install_url())]
 
     def build_integration(self, state: Mapping[str, object]) -> Mapping[str, object]:
         guild_id = str(state.get("guild_id"))
-        guild_name = self.get_guild_name(guild_id)
+        guild_name = self._get_guild_name(guild_id)
+        discord_user_id = self._get_discord_user_id(str(state.get("code")))
+
         return {
             "name": guild_name,
             "external_id": guild_id,
+            "user_identity": {
+                "type": "discord",
+                "external_id": discord_user_id,
+                "scopes": [],
+                "data": {},
+            },
         }
 
-    def get_guild_name(self, guild_id: str) -> str:
-        url = DiscordClient.GUILD_URL.format(guild_id=guild_id)
+    def setup(self) -> None:
+        if self._credentials_exist():
+            DiscordCommandManager().register_commands()
+
+    def _get_guild_name(self, guild_id: str) -> str:
+        url = self.client.GUILD_URL.format(guild_id=guild_id)
         headers = {"Authorization": f"Bot {self.bot_token}"}
         try:
-            response = DiscordClient().get(url, headers=headers)
-            guild_name = response["name"]  # type:ignore
-        except ApiError:
+            response = self.client.get(url, headers=headers)
+            return response["name"]  # type: ignore
+        except (ApiError, AttributeError):
             return guild_id
-        return guild_name
 
-    def get_bot_install_url(self):
-        setup_url = absolute_uri("extensions/discord/setup/")
+    def _get_discord_user_id(self, auth_code: str) -> str:
+        """
+        Helper function for completing the oauth2 flow and grabbing the
+        installing user's Discord user id so we can link their identities.
 
-        return f"https://discord.com/api/oauth2/authorize?client_id={self.application_id}&permissions={self.bot_permissions}&redirect_uri={setup_url}&response_type=code&scope={' '.join(self.oauth_scopes)}"
+        We don't keep the granted token beyond this function because we don't
+        need it.
 
-    def setup(self) -> None:
-        if self._credentials_exist():
-            DiscordCommandManager().register_commands()
+        If something goes wrong with this we will throw an error because we
+        need an initial identity to configure the identity provider for this
+        integration.
+
+        """
+        form_data = f"client_id={self.application_id}&client_secret={self.client_secret}&grant_type=authorization_code&code={auth_code}&redirect_uri={self.setup_url}"
+        headers = {
+            "Content-Type": "application/x-www-form-urlencoded",
+        }
+
+        try:
+            response = self.client.post(
+                "https://discord.com/api/v10/oauth2/token",
+                json=False,
+                data=form_data,
+                headers=headers,
+            )
+            token = response["access_token"]  # type: ignore
+
+            headers = {"Authorization": f"Bearer {token}"}
+
+            # Can't use self.client.get because that will overwrite our headers
+            # with our bot's authorization
+            response = requests.get(f"{self.client.base_url}/users/@me", headers=headers)
+            return response.json()["id"]
+        except (ApiError, KeyError):
+            logger.error("discord.install.failed_to_get_discord_user_id")
+            raise IntegrationError("Could not retrieve Discord user information.")
+
+    def _get_bot_install_url(self):
+        return f"https://discord.com/api/oauth2/authorize?client_id={self.application_id}&permissions={self.bot_permissions}&redirect_uri={self.setup_url}&response_type=code&scope={' '.join(self.oauth_scopes)}"
 
     def _credentials_exist(self) -> bool:
-        return self.application_id and self.public_key and self.bot_token
+        return all((self.application_id, self.public_key, self.bot_token, self.client_secret))
 
 
 class DiscordInstallPipeline(PipelineView):
@@ -150,8 +197,9 @@ def __init__(self, install_url: str):
         super().__init__()
 
     def dispatch(self, request, pipeline):
-        if "guild_id" in request.GET:
-            pipeline.bind_state("guild_id", request.GET["guild_id"])
-            return pipeline.next_step()
+        if "guild_id" not in request.GET or "code" not in request.GET:
+            return self.redirect(self.install_url)
 
-        return self.redirect(self.install_url)
+        pipeline.bind_state("guild_id", request.GET["guild_id"])
+        pipeline.bind_state("code", request.GET["code"])
+        return pipeline.next_step()
diff --git a/src/sentry/integrations/discord/requests/base.py b/src/sentry/integrations/discord/requests/base.py
index bf5660d9ca6559..68fd2ca812ef36 100644
--- a/src/sentry/integrations/discord/requests/base.py
+++ b/src/sentry/integrations/discord/requests/base.py
@@ -7,7 +7,11 @@
 from rest_framework.request import Request
 
 from sentry import options
+from sentry.services.hybrid_cloud.identity.model import RpcIdentity
+from sentry.services.hybrid_cloud.identity.service import identity_service
 from sentry.services.hybrid_cloud.integration import RpcIntegration, integration_service
+from sentry.services.hybrid_cloud.user.model import RpcUser
+from sentry.services.hybrid_cloud.user.service import user_service
 
 from ..utils import logger, verify_signature
 
@@ -22,6 +26,11 @@ class DiscordRequestError(Exception):
     status: int
 
 
+class DiscordRequestTypes:
+    PING = 1
+    COMMAND = 2
+
+
 class DiscordRequest:
     """
     A Request from Discord to our interactions endpoint.
@@ -36,6 +45,8 @@ def __init__(self, request: Request):
         self.request = request
         self._integration: RpcIntegration | None = None
         self._data: Mapping[str, object] = {}
+        self._identity: RpcIdentity | None = None
+        self.user: RpcUser | None = None
 
     @property
     def integration(self) -> RpcIntegration | None:
@@ -57,6 +68,13 @@ def channel_id(self) -> str | None:
         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
+        except (AttributeError, TypeError):
+            return None
+
     @property
     def logging_data(self) -> Mapping[str, str | int]:
         # TODO: come back to this later and see what additional metadata makes sense to include here
@@ -67,6 +85,8 @@ def logging_data(self) -> Mapping[str, str | int]:
 
         if self.integration:
             data["integration_id"] = self.integration.id
+        if self.user_id:
+            data["discord_user_id"] = self.user_id
 
         return {k: v for k, v in data.items() if v}
 
@@ -75,6 +95,7 @@ def validate(self) -> None:
         self._log_request()
         self.authorize()
         self.validate_integration()
+        self._validate_identity()
 
     def authorize(self) -> None:
         public_key: str = options.get("discord.public-key")
@@ -85,7 +106,6 @@ def authorize(self) -> None:
         if signature and timestamp and verify_signature(public_key, signature, timestamp + body):
             return
 
-        self._info("discord.interactions.auth")
         raise DiscordRequestError(status=status.HTTP_401_UNAUTHORIZED)
 
     def _validate_data(self) -> None:
@@ -94,11 +114,40 @@ def _validate_data(self) -> None:
         except (ValueError, TypeError):
             raise DiscordRequestError(status=status.HTTP_400_BAD_REQUEST)
 
+    def _validate_identity(self) -> None:
+        self.user = self.get_identity_user()
+
+    def get_identity_user(self) -> RpcUser | None:
+        identity = self.get_identity()
+        if not identity:
+            return None
+        return user_service.get_user(identity.user_id)
+
+    def get_identity(self) -> RpcIdentity | None:
+        if not self._identity:
+            provider = identity_service.get_provider(
+                provider_type="discord", provider_ext_id=self.guild_id
+            )
+            self._identity = (
+                identity_service.get_identity(
+                    filter={"provider_id": provider.id, "identity_ext_id": self.user_id}
+                )
+                if provider
+                else None
+            )
+        return self._identity
+
+    def get_identity_str(self) -> str | None:
+        return self.user.email if self.user else None
+
     def validate_integration(self) -> None:
         self._integration = integration_service.get_integration(
             provider="discord", external_id=self.guild_id
         )
 
+    def has_identity(self) -> bool:
+        return self.user is not None
+
     def _log_request(self) -> None:
         self._info("discord.request")
 
@@ -109,8 +158,12 @@ def _error(self, key: str) -> None:
         logger.error(key, extra={**self.logging_data})
 
     def is_ping(self) -> bool:
-        # TODO: Make these constants an enum like DISCORD_REQUEST_TYPE.PING
-        return self._data.get("type", 0) == 1
+        return self._data.get("type", 0) == DiscordRequestTypes.PING
 
     def is_command(self) -> bool:
-        return self._data.get("type", 0) == 2
+        return self._data.get("type", 0) == DiscordRequestTypes.COMMAND
+
+    def get_command_name(self) -> str:
+        if not self.is_command():
+            return ""
+        return self._data.get("data")["name"]  # type: ignore
diff --git a/src/sentry/integrations/discord/urls.py b/src/sentry/integrations/discord/urls.py
index f0c764c5f2d7f9..3f18974c8fa83d 100644
--- a/src/sentry/integrations/discord/urls.py
+++ b/src/sentry/integrations/discord/urls.py
@@ -1,5 +1,7 @@
 from django.urls import re_path
 
+from .views.link_identity import DiscordLinkIdentityView
+from .views.unlink_identity import DiscordUnlinkIdentityView
 from .webhooks.base import DiscordInteractionsEndpoint
 
 urlpatterns = [
@@ -7,5 +9,15 @@
         r"^interactions/$",
         DiscordInteractionsEndpoint.as_view(),
         name="sentry-integration-discord-interactions",
-    )
+    ),
+    re_path(
+        r"link-identity/(?P<signed_params>[^\/]+)/$",
+        DiscordLinkIdentityView.as_view(),
+        name="sentry-integration-discord-link-identity",
+    ),
+    re_path(
+        r"unlink-identity/(?P<signed_params>[^\/]+)/$",
+        DiscordUnlinkIdentityView.as_view(),
+        name="sentry-integration-discord-unlink-identity",
+    ),
 ]
diff --git a/src/sentry/integrations/discord/views/__init__.py b/src/sentry/integrations/discord/views/__init__.py
new file mode 100644
index 00000000000000..bd536d345d157f
--- /dev/null
+++ b/src/sentry/integrations/discord/views/__init__.py
@@ -0,0 +1,2 @@
+from .link_identity import *  # noqa: F401,F403
+from .unlink_identity import *  # noqa: F401,F403
diff --git a/src/sentry/integrations/discord/views/link_identity.py b/src/sentry/integrations/discord/views/link_identity.py
new file mode 100644
index 00000000000000..967d06521f0608
--- /dev/null
+++ b/src/sentry/integrations/discord/views/link_identity.py
@@ -0,0 +1,58 @@
+from django.core.signing import BadSignature, SignatureExpired
+from django.http import HttpResponse
+from django.urls import reverse
+from django.views.decorators.cache import never_cache
+from rest_framework.request import Request
+
+from sentry.integrations.utils.identities import get_identity_or_404
+from sentry.models.identity import Identity
+from sentry.services.hybrid_cloud.integration.model import RpcIntegration
+from sentry.types.integrations import ExternalProviders
+from sentry.utils.http import absolute_uri
+from sentry.utils.signing import sign, unsign
+from sentry.web.decorators import transaction_start
+from sentry.web.frontend.base import BaseView
+from sentry.web.helpers import render_to_response
+
+
+def build_linking_url(integration: RpcIntegration, discord_id: str) -> str:
+    endpoint = "sentry-integration-discord-link-identity"
+    kwargs = {
+        "discord_id": discord_id,
+        "integration_id": integration.id,
+    }
+    return absolute_uri(reverse(endpoint, kwargs={"signed_params": sign(**kwargs)}))
+
+
+class DiscordLinkIdentityView(BaseView):
+    """
+    Django view for linking user to Discord account.
+    """
+
+    @transaction_start("DiscordLinkIdentityView")
+    @never_cache
+    def handle(self, request: Request, signed_params: str) -> HttpResponse:
+        try:
+            params = unsign(signed_params)
+        except (SignatureExpired, BadSignature):
+            return render_to_response("sentry/integrations/discord/expired-link.html")
+
+        organization, integration, idp = get_identity_or_404(
+            ExternalProviders.DISCORD,
+            request.user,
+            integration_id=params["integration_id"],
+        )
+
+        if request.method != "POST":
+            return render_to_response(
+                "sentry/auth-link-identity.html",
+                request=request,
+                context={"organization": organization, "provider": integration.get_provider()},
+            )
+
+        Identity.objects.link_identity(user=request.user, idp=idp, external_id=params["discord_id"])  # type: ignore
+
+        return render_to_response(
+            "sentry/integrations/discord/linked.html",
+            request=request,
+        )
diff --git a/src/sentry/integrations/discord/views/unlink_identity.py b/src/sentry/integrations/discord/views/unlink_identity.py
new file mode 100644
index 00000000000000..5314b28b02c957
--- /dev/null
+++ b/src/sentry/integrations/discord/views/unlink_identity.py
@@ -0,0 +1,65 @@
+from django.core.signing import BadSignature, SignatureExpired
+from django.db import IntegrityError
+from django.http import Http404, HttpResponse
+from django.urls import reverse
+from django.views.decorators.cache import never_cache
+from rest_framework.request import Request
+
+from sentry.integrations.utils.identities import get_identity_or_404
+from sentry.models.identity import Identity
+from sentry.services.hybrid_cloud.integration.model import RpcIntegration
+from sentry.types.integrations import ExternalProviders
+from sentry.utils.http import absolute_uri
+from sentry.utils.signing import sign, unsign
+from sentry.web.decorators import transaction_start
+from sentry.web.frontend.base import BaseView
+from sentry.web.helpers import render_to_response
+
+from ..utils import logger
+
+
+def build_unlinking_url(integration: RpcIntegration, discord_id: str) -> str:
+    endpoint = "sentry-integration-discord-unlink-identity"
+    kwargs = {
+        "discord_id": discord_id,
+        "integration_id": integration.id,
+    }
+    return absolute_uri(reverse(endpoint, kwargs={"signed_params": sign(**kwargs)}))
+
+
+class DiscordUnlinkIdentityView(BaseView):
+    """
+    Django view for unlinking user from Discord account.
+    """
+
+    @transaction_start("DiscordUnlinkIdentityView")
+    @never_cache
+    def handle(self, request: Request, signed_params: str) -> HttpResponse:
+        try:
+            params = unsign(signed_params)
+        except (SignatureExpired, BadSignature):
+            return render_to_response("sentry/integrations/discord/expired-link.html")
+
+        organization, integration, idp = get_identity_or_404(
+            ExternalProviders.DISCORD,
+            request.user,
+            integration_id=params["integration_id"],
+        )
+
+        if request.method != "POST":
+            return render_to_response(
+                "sentry/auth-unlink-identity.html",
+                request=request,
+                context={"organization": organization, "provider": integration.get_provider()},
+            )
+
+        try:
+            Identity.objects.filter(idp_id=idp.id, external_id=params["discord_id"]).delete()
+        except IntegrityError:
+            logger.exception("discord.unlink.integrity-error")
+            raise Http404
+
+        return render_to_response(
+            "sentry/integrations/discord/unlinked.html",
+            request=request,
+        )
diff --git a/src/sentry/integrations/discord/webhooks/base.py b/src/sentry/integrations/discord/webhooks/base.py
index 7530a96d09b044..beeb6e51a8f185 100644
--- a/src/sentry/integrations/discord/webhooks/base.py
+++ b/src/sentry/integrations/discord/webhooks/base.py
@@ -1,11 +1,34 @@
+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.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.
+"""
+
 
 @region_silo_endpoint
 class DiscordInteractionsEndpoint(Endpoint):
@@ -20,31 +43,105 @@ class DiscordInteractionsEndpoint(Endpoint):
     discord_request_class = DiscordRequest
     provider = "discord"
 
+    def __init__(self) -> None:
+        super().__init__()
+        self.discord_request: DiscordRequest
+
     @csrf_exempt
     @transaction_start("DiscordInteractionsEndpoint")
     def post(self, request: Request) -> Response:
         try:
-            discord_request = self.discord_request_class(request)
-            discord_request.validate()
+            self.discord_request = self.discord_request_class(request)
+            self.discord_request.validate()
+
+            if self.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)
+
+            elif self.discord_request.is_command():
+                return self.handle_command()
+
         except DiscordRequestError as e:
             return self.respond(status=e.status)
 
-        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)
-
-        elif discord_request.is_command():
-            # Temporary response to example command
-            return self.respond(
-                {
-                    "type": 4,
-                    "data": {
-                        "content": "howdy",
-                    },
-                },
-                status=200,
-            )
-
         # This isn't an interaction type that we need to worry about, so we'll
         # 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/templates/sentry/integrations/discord/expired-link.html b/src/sentry/templates/sentry/integrations/discord/expired-link.html
new file mode 100644
index 00000000000000..cad42d66b34cda
--- /dev/null
+++ b/src/sentry/templates/sentry/integrations/discord/expired-link.html
@@ -0,0 +1,14 @@
+{% extends "sentry/bases/modal.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Discord Expired Link" %} | {{ block.super }}{% endblock %}
+{% block wrapperclass %}narrow auth{% endblock %}
+
+{% block main %}
+  <div class="align-center">
+    <p>
+      {% trans "Your link is expired. Please try using the link command in Discord again." %}
+    </p>
+  </div>
+{% endblock %}
diff --git a/src/sentry/templates/sentry/integrations/discord/linked.html b/src/sentry/templates/sentry/integrations/discord/linked.html
new file mode 100644
index 00000000000000..0e53d76977eed7
--- /dev/null
+++ b/src/sentry/templates/sentry/integrations/discord/linked.html
@@ -0,0 +1,14 @@
+{% extends "sentry/bases/modal.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Discord Linked" %} | {{ block.super }}{% endblock %}
+{% block wrapperclass %}narrow auth{% endblock %}
+
+{% block main %}
+  <div class="align-center">
+    <p>
+      {% trans "Your Discord account has been associated with your Sentry account. You may now take actions through Discord!" %}
+    </p>
+  </div>
+{% endblock %}
diff --git a/src/sentry/templates/sentry/integrations/discord/unlinked.html b/src/sentry/templates/sentry/integrations/discord/unlinked.html
new file mode 100644
index 00000000000000..2ed610208b53f4
--- /dev/null
+++ b/src/sentry/templates/sentry/integrations/discord/unlinked.html
@@ -0,0 +1,14 @@
+{% extends "sentry/bases/modal.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Discord Unlinked" %} | {{ block.super }}{% endblock %}
+{% block wrapperclass %}narrow auth{% endblock %}
+
+{% block main %}
+  <div class="align-center">
+    <p>
+      {% trans "Your Discord account has been unlinked from your Sentry account." %}
+    </p>
+  </div>
+{% endblock %}
diff --git a/src/sentry/types/integrations.py b/src/sentry/types/integrations.py
index 8e1fd3b23b74fe..d5c632f102e1bb 100644
--- a/src/sentry/types/integrations.py
+++ b/src/sentry/types/integrations.py
@@ -11,6 +11,7 @@ class ExternalProviders(ValueEqualityEnum):
     SLACK = 110
     MSTEAMS = 120
     PAGERDUTY = 130
+    DISCORD = 140
     GITHUB = 200
     GITLAB = 210
 
@@ -27,6 +28,7 @@ def name(self) -> Optional[str]:
     ExternalProviders.SLACK: "slack",
     ExternalProviders.MSTEAMS: "msteams",
     ExternalProviders.PAGERDUTY: "pagerduty",
+    ExternalProviders.DISCORD: "discord",
     ExternalProviders.GITHUB: "github",
     ExternalProviders.GITLAB: "gitlab",
     ExternalProviders.CUSTOM: "custom_scm",
diff --git a/tests/sentry/integrations/discord/test_integration.py b/tests/sentry/integrations/discord/test_integration.py
index 433204ac73b18e..25d8242860309d 100644
--- a/tests/sentry/integrations/discord/test_integration.py
+++ b/tests/sentry/integrations/discord/test_integration.py
@@ -1,6 +1,7 @@
 from unittest import mock
 from urllib.parse import parse_qs, urlencode, urlparse
 
+import pytest
 import responses
 
 from sentry import audit_log, options
@@ -8,11 +9,10 @@
 from sentry.integrations.discord.integration import DiscordIntegrationProvider
 from sentry.models.auditlogentry import AuditLogEntry
 from sentry.models.integrations.integration import Integration
+from sentry.shared_integrations.exceptions import IntegrationError
 from sentry.testutils import IntegrationTestCase
-from sentry.testutils.silo import control_silo_test
 
 
-@control_silo_test(stable=True)
 class DiscordIntegrationTest(IntegrationTestCase):
     provider = DiscordIntegrationProvider
 
@@ -21,14 +21,17 @@ def setUp(self):
         self.application_id = "application-id"
         self.public_key = "public-key"
         self.bot_token = "bot-token"
+        self.client_secret = "client-secret"
         options.set("discord.application-id", self.application_id)
-        options.set("discord.bot-token", self.bot_token)
         options.set("discord.public-key", self.public_key)
+        options.set("discord.bot-token", self.bot_token)
+        options.set("discord.client-secret", self.client_secret)
 
     def assert_setup_flow(
         self,
         guild_id="1234567890",
         server_name="Cool server",
+        auth_code="auth_code",
     ):
         responses.reset()
 
@@ -54,11 +57,25 @@ def assert_setup_flow(
                 "name": server_name,
             },
         )
+        responses.add(
+            responses.POST,
+            url="https://discord.com/api/v10/oauth2/token",
+            json={
+                "access_token": "access_token",
+            },
+        )
+        responses.add(
+            responses.GET, url=f"{DiscordClient.base_url}/users/@me", json={"id": "user_1234"}
+        )
 
-        resp = self.client.get("{}?{}".format(self.setup_path, urlencode({"guild_id": guild_id})))
+        resp = self.client.get(
+            "{}?{}".format(self.setup_path, urlencode({"guild_id": guild_id, "code": auth_code}))
+        )
 
-        mock_request = responses.calls[0].request
-        assert mock_request.headers["Authorization"] == f"Bot {self.bot_token}"
+        call_list = responses.calls
+        assert call_list[0].request.headers["Authorization"] == f"Bot {self.bot_token}"
+        assert f"code={auth_code}" in call_list[1].request.body
+        assert call_list[2].request.headers["Authorization"] == "Bearer access_token"
 
         assert resp.status_code == 200
         self.assertDialogSuccess(resp)
@@ -111,7 +128,7 @@ def test_get_guild_name(self):
             },
         )
 
-        resp = provider.get_guild_name(guild_id)
+        resp = provider._get_guild_name(guild_id)
         assert resp == "asdf"
         mock_request = responses.calls[0].request
         assert mock_request.headers["Authorization"] == f"Bot {self.bot_token}"
@@ -127,7 +144,7 @@ def test_get_guild_name_failure(self):
             status=500,
         )
 
-        resp = provider.get_guild_name(guild_id)
+        resp = provider._get_guild_name(guild_id)
         assert resp == "1234"
         mock_request = responses.calls[0].request
         assert mock_request.headers["Authorization"] == f"Bot {self.bot_token}"
@@ -181,3 +198,30 @@ def test_setup_cache(self):
 
         # Second provider.setup() should not update commands -> 1 call to API
         assert responses.assert_call_count(count=1, url=url)
+
+    @responses.activate
+    def test_get_discord_user_id(self):
+        provider = self.provider()
+        user_id = "user1234"
+
+        responses.add(
+            responses.POST,
+            url="https://discord.com/api/v10/oauth2/token",
+            json={
+                "access_token": "access_token",
+            },
+        )
+        responses.add(
+            responses.GET, url=f"{DiscordClient.base_url}/users/@me", json={"id": user_id}
+        )
+
+        result = provider._get_discord_user_id("auth_code")
+
+        assert result == user_id
+
+    @responses.activate
+    def test_get_discord_user_id_failure(self):
+        provider = self.provider()
+        responses.add(responses.POST, url="https://discord.com/api/v10/oauth2/token", status=500)
+        with pytest.raises(IntegrationError):
+            provider._get_discord_user_id("auth_code")
diff --git a/tests/sentry/integrations/discord/test_requests.py b/tests/sentry/integrations/discord/test_requests.py
index 743143c46d8423..dd7f216b83759e 100644
--- a/tests/sentry/integrations/discord/test_requests.py
+++ b/tests/sentry/integrations/discord/test_requests.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from unittest import mock
 from urllib.parse import urlencode
 
@@ -7,50 +9,59 @@
 from sentry.services.hybrid_cloud.integration.model import RpcIntegration
 from sentry.testutils.cases import TestCase
 from sentry.testutils.silo import control_silo_test
-from sentry.utils.cache import memoize
 
 
 @control_silo_test(stable=True)
 class DiscordRequestTest(TestCase):
-    def setUp(self):
-        super().setUp()
-
+    def mock_request(self, request_data: dict | None = None) -> DiscordRequest:
         self.request = mock.Mock()
-        self.request.data = {
-            "type": 1,
-            "guild_id": "guild-id",
-            "channel_id": "channel-id",
-        }
+        self.request.data = (
+            {
+                "type": 1,
+                "guild_id": "guild-id",
+                "channel_id": "channel-id",
+                "member": {
+                    "user": {
+                        "id": "user-id",
+                    },
+                },
+            }
+            if request_data is None
+            else request_data
+        )
         self.request.body = urlencode(self.request.data).encode()
         self.request.META = {
             "HTTP_X_SIGNATURE_ED25519": "signature",
             "HTTP_X_SIGNATURE_TIMESTAMP": "timestamp",
         }
-
-    @memoize
-    def discord_request(self):
         return DiscordRequest(self.request)
 
     def test_exposes_data(self):
-        assert self.discord_request.data["type"] == 1
+        discord_request = self.mock_request()
+        assert discord_request.data["type"] == 1
 
     def test_exposes_guild_id(self):
-        assert self.discord_request.guild_id == "guild-id"
+        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:
-            self.discord_request.validate()
+            discord_request.validate()
             assert e.value.status == 400
 
     def test_collects_logging_data(self):
-        assert self.discord_request.logging_data == {
+        discord_request = self.mock_request()
+        assert discord_request.logging_data == {
             "discord_guild_id": "guild-id",
             "discord_channel_id": "channel-id",
+            "discord_user_id": "user-id",
         }
 
     @mock.patch("sentry.integrations.discord.requests.base.integration_service.get_integration")
     def test_collects_logging_data_with_integration_id(self, mock_get_integration):
+        discord_request = self.mock_request()
         mock_get_integration.return_value = RpcIntegration(
             id=1,
             provider="discord",
@@ -59,15 +70,17 @@ def test_collects_logging_data_with_integration_id(self, mock_get_integration):
             metadata={},
             status=1,
         )
-        self.discord_request.validate_integration()
-        assert self.discord_request.logging_data == {
+        discord_request.validate_integration()
+        assert discord_request.logging_data == {
             "discord_guild_id": "guild-id",
             "discord_channel_id": "channel-id",
+            "discord_user_id": "user-id",
             "integration_id": 1,
         }
 
     @mock.patch("sentry.integrations.discord.requests.base.integration_service.get_integration")
     def test_validate_integration(self, mock_get_integration):
+        discord_request = self.mock_request()
         mock_get_integration.return_value = RpcIntegration(
             id=1,
             provider="discord",
@@ -76,13 +89,48 @@ def test_validate_integration(self, mock_get_integration):
             metadata={},
             status=1,
         )
-        self.discord_request.validate_integration()
+        discord_request.validate_integration()
         assert mock_get_integration.call_count == 1
-        assert self.discord_request.integration is not None
+        assert discord_request.integration is not None
 
     @mock.patch("sentry.integrations.discord.requests.base.integration_service.get_integration")
     def test_validate_integration_no_integration(self, mock_get_integration):
+        discord_request = self.mock_request()
         mock_get_integration.return_value = None
-        self.discord_request.validate_integration()
+        discord_request.validate_integration()
         assert mock_get_integration.call_count == 1
-        assert self.discord_request.integration is None
+        assert discord_request.integration is None
+
+    def test_get_command_name(self):
+        discord_request = self.mock_request(
+            {
+                "type": 2,
+                "guild_id": "guild-id",
+                "channel_id": "channel-id",
+                "data": {
+                    "name": "test_command",
+                },
+            }
+        )
+        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 == ""
+
+    def test_validate_identity_flow(self):
+        integration = self.create_integration(
+            self.organization, provider="discord", external_id="guild-id"
+        )
+        provider = self.create_identity_provider(integration=integration)
+        self.create_identity(user=self.user, identity_provider=provider, external_id="user-id")
+
+        discord_request = self.mock_request()
+        discord_request._validate_identity()
+        user = discord_request.get_identity_user()
+        assert user is not None
+        assert user.id == self.user.id
diff --git a/tests/sentry/integrations/discord/test_views.py b/tests/sentry/integrations/discord/test_views.py
new file mode 100644
index 00000000000000..fa6686eb618013
--- /dev/null
+++ b/tests/sentry/integrations/discord/test_views.py
@@ -0,0 +1,80 @@
+from unittest import mock
+
+from django.core.signing import SignatureExpired
+
+from sentry.integrations.discord.views.link_identity import build_linking_url
+from sentry.integrations.discord.views.unlink_identity import build_unlinking_url
+from sentry.models.identity import Identity, IdentityStatus
+from sentry.testutils.cases import TestCase
+from sentry.testutils.silo import control_silo_test
+
+
+class DiscordIntegrationLinkIdentityTestBase(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.login_as(self.user)
+        self.guild_id = "guild-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)
+
+
+@control_silo_test(stable=True)
+class DiscordIntegrationLinkIdentityTest(DiscordIntegrationLinkIdentityTestBase):
+    def test_basic_flow(self):
+        url = build_linking_url(self.discord_integration, self.discord_user_id)  # type: ignore
+        response = self.client.get(url)
+        assert response.status_code == 200
+        self.assertTemplateUsed(response, "sentry/auth-link-identity.html")
+
+        response = self.client.post(url)
+
+        assert response.status_code == 200
+        self.assertTemplateUsed(response, "sentry/integrations/discord/linked.html")
+
+        identity = Identity.objects.filter(external_id=self.discord_user_id, user=self.user)
+
+        assert len(identity) == 1
+        assert identity[0].idp == self.provider
+        assert identity[0].status == IdentityStatus.VALID
+
+    @mock.patch("sentry.integrations.discord.views.link_identity.unsign")
+    def test_expired_signature(self, mock_sign):
+        mock_sign.side_effect = SignatureExpired
+        url = build_linking_url(self.discord_integration, self.discord_user_id)  # type: ignore
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, "sentry/integrations/discord/expired-link.html")
+
+
+@control_silo_test(stable=True)
+class DiscordIntegrationUnlinkIdentityTest(DiscordIntegrationLinkIdentityTestBase):
+    def setUp(self):
+        super().setUp()
+        self.identity = self.create_identity(self.user, self.provider, self.discord_user_id)
+
+    def test_basic_flow(self):
+        url = build_unlinking_url(self.discord_integration, self.discord_user_id)  # type: ignore
+        response = self.client.get(url)
+        assert response.status_code == 200
+        self.assertTemplateUsed(response, "sentry/auth-unlink-identity.html")
+
+        response = self.client.post(url)
+
+        assert response.status_code == 200
+        self.assertTemplateUsed(response, "sentry/integrations/discord/unlinked.html")
+
+        assert not Identity.objects.filter(
+            external_id=self.discord_user_id, user=self.user
+        ).exists()
+
+    @mock.patch("sentry.integrations.discord.views.unlink_identity.unsign")
+    def test_expired_signature(self, mock_sign):
+        mock_sign.side_effect = SignatureExpired
+        url = build_unlinking_url(self.discord_integration, self.discord_user_id)  # type: ignore
+        response = self.client.get(url)
+        self.assertTemplateUsed(response, "sentry/integrations/discord/expired-link.html")
diff --git a/tests/sentry/integrations/discord/test_webhook.py b/tests/sentry/integrations/discord/test_webhook.py
index 2d736b35b902b3..f4d2a4cca024cc 100644
--- a/tests/sentry/integrations/discord/test_webhook.py
+++ b/tests/sentry/integrations/discord/test_webhook.py
@@ -1,5 +1,6 @@
 from unittest import mock
 
+from sentry.integrations.discord.requests.base import DiscordRequestTypes
 from sentry.testutils.cases import APITestCase
 from sentry.testutils.silo import region_silo_test
 
@@ -30,9 +31,7 @@ def test_command_interaction(self, mock_verify_signature):
         mock_verify_signature.return_value = True
         resp = self.client.post(
             path=WEBHOOK_URL,
-            data={
-                "type": 2,
-            },
+            data={"type": 2, "data": {"name": "command_name"}},
             format="json",
             HTTP_X_SIGNATURE_ED25519="signature",
             HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
@@ -94,3 +93,191 @@ def test_missing_timestamp(self):
         )
 
         assert resp.status_code == 401
+
+    def test_link_no_integration(self):
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "link",
+                    },
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+        assert resp.status_code == 400
+
+    def test_link_no_user_id(self):
+        guild_id = "guild-id"
+        self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=guild_id,
+            organization=self.organization,
+        )
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "link",
+                    },
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+        assert resp.status_code == 400
+
+    def test_link(self):
+        guild_id = "guild-id"
+        self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=guild_id,
+            organization=self.organization,
+        )
+
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "link",
+                    },
+                    "guild_id": guild_id,
+                    "channel_id": "channel-id",
+                    "member": {"user": {"id": "user1234"}},
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+
+        assert resp.status_code == 200
+
+    def test_link_already_linked(self):
+        guild_id = "guild-id"
+        discord_user_id = "user1234"
+        integration = self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=guild_id,
+            organization=self.organization,
+        )
+        provider = self.create_identity_provider(integration=integration)
+        self.create_identity(
+            user=self.user, identity_provider=provider, external_id=discord_user_id
+        )
+
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "link",
+                    },
+                    "guild_id": guild_id,
+                    "channel_id": "channel-id",
+                    "member": {
+                        "user": {
+                            "id": discord_user_id,
+                        }
+                    },
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+
+        assert resp.status_code == 200
+
+    def test_unlink_no_identity(self):
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "unlink",
+                    },
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+        assert resp.status_code == 200
+
+    def test_unlink(self):
+        guild_id = "guild-id"
+        discord_user_id = "user1234"
+        integration = self.create_integration(
+            provider="discord",
+            name="Cool server",
+            external_id=guild_id,
+            organization=self.organization,
+        )
+        provider = self.create_identity_provider(integration=integration)
+        self.create_identity(
+            user=self.user, identity_provider=provider, external_id=discord_user_id
+        )
+
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "unlink",
+                    },
+                    "guild_id": guild_id,
+                    "channel_id": "channel-id",
+                    "member": {
+                        "user": {
+                            "id": discord_user_id,
+                        }
+                    },
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+
+        assert resp.status_code == 200
+
+    def test_help(self):
+        with mock.patch(
+            "sentry.integrations.discord.requests.base.verify_signature", return_value=True
+        ):
+            resp = self.client.post(
+                path=WEBHOOK_URL,
+                data={
+                    "type": DiscordRequestTypes.COMMAND,
+                    "data": {
+                        "name": "help",
+                    },
+                },
+                format="json",
+                HTTP_X_SIGNATURE_ED25519="signature",
+                HTTP_X_SIGNATURE_TIMESTAMP="timestamp",
+            )
+
+        assert resp.status_code == 200


Code review comments:
diff_hunk: @@ -108,40 +110,85 @@ def __init__(self) -> None:
         self.application_id = options.get("discord.application-id")
         self.public_key = options.get("discord.public-key")
         self.bot_token = options.get("discord.bot-token")
+        self.client_secret = options.get("discord.client-secret")
+        self.client = DiscordClient()
+        self.setup_url = absolute_uri("extensions/discord/setup/")
         super().__init__()
 
     def get_pipeline_views(self) -> Sequence[PipelineView]:
-        return [DiscordInstallPipeline(self.get_bot_install_url())]
+        return [DiscordInstallPipeline(self._get_bot_install_url())]
 
     def build_integration(self, state: Mapping[str, object]) -> Mapping[str, object]:
         guild_id = str(state.get("guild_id"))
-        guild_name = self.get_guild_name(guild_id)
+        guild_name = self._get_guild_name(guild_id)
+        discord_user_id = self._get_discord_user_id(str(state.get("code")))
+
         return {
             "name": guild_name,
             "external_id": guild_id,
+            "user_identity": {
+                "type": "discord",
+                "external_id": discord_user_id,
+                "scopes": [],
+                "data": {},
+            },
         }
 
-    def get_guild_name(self, guild_id: str) -> str:
-        url = DiscordClient.GUILD_URL.format(guild_id=guild_id)
+    def setup(self) -> None:
+        if self._credentials_exist():
+            DiscordCommandManager().register_commands()
+
+    def _get_guild_name(self, guild_id: str) -> str:
+        url = self.client.GUILD_URL.format(guild_id=guild_id)
         headers = {"Authorization": f"Bot {self.bot_token}"}
         try:
-            response = DiscordClient().get(url, headers=headers)
-            guild_name = response["name"]  # type:ignore
-        except ApiError:
+            response = self.client.get(url, headers=headers)
+            return response["name"]  # type: ignore
+        except (ApiError, AttributeError):
             return guild_id
-        return guild_name
 
-    def get_bot_install_url(self):
-        setup_url = absolute_uri("extensions/discord/setup/")
+    def _get_discord_user_id(self, auth_code: str) -> str:
+        """
+        Helper function for completing the oauth2 flow and grabbing the
+        installing user's Discord user id so we can link their identities.
 
-        return f"https://discord.com/api/oauth2/authorize?client_id={self.application_id}&permissions={self.bot_permissions}&redirect_uri={setup_url}&response_type=code&scope={' '.join(self.oauth_scopes)}"
+        We don't keep the granted token beyond this function because we don't
+        need it.
 
-    def setup(self) -> None:
-        if self._credentials_exist():
-            DiscordCommandManager().register_commands()
+        If something goes wrong with this we will throw an error because we
+        need an initial identity to configure the identity provider for this
+        integration.
+
+        """
+        form_data = f"client_id={self.application_id}&client_secret={self.client_secret}&grant_type=authorization_code&code={auth_code}&redirect_uri={self.setup_url}"
+        headers = {
+            "Content-Type": "application/x-www-form-urlencoded",
+        }
+
+        try:
+            response = self.client.post(
+                "https://discord.com/api/v10/oauth2/token",
+                json=False,
+                data=form_data,
+                headers=headers,
+            )
+            token = response["access_token"]  # type: ignore
+
+            headers = {"Authorization": f"Bearer {token}"}
+
+            # Can't use self.client.get because that will overwrite our headers
+            # with our bot's authorization
+            response = requests.get(f"{self.client.base_url}/users/@me", headers=headers)
+            return response.json()["id"]
+        except (ApiError, KeyError):
+            logger.error("discord.install.failed_to_get_discord_user_id")
path: src/sentry/integrations/discord/integration.py
text: @user1:
Do we want to include any additional info about the error/request? you can include a dict `extra` like so:

```
logger.error(
            "discord.install.failed_to_get_discord_user_id",
            extra={
                "key": value
            },
        )
```