Skip to content

Commit

Permalink
ref(integrations): Introduce common dispatcher for webhook commands (#…
Browse files Browse the repository at this point in the history
…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`.
  • Loading branch information
RyanSkonnord authored Oct 2, 2024
1 parent cb3e5cf commit 41decd5
Show file tree
Hide file tree
Showing 5 changed files with 242 additions and 77 deletions.
74 changes: 44 additions & 30 deletions src/sentry/integrations/discord/webhooks/command.py
Original file line number Diff line number Diff line change
@@ -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}`."
Expand All @@ -22,12 +32,6 @@
"""


class DiscordCommandNames:
LINK = "link"
UNLINK = "unlink"
HELP = "help"


class DiscordCommandHandler(DiscordInteractionHandler):
"""
Handles logic for Discord Command interactions.
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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
103 changes: 103 additions & 0 deletions src/sentry/integrations/messaging/commands.py
Original file line number Diff line number Diff line change
@@ -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)
73 changes: 52 additions & 21 deletions src/sentry/integrations/msteams/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions src/sentry/integrations/slack/requests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Loading

0 comments on commit 41decd5

Please sign in to comment.