Browse Source

feat(discord): Implement Discord integration installation (#51574)

- 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
Spencer Murray 1 year ago
parent
commit
8ae826ca6f

+ 45 - 0
src/sentry/integrations/discord/client.py

@@ -0,0 +1,45 @@
+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.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/{guild_id}"
+
+    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.format(guild_id=guild_id))["name"]  # type:ignore

+ 51 - 4
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 gettext_lazy as _
 
+from sentry import options
 from sentry.integrations import (
     FeatureDescription,
     IntegrationFeatures,
@@ -11,7 +12,10 @@ from sentry.integrations import (
     IntegrationMetadata,
     IntegrationProvider,
 )
+from sentry.integrations.discord.client import DiscordClient
 from sentry.pipeline.views.base import PipelineView
+from sentry.shared_integrations.exceptions.base import ApiError
+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 +50,51 @@ 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 = self.get_guild_name(guild_id)
+        return {
+            "name": guild_name,
+            "external_id": guild_id,
+        }
+
+    def get_guild_name(self, guild_id: str) -> str | None:
+        bot_token = options.get("discord.bot-token")
+        url = DiscordClient.get_guild_url.format(guild_id=guild_id)
+        headers = {"Authorization": f"Bot {bot_token}"}
+        try:
+            response = DiscordClient().get(url, headers=headers)
+            guild_name = response["name"]  # type:ignore
+        except ApiError:
+            return None
+        return guild_name
+
+    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)

+ 3 - 0
src/sentry/integrations/discord/utils/__init__.py

@@ -0,0 +1,3 @@
+import logging
+
+logger = logging.getLogger("sentry.integrations.discord")

+ 2 - 2
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__`.

+ 0 - 0
tests/sentry/integrations/discord/__init__.py


+ 128 - 0
tests/sentry/integrations/discord/test_client.py

@@ -0,0 +1,128 @@
+import responses
+from django.test import override_settings
+
+from sentry import options
+from sentry.integrations.discord.client import DiscordClient
+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):
+    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=f"{DiscordClient.base_url}/",
+            json={},
+        )
+        self.discord_client.get("/")
+        request = responses.calls[0].request
+        assert request.headers["Authorization"] == f"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=f"{DiscordClient.base_url}{DiscordClient.get_guild_url.format(guild_id=guild_id)}",
+            json={
+                "id": guild_id,
+                "name": server_name,
+            },
+        )
+
+        guild_name = self.discord_client.get_guild_name(guild_id)
+        assert guild_name == "Cool server"
+
+
+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=f"{DiscordClient.base_url}{DiscordClient.get_guild_url.format(guild_id=self.integration.external_id)}",
+            json={"guild_id": "1234567890", "name": "Cool server"},
+            status=200,
+        )
+
+        responses.add(
+            method=responses.GET,
+            url=f"{control_address}{PROXY_BASE_PATH}{DiscordClient.get_guild_url.format(guild_id=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.format(guild_id=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.format(guild_id=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.format(guild_id=self.integration.external_id) in request.url
+            assert client.base_url not in request.url
+            client.assert_proxy_request(request, is_proxy=True)

+ 128 - 0
tests/sentry/integrations/discord/test_integration.py

@@ -0,0 +1,128 @@
+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=f"{DiscordClient.base_url}{DiscordClient.get_guild_url.format(guild_id=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"] == f"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"
+
+    @responses.activate
+    def test_get_guild_name(self):
+        provider = self.provider()
+        guild_id = "1234"
+        guild_name = "asdf"
+
+        responses.add(
+            responses.GET,
+            url=f"{DiscordClient.base_url}{DiscordClient.get_guild_url.format(guild_id=guild_id)}",
+            json={
+                "id": guild_id,
+                "name": guild_name,
+            },
+        )
+
+        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}"
+
+    @responses.activate
+    def test_get_guild_name_failure(self):
+        provider = self.provider()
+        guild_id = "1234"
+
+        responses.add(
+            responses.GET,
+            url=f"{DiscordClient.base_url}{DiscordClient.get_guild_url.format(guild_id=guild_id)}",
+            status=500,
+        )
+
+        resp = provider.get_guild_name(guild_id)
+        assert resp is None
+        mock_request = responses.calls[0].request
+        assert mock_request.headers["Authorization"] == f"Bot {self.bot_token}"