Repository: getsentry/sentry
Pull request: feat(discord): Implement Discord integration installation
- Build upon the `DiscordIntegrationProvider` to allow for installing the Discord bot from the integration details page
- Implement the beginnings of `DiscordClient` in order to fetch guild name at install time (extending `IntegrationProxyClient` and not `ApiClient`

Problem statement:
Create initial Discord API client structure
Should exist in `integrations/discord/client.py` and should extend `ApiClient`. See `msteams` client for reference.

This initial client implementation should be able to do some basic request to the Discord API using our credentials.

Implement Discord integration installation flow
Should mimic the flow of our Slack integration if possible. There is a place in the Discord developer portal for creating an install URL. There is a required field for redirect URL here - this should point to `/extensions/discord/setup/` or equivalent.

When this is implemented we should be able to install the Discord bot through the Sentry integrations page.

Hints from issue discussion:

Commit message: Add utils module with logger definition

Patch to review:
diff --git a/src/sentry/integrations/discord/client.py b/src/sentry/integrations/discord/client.py
new file mode 100644
index 00000000000000..0502bb4df25f8e
--- /dev/null
+++ b/src/sentry/integrations/discord/client.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from requests import PreparedRequest
+
+from sentry import options
+from sentry.services.hybrid_cloud.util import control_silo_function
+from sentry.shared_integrations.client.proxy import IntegrationProxyClient, infer_org_integration
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.shared_integrations.exceptions.base import ApiError
+from sentry.utils.json import JSONData
+
+from .utils import logger
+
+
+class DiscordClient(IntegrationProxyClient):
+    integration_name: str = "discord"
+    base_url: str = "https://discord.com/api/v10"
+
+    # https://discord.com/developers/docs/resources/guild#get-guild
+    GET_GUILD_URL = "/guilds/%s"
+
+    def __init__(
+        self,
+        integration_id: int | None = None,
+        org_integration_id: int | None = None,
+        verify_ssl: bool = True,
+        logging_context: JSONData | None = None,
+    ):
+        self.application_id = options.get("discord.application-id")
+        self.bot_token = options.get("discord.bot-token")
+        self.integration_id: int | None = integration_id
+        if not org_integration_id and integration_id is not None:
+            org_integration_id = infer_org_integration(
+                integration_id=integration_id, ctx_logger=logger
+            )
+        super().__init__(org_integration_id, verify_ssl, logging_context)
+
+    @control_silo_function
+    def authorize_request(self, prepared_request: PreparedRequest) -> PreparedRequest:
+        prepared_request.headers["Authorization"] = f"Bot {self.bot_token}"
+        return prepared_request
+
+    def get_guild_name(self, guild_id: str) -> str:
+        """
+        Normal version of get_guild_name that uses the regular auth flow.
+        """
+        return self.get(self.GET_GUILD_URL % guild_id)["name"]  # type:ignore
+
+    def _get_guild_name(self, guild_id: str) -> str:
+        """
+        This version of get_guild_name is meant to be used during the
+        integration install process where we don't yet have an integration
+        created.
+        """
+        # We need to manually add authorization
+        headers = {"Authorization": f"Bot {self.bot_token}"}
+        try:
+            response = self.get(self.GET_GUILD_URL % guild_id, headers=headers)
+        except ApiError:
+            raise IntegrationError("Could not retrieve Discord guild name")
+        return response["name"]  # type:ignore
diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py
index 150b716c4ae206..b4c2a6133b92ce 100644
--- a/src/sentry/integrations/discord/integration.py
+++ b/src/sentry/integrations/discord/integration.py
@@ -1,9 +1,10 @@
 from __future__ import annotations
 
-from typing import Any, Mapping, Sequence
+from collections.abc import Mapping, Sequence
 
 from django.utils.translation import ugettext_lazy as _
 
+from sentry import options
 from sentry.integrations import (
     FeatureDescription,
     IntegrationFeatures,
@@ -11,7 +12,9 @@
     IntegrationMetadata,
     IntegrationProvider,
 )
+from sentry.integrations.discord.client import DiscordClient
 from sentry.pipeline.views.base import PipelineView
+from sentry.utils.http import absolute_uri
 
 DESCRIPTION = "Discord’s your place to collaborate, share, and just talk about your day – or commiserate about app errors. Connect Sentry to your Discord server and get [alerts](https://docs.sentry.io/product/alerts/alert-types/) in a channel of your choice or via direct message when sh%t hits the fan."
 
@@ -46,8 +49,40 @@ class DiscordIntegrationProvider(IntegrationProvider):
     features = frozenset([IntegrationFeatures.CHAT_UNFURL])
     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"])
+
+    # Visit the bot tab of your app in the Discord developer portal for a tool to generate this
+    bot_permissions = 2048
+
+    setup_dialog_config = {"width": 600, "height": 900}
+
     def get_pipeline_views(self) -> Sequence[PipelineView]:
-        return super().get_pipeline_views()
+        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 = DiscordClient()._get_guild_name(guild_id)
+        return {
+            "name": guild_name,
+            "external_id": guild_id,
+        }
+
+    def get_bot_install_url(self):
+        application_id = options.get("discord.application-id")
+        setup_url = absolute_uri("extensions/discord/setup/")
+
+        return f"https://discord.com/api/oauth2/authorize?client_id={application_id}&permissions={self.bot_permissions}&redirect_uri={setup_url}&response_type=code&scope={' '.join(self.oauth_scopes)}"
+
+
+class DiscordInstallPipeline(PipelineView):
+    def __init__(self, install_url: str):
+        self.install_url = install_url
+        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()
 
-    def build_integration(self, state: Mapping[str, Any]) -> Mapping[str, Any]:
-        return super().build_integration(state)
+        return self.redirect(self.install_url)
diff --git a/src/sentry/integrations/discord/utils/__init__.py b/src/sentry/integrations/discord/utils/__init__.py
new file mode 100644
index 00000000000000..e1da300bbd947a
--- /dev/null
+++ b/src/sentry/integrations/discord/utils/__init__.py
@@ -0,0 +1,3 @@
+import logging
+
+logger = logging.getLogger("sentry.integrations.discord")
diff --git a/src/sentry/shared_integrations/client/integration_proxy_client.md b/src/sentry/shared_integrations/client/integration_proxy_client.md
index 04fcb60ac22f34..69a7d87ed69eac 100644
--- a/src/sentry/shared_integrations/client/integration_proxy_client.md
+++ b/src/sentry/shared_integrations/client/integration_proxy_client.md
@@ -22,8 +22,8 @@ Ensuring an integration proxies its requests can be done with three steps:
 1. Replace the `ApiClient` base class with `IntegrationProxyClient`
 
 ```diff
-+ class ExampleApiClient(ApiClient):
-- class ExampleApiClient(IntegrationProxyClient):
+- class ExampleApiClient(ApiClient):
++ class ExampleApiClient(IntegrationProxyClient):
 ```
 
 2. Ensure all instances of the client pass in an `org_integration_id` on `__init__`.
diff --git a/tests/sentry/integrations/discord/__init__.py b/tests/sentry/integrations/discord/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tests/sentry/integrations/discord/test_client.py b/tests/sentry/integrations/discord/test_client.py
new file mode 100644
index 00000000000000..11ddcaa24771da
--- /dev/null
+++ b/tests/sentry/integrations/discord/test_client.py
@@ -0,0 +1,166 @@
+import responses
+from django.test import override_settings
+
+from sentry import options
+from sentry.integrations.discord.client import DiscordClient
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.silo.base import SiloMode
+from sentry.silo.util import PROXY_BASE_PATH, PROXY_OI_HEADER, PROXY_SIGNATURE_HEADER
+from sentry.testutils.cases import TestCase
+
+
+class DiscordClientTest(TestCase):
+    guild_name: DiscordClient
+
+    def setUp(self):
+        self.application_id = "application-id"
+        self.bot_token = "bot-token"
+        options.set("discord.application-id", self.application_id)
+        options.set("discord.bot-token", self.bot_token)
+        self.integration = self.create_integration(
+            organization=self.organization,
+            external_id="1234567890",
+            name="Cool server",
+            provider="discord",
+        )
+        self.discord_client = DiscordClient(self.integration.id)
+
+    @responses.activate
+    def test_authorize_request(self):
+        responses.add(
+            responses.GET,
+            url=DiscordClient.base_url + "/",
+            json={},
+        )
+        self.discord_client.get("/")
+        request = responses.calls[0].request
+        assert request.headers["Authorization"] == "Bot " + self.bot_token
+
+    @responses.activate
+    def test_get_guild_name(self):
+        guild_id = self.integration.external_id
+        server_name = self.integration.name
+
+        responses.add(
+            responses.GET,
+            url=DiscordClient.base_url + (DiscordClient.GET_GUILD_URL % guild_id),
+            json={
+                "id": guild_id,
+                "name": server_name,
+            },
+        )
+
+        guild_name = self.discord_client.get_guild_name(guild_id)
+        assert guild_name == "Cool server"
+
+    @responses.activate
+    def test_manual_get_guild_name(self):
+        guild_id = self.integration.external_id
+        server_name = self.integration.name
+
+        responses.add(
+            responses.GET,
+            url=DiscordClient.base_url + (DiscordClient.GET_GUILD_URL % guild_id),
+            json={
+                "id": guild_id,
+                "name": server_name,
+            },
+        )
+
+        guild_name = self.discord_client._get_guild_name(guild_id)
+        assert guild_name == "Cool server"
+
+    @responses.activate
+    def test_manual_get_guild_name_response_error(self):
+        guild_id = self.integration.external_id
+
+        responses.add(
+            responses.GET,
+            url=DiscordClient.base_url + (DiscordClient.GET_GUILD_URL % guild_id),
+            status=500,
+            json={"message": "aaaaaa"},
+        )
+
+        try:
+            self.discord_client._get_guild_name(guild_id)
+        except IntegrationError as e:
+            assert e.args[0] == "Could not retrieve Discord guild name"
+
+
+control_address = "http://controlserver"
+secret = "secret-has-6-letters"
+
+
+@override_settings(
+    SENTRY_CONTROL_ADDRESS=control_address,
+    SENTRY_SUBNET_SECRET=secret,
+)
+class DiscordProxyClientTest(TestCase):
+    def setUp(self):
+        self.integration = self.create_integration(
+            organization=self.organization,
+            provider="discord",
+            name="Cool server",
+            external_id="1234567890",
+        )
+        self.installation = self.integration.get_installation(organization_id=self.organization.id)
+        self.discord_client = DiscordClient(self.integration.id)
+
+    @responses.activate
+    def test_integration_proxy_is_active(self):
+        class DiscordProxyTestClient(DiscordClient):
+            _use_proxy_url_for_tests = True
+
+            def assert_proxy_request(self, request, is_proxy=True):
+                assert (PROXY_BASE_PATH in request.url) == is_proxy
+                assert (PROXY_OI_HEADER in request.headers) == is_proxy
+                assert (PROXY_SIGNATURE_HEADER in request.headers) == is_proxy
+                # The discord bot token shouldn't yet be in the request
+                assert ("Authorization" in request.headers) != is_proxy
+                if is_proxy:
+                    assert request.headers[PROXY_OI_HEADER] is not None
+
+        responses.add(
+            method=responses.GET,
+            url=DiscordClient.base_url + DiscordClient.GET_GUILD_URL % self.integration.external_id,
+            json={"guild_id": "1234567890", "name": "Cool server"},
+            status=200,
+        )
+
+        responses.add(
+            method=responses.GET,
+            url=control_address
+            + PROXY_BASE_PATH
+            + DiscordClient.GET_GUILD_URL % self.integration.external_id,
+            json={"guild_id": "1234567890", "name": "Cool server"},
+            status=200,
+        )
+
+        with override_settings(SILO_MODE=SiloMode.MONOLITH):
+            client = DiscordProxyTestClient(integration_id=self.integration.id)
+            client.get_guild_name(self.integration.external_id)
+            request = responses.calls[0].request
+
+            assert client.GET_GUILD_URL % self.integration.external_id in request.url
+            assert client.base_url in request.url
+            client.assert_proxy_request(request, is_proxy=False)
+
+        responses.calls.reset()
+        with override_settings(SILO_MODE=SiloMode.CONTROL):
+            client = DiscordProxyTestClient(integration_id=self.integration.id)
+            client.get_guild_name(self.integration.external_id)
+            request = responses.calls[0].request
+
+            assert client.GET_GUILD_URL % self.integration.external_id in request.url
+            assert client.base_url in request.url
+            client.assert_proxy_request(request, is_proxy=False)
+
+        responses.calls.reset()
+        with override_settings(SILO_MODE=SiloMode.REGION):
+            client = DiscordProxyTestClient(integration_id=self.integration.id)
+            client.get_guild_name(self.integration.external_id)
+            request = responses.calls[0].request
+
+            assert client.GET_GUILD_URL % self.integration.external_id in request.url
+            assert client.base_url not in request.url
+            client.assert_proxy_request(request, is_proxy=True)
diff --git a/tests/sentry/integrations/discord/test_integration.py b/tests/sentry/integrations/discord/test_integration.py
new file mode 100644
index 00000000000000..e320cc32c18e3a
--- /dev/null
+++ b/tests/sentry/integrations/discord/test_integration.py
@@ -0,0 +1,92 @@
+from urllib.parse import parse_qs, urlencode, urlparse
+
+import responses
+
+from sentry import audit_log, options
+from sentry.integrations.discord.client import DiscordClient
+from sentry.integrations.discord.integration import DiscordIntegrationProvider
+from sentry.models.auditlogentry import AuditLogEntry
+from sentry.models.integrations.integration import Integration
+from sentry.testutils import IntegrationTestCase
+
+
+class DiscordIntegrationTest(IntegrationTestCase):
+    provider = DiscordIntegrationProvider
+
+    def setUp(self):
+        super().setUp()
+        self.application_id = "application-id"
+        self.bot_token = "bot-token"
+        options.set("discord.application-id", self.application_id)
+        options.set("discord.bot-token", self.bot_token)
+
+    def assert_setup_flow(
+        self,
+        guild_id="1234567890",
+        server_name="Cool server",
+    ):
+        responses.reset()
+
+        resp = self.client.get(self.init_path)
+        assert resp.status_code == 302
+        redirect = urlparse(resp["Location"])
+        assert redirect.scheme == "https"
+        assert redirect.netloc == "discord.com"
+        assert redirect.path == "/api/oauth2/authorize"
+        params = parse_qs(redirect.query)
+        assert params["client_id"] == [self.application_id]
+        assert params["permissions"] == [str(self.provider.bot_permissions)]
+        assert params["redirect_uri"] == ["http://testserver/extensions/discord/setup/"]
+        assert params["response_type"] == ["code"]
+        scopes = self.provider.oauth_scopes
+        assert params["scope"] == [" ".join(scopes)]
+
+        responses.add(
+            responses.GET,
+            url=(DiscordClient.base_url + (DiscordClient.GET_GUILD_URL % guild_id)),
+            json={
+                "id": guild_id,
+                "name": server_name,
+            },
+        )
+
+        resp = self.client.get("{}?{}".format(self.setup_path, urlencode({"guild_id": guild_id})))
+
+        mock_request = responses.calls[0].request
+        assert mock_request.headers["Authorization"] == "Bot " + self.bot_token
+
+        assert resp.status_code == 200
+        self.assertDialogSuccess(resp)
+
+    @responses.activate
+    def test_bot_flow(self):
+        with self.tasks():
+            self.assert_setup_flow()
+
+        integration = Integration.objects.get(provider=self.provider.key)
+        assert integration.external_id == "1234567890"
+        assert integration.name == "Cool server"
+
+        audit_entry = AuditLogEntry.objects.get(event=audit_log.get_event_id("INTEGRATION_ADD"))
+        audit_log_event = audit_log.get(audit_entry.event)
+        assert (
+            audit_log_event.render(audit_entry)
+            == "installed Cool server for the discord integration"
+        )
+
+    @responses.activate
+    def test_multiple_integrations(self):
+        with self.tasks():
+            self.assert_setup_flow()
+        with self.tasks():
+            self.assert_setup_flow(guild_id="0987654321", server_name="Uncool server")
+
+        integrations = Integration.objects.filter(provider=self.provider.key).order_by(
+            "external_id"
+        )
+
+        assert integrations.count() == 2
+        assert integrations[0].external_id == "0987654321"
+        assert integrations[0].name == "Uncool server"
+        assert integrations[1].external_id == "1234567890"
+        assert integrations[1].name == "Cool server"


Code review comments:
diff_hunk: @@ -0,0 +1,166 @@
+import responses
+from django.test import override_settings
+
+from sentry import options
+from sentry.integrations.discord.client import DiscordClient
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.silo.base import SiloMode
+from sentry.silo.util import PROXY_BASE_PATH, PROXY_OI_HEADER, PROXY_SIGNATURE_HEADER
+from sentry.testutils.cases import TestCase
+
+
+class DiscordClientTest(TestCase):
+    guild_name: DiscordClient
+
+    def setUp(self):
+        self.application_id = "application-id"
+        self.bot_token = "bot-token"
+        options.set("discord.application-id", self.application_id)
+        options.set("discord.bot-token", self.bot_token)
+        self.integration = self.create_integration(
+            organization=self.organization,
+            external_id="1234567890",
+            name="Cool server",
+            provider="discord",
+        )
+        self.discord_client = DiscordClient(self.integration.id)
+
+    @responses.activate
+    def test_authorize_request(self):
+        responses.add(
+            responses.GET,
+            url=DiscordClient.base_url + "/",
path: tests/sentry/integrations/discord/test_client.py
text: @user1:
```suggestion
            url=f"{DiscordClient.base_url}/",
```

diff_hunk: @@ -0,0 +1,166 @@
+import responses
+from django.test import override_settings
+
+from sentry import options
+from sentry.integrations.discord.client import DiscordClient
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.silo.base import SiloMode
+from sentry.silo.util import PROXY_BASE_PATH, PROXY_OI_HEADER, PROXY_SIGNATURE_HEADER
+from sentry.testutils.cases import TestCase
+
+
+class DiscordClientTest(TestCase):
+    guild_name: DiscordClient
+
+    def setUp(self):
+        self.application_id = "application-id"
+        self.bot_token = "bot-token"
+        options.set("discord.application-id", self.application_id)
+        options.set("discord.bot-token", self.bot_token)
+        self.integration = self.create_integration(
+            organization=self.organization,
+            external_id="1234567890",
+            name="Cool server",
+            provider="discord",
+        )
+        self.discord_client = DiscordClient(self.integration.id)
+
+    @responses.activate
+    def test_authorize_request(self):
+        responses.add(
+            responses.GET,
+            url=DiscordClient.base_url + "/",
+            json={},
+        )
+        self.discord_client.get("/")
+        request = responses.calls[0].request
+        assert request.headers["Authorization"] == "Bot " + self.bot_token
path: tests/sentry/integrations/discord/test_client.py
text: @user1:
```suggestion
        assert request.headers["Authorization"] == f"Bot {self.bot_token}"
```

@user1:
there are a few more you can do, but I'll leave those for you :+1:

diff_hunk: @@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from requests import PreparedRequest
+
+from sentry import options
+from sentry.services.hybrid_cloud.util import control_silo_function
+from sentry.shared_integrations.client.proxy import IntegrationProxyClient, infer_org_integration
+from sentry.shared_integrations.exceptions import IntegrationError
+from sentry.shared_integrations.exceptions.base import ApiError
+from sentry.utils.json import JSONData
+
+from .utils import logger
+
+
+class DiscordClient(IntegrationProxyClient):
+    integration_name: str = "discord"
+    base_url: str = "https://discord.com/api/v10"
+
+    # https://discord.com/developers/docs/resources/guild#get-guild
+    GET_GUILD_URL = "/guilds/%s"
path: src/sentry/integrations/discord/client.py
text: @user1:
optional nit: This method of string formatting is not often used in Python3 since we have `.format()` now. You can use a method like [what we do for azure devops](https://github.com/getsentry/sentry/blob/master/src/sentry/integrations/vsts/client.py) to name the params of the URL, or implement something like `_get_guild_url(guild_id: str) -> str`

@author:
Thanks for mentioning! I hadn't see this formatting method before, but stuck with it based on other integrations. I'll make that change