From 41decd550ae9e127666d4c75b28889a66c21370f Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Wed, 2 Oct 2024 14:41:34 -0700 Subject: [PATCH] ref(integrations): Introduce common dispatcher for webhook commands (#77169) Introduce `MessagingIntegrationCommand`, an abstraction for the global set of supported chat commands with consistent strings to invoke them. Combine code in various places for parsing command text into `MessagingIntegrationCommandDispatcher`. --- .../integrations/discord/webhooks/command.py | 74 ++++++++----- src/sentry/integrations/messaging/commands.py | 103 ++++++++++++++++++ src/sentry/integrations/msteams/webhook.py | 73 +++++++++---- .../integrations/slack/requests/base.py | 5 + .../integrations/slack/webhooks/base.py | 64 ++++++----- 5 files changed, 242 insertions(+), 77 deletions(-) create mode 100644 src/sentry/integrations/messaging/commands.py diff --git a/src/sentry/integrations/discord/webhooks/command.py b/src/sentry/integrations/discord/webhooks/command.py index 5f411659987100..b5a5dcc16ae381 100644 --- a/src/sentry/integrations/discord/webhooks/command.py +++ b/src/sentry/integrations/discord/webhooks/command.py @@ -1,10 +1,20 @@ +from collections.abc import Callable, Iterable +from dataclasses import dataclass + from rest_framework.response import Response +from sentry.integrations.discord.requests.base import DiscordRequest +from sentry.integrations.discord.utils import logger from sentry.integrations.discord.views.link_identity import build_linking_url from sentry.integrations.discord.views.unlink_identity import build_unlinking_url from sentry.integrations.discord.webhooks.handler import DiscordInteractionHandler - -from ..utils import logger +from sentry.integrations.messaging import commands +from sentry.integrations.messaging.commands import ( + CommandInput, + CommandNotMatchedError, + MessagingIntegrationCommand, + MessagingIntegrationCommandDispatcher, +) 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}`." @@ -22,12 +32,6 @@ """ -class DiscordCommandNames: - LINK = "link" - UNLINK = "unlink" - HELP = "help" - - class DiscordCommandHandler(DiscordInteractionHandler): """ Handles logic for Discord Command interactions. @@ -37,25 +41,35 @@ class DiscordCommandHandler(DiscordInteractionHandler): def handle(self) -> Response: command_name = self.request.get_command_name() - logging_data = self.request.logging_data + cmd_input = CommandInput(command_name) + dispatcher = DiscordCommandDispatcher(self.request) + try: + message = dispatcher.dispatch(cmd_input) + except CommandNotMatchedError: + logger.warning( + "discord.interaction.command.unknown", + extra={"command": command_name, **self.request.logging_data}, + ) + message = dispatcher.help(cmd_input) - if command_name == DiscordCommandNames.LINK: - return self.link_user() - elif command_name == DiscordCommandNames.UNLINK: - return self.unlink_user() - elif command_name == DiscordCommandNames.HELP: - return self.help() + return self.send_message(message) - logger.warning( - "discord.interaction.command.unknown", extra={"command": command_name, **logging_data} - ) - return self.help() - def link_user(self) -> Response: +@dataclass(frozen=True) +class DiscordCommandDispatcher(MessagingIntegrationCommandDispatcher[str]): + request: DiscordRequest + + @property + def command_handlers( + self, + ) -> Iterable[tuple[MessagingIntegrationCommand, Callable[[CommandInput], str]]]: + yield commands.HELP, self.help + yield commands.LINK_IDENTITY, self.link_user + yield commands.UNLINK_IDENTITY, self.unlink_user + + def link_user(self, _: CommandInput) -> str: if self.request.has_identity(): - return self.send_message( - ALREADY_LINKED_MESSAGE.format(email=self.request.get_identity_str()) - ) + return ALREADY_LINKED_MESSAGE.format(email=self.request.get_identity_str()) if not self.request.integration or not self.request.user_id: logger.warning( @@ -65,18 +79,18 @@ def link_user(self) -> Response: "hasUserId": self.request.user_id, }, ) - return self.send_message(MISSING_DATA_MESSAGE) + return MISSING_DATA_MESSAGE link_url = build_linking_url( integration=self.request.integration, discord_id=self.request.user_id, ) - return self.send_message(LINK_USER_MESSAGE.format(url=link_url)) + return LINK_USER_MESSAGE.format(url=link_url) - def unlink_user(self) -> Response: + def unlink_user(self, _: CommandInput) -> str: if not self.request.has_identity(): - return self.send_message(NOT_LINKED_MESSAGE) + return NOT_LINKED_MESSAGE # if self.request.has_identity() then these must not be None assert self.request.integration is not None @@ -87,7 +101,7 @@ def unlink_user(self) -> Response: discord_id=self.request.user_id, ) - return self.send_message(UNLINK_USER_MESSAGE.format(url=unlink_url)) + return UNLINK_USER_MESSAGE.format(url=unlink_url) - def help(self) -> Response: - return self.send_message(HELP_MESSAGE) + def help(self, _: CommandInput) -> str: + return HELP_MESSAGE diff --git a/src/sentry/integrations/messaging/commands.py b/src/sentry/integrations/messaging/commands.py new file mode 100644 index 00000000000000..767ceadd59a0c9 --- /dev/null +++ b/src/sentry/integrations/messaging/commands.py @@ -0,0 +1,103 @@ +import itertools +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Generic, TypeVar + + +@dataclass(frozen=True, eq=True) +class CommandInput: + cmd_value: str + arg_values: tuple[str, ...] = () + + def get_all_tokens(self) -> Iterable[str]: + yield self.cmd_value + yield from self.arg_values + + def adjust(self, slug: "CommandSlug") -> "CommandInput": + """Remove the args that are part of a slug.""" + token_count = len(slug.tokens) - 1 + slug_part = [self.cmd_value] + list(self.arg_values)[:token_count] + remaining_args = self.arg_values[token_count:] + return CommandInput(" ".join(slug_part), remaining_args) + + +class CommandNotMatchedError(Exception): + def __init__(self, message: str, unmatched_input: CommandInput) -> None: + super().__init__(message) + self.unmatched_input = unmatched_input + + +class CommandSlug: + def __init__(self, text: str) -> None: + self.tokens = tuple(token.casefold() for token in text.strip().split()) + + def does_match(self, cmd_input: CommandInput) -> bool: + if not self.tokens: + return cmd_input.cmd_value == "" and not cmd_input.arg_values + cmd_prefix = itertools.islice(cmd_input.get_all_tokens(), 0, len(self.tokens)) + cmd_tokens = tuple(token.casefold() for token in cmd_prefix) + return self.tokens == cmd_tokens + + def __repr__(self): + joined_tokens = " ".join(self.tokens) + return f"{type(self).__name__}({joined_tokens!r})" + + +class MessagingIntegrationCommand: + def __init__(self, name: str, command_text: str, aliases: Iterable[str] = ()) -> None: + super().__init__() + self.name = name + self.command_slug = CommandSlug(command_text) + self.aliases = frozenset(CommandSlug(alias) for alias in aliases) + + @staticmethod + def _to_tokens(text: str) -> tuple[str, ...]: + return tuple(token.casefold() for token in text.strip().split()) + + def get_all_command_slugs(self) -> Iterable[CommandSlug]: + yield self.command_slug + yield from self.aliases + + +MESSAGING_INTEGRATION_COMMANDS = ( + HELP := MessagingIntegrationCommand("HELP", "help", aliases=("", "support", "docs")), + LINK_IDENTITY := MessagingIntegrationCommand("LINK_IDENTITY", "link"), + UNLINK_IDENTITY := MessagingIntegrationCommand("UNLINK_IDENTITY", "unlink"), + LINK_TEAM := MessagingIntegrationCommand("LINK_TEAM", "link team"), + UNLINK_TEAM := MessagingIntegrationCommand("UNLINK_TEAM", "unlink team"), +) + +R = TypeVar("R") # response + + +class MessagingIntegrationCommandDispatcher(Generic[R], ABC): + """The set of commands handled by one messaging integration.""" + + @property + @abstractmethod + def command_handlers( + self, + ) -> Iterable[tuple[MessagingIntegrationCommand, Callable[[CommandInput], R]]]: + raise NotImplementedError + + def dispatch(self, cmd_input: CommandInput) -> R: + candidate_handlers = [ + (slug, callback) + for (command, callback) in self.command_handlers + for slug in command.get_all_command_slugs() + ] + + def parsing_order(handler: tuple[CommandSlug, Callable[[CommandInput], R]]) -> int: + # Sort by descending length of arg tokens. If one slug is a prefix of + # another (e.g., "link" and "link team"), we must check for the longer + # one first. + slug, _ = handler + return -len(slug.tokens) + + candidate_handlers.sort(key=parsing_order) + for (slug, callback) in candidate_handlers: + if slug.does_match(cmd_input): + arg_input = cmd_input.adjust(slug) + return callback(arg_input) + raise CommandNotMatchedError(f"{cmd_input=!r}", cmd_input) diff --git a/src/sentry/integrations/msteams/webhook.py b/src/sentry/integrations/msteams/webhook.py index dbfe7fd00f3184..2fac342351a211 100644 --- a/src/sentry/integrations/msteams/webhook.py +++ b/src/sentry/integrations/msteams/webhook.py @@ -2,7 +2,8 @@ import logging import time -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass from enum import Enum from typing import Any, cast @@ -20,6 +21,13 @@ from sentry.api.base import Endpoint, all_silo_endpoint from sentry.identity.services.identity import identity_service from sentry.identity.services.identity.model import RpcIdentity +from sentry.integrations.messaging import commands +from sentry.integrations.messaging.commands import ( + CommandInput, + CommandNotMatchedError, + MessagingIntegrationCommand, + MessagingIntegrationCommandDispatcher, +) from sentry.integrations.msteams import parsing from sentry.integrations.msteams.spec import PROVIDER from sentry.integrations.services.integration import integration_service @@ -602,27 +610,50 @@ def _handle_channel_message(self, request: Request) -> Response: def _handle_personal_message(self, request: Request) -> Response: data = request.data command_text = data.get("text", "").strip() - lowercase_command = command_text.lower() - conversation_id = data["conversation"]["id"] - teams_user_id = data["from"]["id"] - - # only supporting unlink for now - if "unlink" in lowercase_command: - unlink_url = build_unlinking_url(conversation_id, data["serviceUrl"], teams_user_id) - card = build_unlink_identity_card(unlink_url) - elif "help" in lowercase_command: - card = build_help_command_card() - elif "link" == lowercase_command: # don't to match other types of link commands - has_linked_identity = ( - identity_service.get_identity(filter={"identity_ext_id": teams_user_id}) is not None - ) - if has_linked_identity: - card = build_already_linked_identity_command_card() - else: - card = build_link_identity_command_card() - else: + + dispatcher = MsTeamsCommandDispatcher(data) + try: + card = dispatcher.dispatch(CommandInput(command_text)) + except CommandNotMatchedError: card = build_unrecognized_command_card(command_text) client = get_preinstall_client(data["serviceUrl"]) - client.send_card(conversation_id, card) + client.send_card(dispatcher.conversation_id, card) return self.respond(status=204) + + +@dataclass(frozen=True) +class MsTeamsCommandDispatcher(MessagingIntegrationCommandDispatcher[AdaptiveCard]): + data: dict[str, Any] + + @property + def conversation_id(self) -> str: + return self.data["conversation"]["id"] + + @property + def teams_user_id(self) -> str: + return self.data["from"]["id"] + + @property + def command_handlers( + self, + ) -> Iterable[tuple[MessagingIntegrationCommand, Callable[[CommandInput], AdaptiveCard]]]: + yield commands.HELP, (lambda _: build_help_command_card()) + yield commands.LINK_IDENTITY, self.link_identity + yield commands.UNLINK_IDENTITY, self.unlink_identity + + def link_identity(self, _: CommandInput) -> AdaptiveCard: + linked_identity = identity_service.get_identity( + filter={"identity_ext_id": self.teams_user_id} + ) + has_linked_identity = linked_identity is not None + if has_linked_identity: + return build_already_linked_identity_command_card() + else: + return build_link_identity_command_card() + + def unlink_identity(self, _: CommandInput) -> AdaptiveCard: + unlink_url = build_unlinking_url( + self.conversation_id, self.data["serviceUrl"], self.teams_user_id + ) + return build_unlink_identity_card(unlink_url) diff --git a/src/sentry/integrations/slack/requests/base.py b/src/sentry/integrations/slack/requests/base.py index 5c4e75fe1db975..0a1f753f4ebc97 100644 --- a/src/sentry/integrations/slack/requests/base.py +++ b/src/sentry/integrations/slack/requests/base.py @@ -12,6 +12,7 @@ from sentry import options from sentry.identity.services.identity import RpcIdentity, identity_service from sentry.identity.services.identity.model import RpcIdentityProvider +from sentry.integrations.messaging.commands import CommandInput from sentry.integrations.services.integration import RpcIntegration, integration_service from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service @@ -276,5 +277,9 @@ def get_command_and_args(self) -> tuple[str, Sequence[str]]: return "", [] return command[0], command[1:] + def get_command_input(self) -> CommandInput: + cmd, args = self.get_command_and_args() + return CommandInput(cmd, tuple(args)) + def _validate_identity(self) -> None: self.user = self.get_identity_user() diff --git a/src/sentry/integrations/slack/webhooks/base.py b/src/sentry/integrations/slack/webhooks/base.py index 1d2eba49c6ba1b..f5a4c16a56cc0e 100644 --- a/src/sentry/integrations/slack/webhooks/base.py +++ b/src/sentry/integrations/slack/webhooks/base.py @@ -1,17 +1,28 @@ from __future__ import annotations import abc +import logging +from collections.abc import Callable, Iterable +from dataclasses import dataclass from rest_framework import status from rest_framework.response import Response from sentry.api.base import Endpoint +from sentry.integrations.messaging import commands +from sentry.integrations.messaging.commands import ( + CommandInput, + CommandNotMatchedError, + MessagingIntegrationCommand, + MessagingIntegrationCommandDispatcher, +) from sentry.integrations.slack.message_builder.help import SlackHelpMessageBuilder from sentry.integrations.slack.metrics import ( SLACK_WEBHOOK_DM_ENDPOINT_FAILURE_DATADOG_METRIC, SLACK_WEBHOOK_DM_ENDPOINT_SUCCESS_DATADOG_METRIC, ) from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError +from sentry.utils import metrics LINK_USER_MESSAGE = ( "<{associate_url}|Link your Slack identity> to your Sentry account to receive notifications. " @@ -24,9 +35,6 @@ NOT_LINKED_MESSAGE = "You do not have a linked identity to unlink." ALREADY_LINKED_MESSAGE = "You are already linked as `{username}`." -import logging - -from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -42,33 +50,21 @@ def post_dispatcher(self, request: SlackDMRequest) -> Response: All Slack commands are handled by this endpoint. This block just validates the request and dispatches it to the right handler. """ - command, args = request.get_command_and_args() - - if command in ["help", "", "support", "docs"]: - return self.respond(SlackHelpMessageBuilder(command=command).build()) - - if command == "link": - if not args: - return self.link_user(request) - - if args[0] == "team": - return self.link_team(request) - - if command == "unlink": - if not args: - return self.unlink_user(request) - - if args[0] == "team": - return self.unlink_team(request) - - # If we cannot interpret the command, print help text. - request_data = request.data - unknown_command = request_data.get("text", "").lower() - return self.respond(SlackHelpMessageBuilder(unknown_command).build()) + cmd_input = request.get_command_input() + try: + return SlackCommandDispatcher(self, request).dispatch(cmd_input) + except CommandNotMatchedError: + # If we cannot interpret the command, print help text. + request_data = request.data + unknown_command = request_data.get("text", "").lower() + return self.help(unknown_command) def reply(self, slack_request: SlackDMRequest, message: str) -> Response: raise NotImplementedError + def help(self, command: str) -> Response: + return self.respond(SlackHelpMessageBuilder(command).build()) + def link_user(self, slack_request: SlackDMRequest) -> Response: from sentry.integrations.slack.views.link_identity import build_linking_url @@ -124,3 +120,19 @@ def link_team(self, slack_request: SlackDMRequest) -> Response: def unlink_team(self, slack_request: SlackDMRequest) -> Response: raise NotImplementedError + + +@dataclass(frozen=True) +class SlackCommandDispatcher(MessagingIntegrationCommandDispatcher[Response]): + endpoint: SlackDMEndpoint + request: SlackDMRequest + + @property + def command_handlers( + self, + ) -> Iterable[tuple[MessagingIntegrationCommand, Callable[[CommandInput], Response]]]: + yield commands.HELP, (lambda i: self.endpoint.help(i.cmd_value)) + yield commands.LINK_IDENTITY, (lambda i: self.endpoint.link_user(self.request)) + yield commands.UNLINK_IDENTITY, (lambda i: self.endpoint.unlink_user(self.request)) + yield commands.LINK_TEAM, (lambda i: self.endpoint.link_team(self.request)) + yield commands.UNLINK_TEAM, (lambda i: self.endpoint.unlink_team(self.request))