Repository: getsentry/sentry

Issue:
Use a different endpoint for Slack lookup
We currently look up channels in Slack by querying all channels, iterating through them, and finding a matching name. However, this process is very bad because it requires hitting the API many times which leads to rate limit issues. Instead of trying to list conversations, we can try using the API for scheduling a message which takes in a channel name instead of a channel ID. If that doesn't produce an error that the channel isn't found, we immediately delete the scheduled message and mark the channel as found. Explanation on how that works: https://stackoverflow.com/a/66067708

Relevant code: https://github.com/getsentry/sentry/blob/master/src/sentry/integrations/slack/utils/channel.py#L101


We should hide all of this code behind a feature flag so we can test this out internally first.


Discussion:

Resolving patch:
diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py
--- a/src/sentry/conf/server.py
+++ b/src/sentry/conf/server.py
@@ -1650,6 +1650,8 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
     "organizations:sdk-crash-detection": False,
     # Enables commenting on PRs from the Sentry comment bot.
     "organizations:pr-comment-bot": False,
+    # Enables slack channel lookup via schedule message
+    "organizations:slack-use-new-lookup": False,
     # Adds additional filters and a new section to issue alert rules.
     "projects:alert-filters": True,
     # Enable functionality to specify custom inbound filters on events.
diff --git a/src/sentry/features/__init__.py b/src/sentry/features/__init__.py
--- a/src/sentry/features/__init__.py
+++ b/src/sentry/features/__init__.py
@@ -256,6 +256,7 @@
 default_manager.add("organizations:ds-sliding-window-org", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
 default_manager.add("organizations:pr-comment-bot", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 default_manager.add("organizations:ds-org-recalibration", OrganizationFeature, FeatureHandlerStrategy.INTERNAL)
+default_manager.add("organizations:slack-use-new-lookup", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 default_manager.add("organizations:sourcemaps-bundle-indexing", OrganizationFeature, FeatureHandlerStrategy.REMOTE)
 
 # Project scoped features
diff --git a/src/sentry/integrations/slack/utils/channel.py b/src/sentry/integrations/slack/utils/channel.py
--- a/src/sentry/integrations/slack/utils/channel.py
+++ b/src/sentry/integrations/slack/utils/channel.py
@@ -5,6 +5,7 @@
 
 from django.core.exceptions import ValidationError
 
+from sentry import features
 from sentry.integrations.slack.client import SlackClient
 from sentry.models import Integration, Organization
 from sentry.services.hybrid_cloud.integration import RpcIntegration
@@ -65,6 +66,9 @@ def get_channel_id(
     # This means some users are unable to create/update alert rules. To avoid this, we attempt
     # to find the channel id asynchronously if it takes longer than a certain amount of time,
     # which I have set as the SLACK_DEFAULT_TIMEOUT - arbitrarily - to 10 seconds.
+
+    if features.has("organizations:slack-use-new-lookup", organization):
+        return get_channel_id_with_timeout_new(integration, channel_name, timeout)
     return get_channel_id_with_timeout(integration, channel_name, timeout)
 
 
@@ -173,3 +177,114 @@ def get_channel_id_with_timeout(
             return id_data
 
     return prefix, None, False
+
+
+def get_channel_id_with_timeout_new(
+    integration: Integration | RpcIntegration,
+    name: Optional[str],
+    timeout: int,
+) -> Tuple[str, Optional[str], bool]:
+    """
+    Fetches the internal slack id of a channel using scheduled message.
+    :param integration: The slack integration
+    :param name: The name of the channel
+    :param timeout: Our self-imposed time limit.
+    :return: a tuple of three values
+        1. prefix: string (`"#"` or `"@"`)
+        2. channel_id: string or `None`
+        3. timed_out: boolean (whether we hit our self-imposed time limit)
+    """
+
+    payload = {
+        "exclude_archived": False,
+        "exclude_members": True,
+        "types": "public_channel,private_channel",
+    }
+
+    time_to_quit = time.time() + timeout
+
+    client = SlackClient(integration_id=integration.id)
+    id_data: Optional[Tuple[str, Optional[str], bool]] = None
+    found_duplicate = False
+    prefix = ""
+    channel_id = None
+    try:  # Check for channel
+        channel_id = check_for_channel(client, name)
+        prefix = "#"
+    except ApiError as e:
+        if str(e) != "channel_not_found":
+            raise e
+        # Check if user
+        while True:
+            cursor = ""
+            # Slack limits the response of `<list_type>.list` to 1000 channels
+            try:
+                items = client.get("/users.list", params=dict(payload, cursor=cursor, limit=1000))
+            except ApiRateLimitedError as e:
+                logger.info("rule.slack.user_list_rate_limited", extra={"error": str(e)})
+                raise e
+            except ApiError as e:
+                logger.info("rule.slack.user_list_api_error", extra={"error": str(e)})
+                return prefix, None, False
+
+            if not isinstance(items, dict):
+                continue
+
+            for c in items["members"]:
+                # The "name" field is unique (this is the username for users)
+                # so we return immediately if we find a match.
+                # convert to lower case since all names in Slack are lowercase
+                if name and c["name"].lower() == name.lower():
+                    prefix = "@"
+                    return prefix, c["id"], False
+                # If we don't get a match on a unique identifier, we look through
+                # the users' display names, and error if there is a repeat.
+                profile = c.get("profile")
+                if profile and profile.get("display_name") == name:
+                    if id_data:
+                        found_duplicate = True
+                    else:
+                        prefix = "@"
+                        id_data = (prefix, c["id"], False)
+
+            cursor = items.get("response_metadata", {}).get("next_cursor", None)
+            if time.time() > time_to_quit:
+                return prefix, None, True
+
+            if not cursor:
+                break
+
+        if found_duplicate:
+            raise DuplicateDisplayNameError(name)
+        elif id_data:
+            return id_data
+
+    return prefix, channel_id, False
+
+
+def check_for_channel(
+    client: SlackClient,
+    name: str,
+) -> str:
+
+    msg_response = client.post(
+        "/chat.scheduleMessage",
+        data={
+            "channel": name,
+            "text": "Sentry is verifying your channel is accessible for sending you alert rule notifications",
+            "post_at": int(time.time() + 500),
+        },
+    )
+
+    if "channel" in msg_response:
+        client.post(
+            "/chat.deleteScheduledMessage",
+            params=dict(
+                {
+                    "channel": msg_response["channel"],
+                    "scheduled_message_id": msg_response["scheduled_message_id"],
+                }
+            ),
+        )
+
+        return msg_response["channel"]


Test patch:
diff --git a/tests/sentry/integrations/slack/test_utils.py b/tests/sentry/integrations/slack/test_utils.py
--- a/tests/sentry/integrations/slack/test_utils.py
+++ b/tests/sentry/integrations/slack/test_utils.py
@@ -6,16 +6,30 @@
 from sentry.models import Integration
 from sentry.shared_integrations.exceptions import ApiRateLimitedError, DuplicateDisplayNameError
 from sentry.testutils import TestCase
-from sentry.testutils.helpers import install_slack
+from sentry.testutils.helpers import install_slack, with_feature
 from sentry.utils import json
 
 
-class GetChannelIdBotTest(TestCase):
+class GetChannelIdTest(TestCase):
+    def add_list_response(self, list_type, channels, result_name="channels"):
+        self.resp = responses.mock
+        self.resp.add(
+            method=responses.GET,
+            url="https://slack.com/api/%s.list" % list_type,
+            status=200,
+            content_type="application/json",
+            body=json.dumps({"ok": "true", result_name: channels}),
+        )
+
+
+class GetChannelIdBotTest(GetChannelIdTest):
     def setUp(self):
+
         self.resp = responses.mock
         self.resp.__enter__()
 
         self.integration = install_slack(self.event.project.organization)
+
         self.add_list_response(
             "conversations",
             [
@@ -38,15 +52,6 @@ def setUp(self):
     def tearDown(self):
         self.resp.__exit__(None, None, None)
 
-    def add_list_response(self, list_type, channels, result_name="channels"):
-        self.resp.add(
-            method=responses.GET,
-            url="https://slack.com/api/%s.list" % list_type,
-            status=200,
-            content_type="application/json",
-            body=json.dumps({"ok": "true", result_name: channels}),
-        )
-
     def run_valid_test(self, channel, expected_prefix, expected_id, timed_out):
         assert (expected_prefix, expected_id, timed_out) == get_channel_id(
             self.organization, self.integration, channel
@@ -73,7 +78,7 @@ def test_invalid_channel_selected(self):
         assert get_channel_id(self.organization, self.integration, "@fake-user")[1] is None
 
 
-class GetChannelIdErrorBotTest(TestCase):
+class GetChannelIdErrorBotTest(GetChannelIdTest):
     def setUp(self):
         self.resp = responses.mock
         self.resp.__enter__()
@@ -103,3 +108,118 @@ def test_rate_limiting(self):
         )
         with pytest.raises(ApiRateLimitedError):
             get_channel_id(self.organization, self.integration, "@user")
+
+
+class GetChannelIdFasterTest(GetChannelIdTest):
+    def setUp(self):
+        self.resp = responses.mock
+        self.resp.__enter__()
+
+        self.integration = install_slack(self.event.project.organization)
+
+    def tearDown(self):
+        self.resp.__exit__(None, None, None)
+
+    def add_msg_response(self, channel_id, result_name="channel"):
+
+        if channel_id == "channel_not_found":
+            bodydict = {"ok": False, "error": "channel_not_found"}
+        else:
+            bodydict = {"ok": True, result_name: channel_id, "scheduled_message_id": "Q1298393284"}
+
+        self.resp.add(
+            method=responses.POST,
+            url="https://slack.com/api/chat.scheduleMessage",
+            status=200,
+            content_type="application/json",
+            body=json.dumps(bodydict),
+        )
+
+    @with_feature("organizations:slack-use-new-lookup")
+    def run_valid_test(self, channel, expected_prefix, expected_id, timed_out):
+        assert (expected_prefix, expected_id, timed_out) == get_channel_id(
+            self.organization, self.integration, channel
+        )
+
+    def test_valid_channel_selected_new(self):
+        self.add_msg_response("m-c")
+        self.resp.add(
+            method=responses.POST,
+            url="https://slack.com/api/chat.deleteScheduledMessage",
+            status=200,
+            content_type="application/json",
+            body=json.dumps({"ok": True}),
+        )
+        self.run_valid_test("#My-Channel", CHANNEL_PREFIX, "m-c", False)
+
+    def test_valid_private_channel_selected_new(self):
+        self.add_msg_response("m-p-c")
+        self.resp.add(
+            method=responses.POST,
+            url="https://slack.com/api/chat.deleteScheduledMessage",
+            status=200,
+            content_type="application/json",
+            body=json.dumps({"ok": True}),
+        )
+        self.run_valid_test("#my-private-channel", CHANNEL_PREFIX, "m-p-c", False)
+
+    def test_valid_member_selected(self):
+        self.add_msg_response("channel_not_found")
+        self.add_list_response(
+            "users",
+            [
+                {"name": "first-morty", "id": "m", "profile": {"display_name": "Morty"}},
+                {"name": "other-user", "id": "o-u", "profile": {"display_name": "Jimbob"}},
+                {"name": "better_morty", "id": "bm", "profile": {"display_name": "Morty"}},
+            ],
+            result_name="members",
+        )
+        self.run_valid_test("@first-morty", MEMBER_PREFIX, "m", False)
+
+    def test_valid_member_selected_display_name(self):
+        self.add_msg_response("channel_not_found")
+        self.add_list_response(
+            "users",
+            [
+                {"name": "first-morty", "id": "m", "profile": {"display_name": "Morty"}},
+                {"name": "other-user", "id": "o-u", "profile": {"display_name": "Jimbob"}},
+                {"name": "better_morty", "id": "bm", "profile": {"display_name": "Morty"}},
+            ],
+            result_name="members",
+        )
+        self.run_valid_test("@Jimbob", MEMBER_PREFIX, "o-u", False)
+
+    @with_feature("organizations:slack-use-new-lookup")
+    def test_invalid_member_selected_display_name(self):
+        self.add_msg_response("channel_not_found")
+        self.add_list_response(
+            "users",
+            [
+                {"name": "first-morty", "id": "m", "profile": {"display_name": "Morty"}},
+                {"name": "other-user", "id": "o-u", "profile": {"display_name": "Jimbob"}},
+                {"name": "better_morty", "id": "bm", "profile": {"display_name": "Morty"}},
+            ],
+            result_name="members",
+        )
+        with pytest.raises(DuplicateDisplayNameError):
+            get_channel_id(self.organization, self.integration, "@Morty")
+
+    @with_feature("organizations:slack-use-new-lookup")
+    def test_invalid_channel_selected(self):
+        self.add_msg_response("channel_not_found")
+        assert get_channel_id(self.organization, self.integration, "#fake-channel")[1] is None
+        assert get_channel_id(self.organization, self.integration, "@fake-user")[1] is None
+
+    @with_feature("organizations:slack-use-new-lookup")
+    def test_rate_limiting(self):
+        """Should handle 429 from Slack when searching for channels"""
+        self.add_msg_response("channel_not_found")
+        self.resp.add(
+            method=responses.GET,
+            url="https://slack.com/api/users.list",
+            status=429,
+            content_type="application/json",
+            body=json.dumps({"ok": False, "error": "ratelimited"}),
+        )
+        with pytest.raises(ApiRateLimitedError):
+            get_channel_id(self.organization, self.integration, "@user")