Browse Source

feat(notifications): Slack Commands (#26569)

Marcos Gaeta 3 years ago
parent
commit
2b53964702

+ 1 - 0
mypy.ini

@@ -19,6 +19,7 @@ files = src/sentry/api/bases/external_actor.py,
         src/sentry/grouping/strategies/message.py,
         src/sentry/integrations/slack/message_builder/**/*.py,
         src/sentry/integrations/slack/requests/*.py,
+        src/sentry/integrations/slack/util/*.py,
         src/sentry/notifications/**/*.py,
         src/sentry/snuba/outcomes.py,
         src/sentry/snuba/query_subscription_consumer.py,

+ 1 - 1
src/sentry/integrations/slack/action_endpoint.py

@@ -4,8 +4,8 @@ from sentry.api.base import Endpoint
 from sentry.integrations.slack.client import SlackClient
 from sentry.integrations.slack.link_identity import build_linking_url
 from sentry.integrations.slack.message_builder.issues import build_group_attachment
-from sentry.integrations.slack.requests import SlackRequestError
 from sentry.integrations.slack.requests.action import SlackActionRequest
+from sentry.integrations.slack.requests.base import SlackRequestError
 from sentry.integrations.slack.unlink_identity import build_unlinking_url
 from sentry.models import ApiKey, Group, Identity, IdentityProvider, Project
 from sentry.shared_integrations.exceptions import ApiError

+ 43 - 0
src/sentry/integrations/slack/command_endpoint.py

@@ -0,0 +1,43 @@
+import logging
+from typing import Mapping
+
+from django.http import HttpResponse
+from rest_framework.request import Request
+
+from sentry.api.base import Endpoint
+from sentry.integrations.slack.message_builder.help import SlackHelpMessageBuilder
+from sentry.integrations.slack.requests.base import SlackRequestError
+from sentry.integrations.slack.requests.command import SlackCommandRequest
+
+logger = logging.getLogger("sentry.integrations.slack")
+
+
+def get_command(payload: Mapping[str, str]) -> str:
+    return payload.get("text", "").split(" ")[0].lower()
+
+
+class SlackCommandsEndpoint(Endpoint):
+    authentication_classes = ()
+    permission_classes = ()
+
+    def post(self, request: Request) -> HttpResponse:
+        """
+        All Slack commands are handled by this endpoint. This block just
+        validates the request and dispatches it to the right handler.
+        """
+        try:
+            slack_request = SlackCommandRequest(request)
+            slack_request.validate()
+        except SlackRequestError as e:
+            return self.respond(status=e.status)
+
+        payload = slack_request.data
+        command = get_command(payload)
+
+        if command in ["help", ""]:
+            return self.respond(SlackHelpMessageBuilder().build())
+
+        # TODO(mgaeta): Add more commands.
+
+        # If we cannot interpret the command, print help text.
+        return self.respond(SlackHelpMessageBuilder(command).build())

+ 1 - 1
src/sentry/integrations/slack/event_endpoint.py

@@ -4,7 +4,7 @@ from typing import Any, Dict, List
 from sentry.api.base import Endpoint
 from sentry.integrations.slack.client import SlackClient
 from sentry.integrations.slack.message_builder.event import SlackEventMessageBuilder
-from sentry.integrations.slack.requests import SlackRequestError
+from sentry.integrations.slack.requests.base import SlackRequestError
 from sentry.integrations.slack.requests.event import SlackEventRequest
 from sentry.integrations.slack.unfurl import LinkType, UnfurlableUrl, link_handlers, match_link
 from sentry.shared_integrations.exceptions import ApiError

+ 5 - 3
src/sentry/integrations/slack/message_builder/base/base.py

@@ -14,8 +14,8 @@ class SlackMessageBuilder(AbstractMessageBuilder, ABC):
 
     @staticmethod
     def _build(
-        title: str,
         text: str,
+        title: Optional[str] = None,
         footer: Optional[str] = None,
         color: Optional[str] = None,
         **kwargs: Any,
@@ -23,8 +23,8 @@ class SlackMessageBuilder(AbstractMessageBuilder, ABC):
         """
         Helper to DRY up Slack specific fields.
 
-        :param string title: Title text.
         :param string text: Body text.
+        :param [string] title: Title text.
         :param [string] footer: Footer text.
         :param [string] color: The key in the Slack palate table, NOT hex. Default: "info".
         :param kwargs: Everything else.
@@ -36,8 +36,10 @@ class SlackMessageBuilder(AbstractMessageBuilder, ABC):
                 absolute_uri(get_asset_url("sentry", "images/sentry-email-avatar.png"))
             )
 
+        if title:
+            kwargs["title"] = title
+
         return {
-            "title": title,
             "text": text,
             "mrkdwn_in": ["text"],
             "color": LEVEL_TO_COLOR[color or "info"],

+ 29 - 0
src/sentry/integrations/slack/message_builder/help.py

@@ -0,0 +1,29 @@
+from typing import Optional
+
+from sentry.integrations.slack.message_builder import SlackBody
+from sentry.integrations.slack.message_builder.base.base import SlackMessageBuilder
+
+AVAILABLE_COMMANDS = {
+    "help": "displays the available commands",
+    "link": "kicks off linking Slack and Sentry",
+    "unlink": "unlinks your identity",
+}
+
+
+def get_message(command: Optional[str] = None) -> str:
+    unknown_command = f"Unknown command: `{command}`\n" if command else ""
+    commands_list = "\n".join(
+        f"• *{command}* - {description}" for command, description in AVAILABLE_COMMANDS.items()
+    )
+    return f"{unknown_command}Available Commands:\n{commands_list}"
+
+
+class SlackHelpMessageBuilder(SlackMessageBuilder):
+    def __init__(self, command: Optional[str] = None) -> None:
+        super().__init__()
+        self.command = command
+
+    def build(self) -> SlackBody:
+        return self._build(
+            text=get_message(self.command),
+        )

+ 7 - 10
src/sentry/integrations/slack/requests/base.py

@@ -1,10 +1,9 @@
-import hmac
-from hashlib import sha256
 from typing import Any, Mapping, MutableMapping, Optional
 
 from rest_framework.request import Request
 
 from sentry import options
+from sentry.integrations.slack.util.auth import check_signing_secret
 from sentry.models import Integration
 
 from ..utils import logger
@@ -111,14 +110,12 @@ class SlackRequest:
         raise SlackRequestError(status=401)
 
     def _check_signing_secret(self, signing_secret: str) -> bool:
-        # Taken from: https://github.com/slackapi/python-slack-events-api/blob/master/slackeventsapi/server.py#L47
-        # Slack docs on this here: https://api.slack.com/authentication/verifying-requests-from-slack#about
-        signature = self.request.META["HTTP_X_SLACK_SIGNATURE"]
-        timestamp = self.request.META["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
-
-        req = b"v0:%s:%s" % (timestamp.encode("utf-8"), self.request.body)
-        request_hash = "v0=" + hmac.new(signing_secret.encode("utf-8"), req, sha256).hexdigest()
-        return hmac.compare_digest(request_hash.encode("utf-8"), signature.encode("utf-8"))
+        signature = self.request.META.get("HTTP_X_SLACK_SIGNATURE")
+        timestamp = self.request.META.get("HTTP_X_SLACK_REQUEST_TIMESTAMP")
+        if not (signature and timestamp):
+            return False
+
+        return check_signing_secret(signing_secret, self.request.body, timestamp, signature)
 
     def _check_verification_token(self, verification_token: str) -> bool:
         return self.data.get("token") == verification_token

+ 25 - 0
src/sentry/integrations/slack/requests/command.py

@@ -0,0 +1,25 @@
+import logging
+from urllib.parse import parse_qs
+
+from sentry.integrations.slack.requests.base import SlackRequest, SlackRequestError
+
+logger = logging.getLogger("sentry.integrations.slack")
+
+
+class SlackCommandRequest(SlackRequest):
+    """
+    A Command request sent from Slack.
+
+    Slack sends the command payload as `application/x-www-form-urlencoded`
+    instead of JSON. This is slightly annoying because the values in the key-
+    value pairs are all automatically wrapped in arrays.
+    """
+
+    def _validate_data(self) -> None:
+        try:
+            qs_data = parse_qs(self.request.body.decode("utf-8"), strict_parsing=True)
+        except ValueError:
+            raise SlackRequestError(status=400)
+
+        # Flatten the values.
+        self._data = {key: value_array[0] for key, value_array in qs_data.items()}

+ 1 - 1
src/sentry/integrations/slack/unfurl/__init__.py

@@ -28,7 +28,7 @@ class Handler(NamedTuple):
 
 def make_type_coercer(type_map: Mapping[str, type]) -> ArgsMapper:
     """
-    Given a mapping of argument names to types, cosntruct a function that will
+    Given a mapping of argument names to types, construct a function that will
     coerce given arguments into those types.
     """
 

+ 2 - 0
src/sentry/integrations/slack/urls.py

@@ -1,12 +1,14 @@
 from django.conf.urls import url
 
 from .action_endpoint import SlackActionEndpoint
+from .command_endpoint import SlackCommandsEndpoint
 from .event_endpoint import SlackEventEndpoint
 from .link_identity import SlackLinkIdentityView
 from .unlink_identity import SlackUnlinkIdentityView
 
 urlpatterns = [
     url(r"^action/$", SlackActionEndpoint.as_view()),
+    url(r"^commands/$", SlackCommandsEndpoint.as_view(), name="sentry-integration-slack-commands"),
     url(r"^event/$", SlackEventEndpoint.as_view()),
     url(
         r"^link-identity/(?P<signed_params>[^\/]+)/$",

Some files were not shown because too many files changed in this diff