From 202b993da322a88befb23278f36adbe03c464e79 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 17 Mar 2022 09:45:21 -0400 Subject: [PATCH] Add Interaction.command and Interaction.namespace attributes --- discord/app_commands/commands.py | 30 ++++---- discord/app_commands/namespace.py | 2 +- discord/app_commands/tree.py | 109 ++++++++++++++++++------------ discord/interactions.py | 61 ++++++++++++++++- 4 files changed, 140 insertions(+), 62 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 311e2996d..2cfc69c41 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -47,7 +47,6 @@ from textwrap import TextWrapper import re from ..enums import AppCommandOptionType, AppCommandType -from ..interactions import Interaction from .models import Choice from .transformers import annotation_to_parameter, CommandParameter, NoneType from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered @@ -58,6 +57,7 @@ from ..utils import resolve_annotation, MISSING, is_inside_class if TYPE_CHECKING: from typing_extensions import ParamSpec, Concatenate + from ..interactions import Interaction from ..abc import Snowflake from .namespace import Namespace from .models import ChoiceT @@ -88,32 +88,32 @@ T = TypeVar('T') GroupT = TypeVar('GroupT', bound='Union[Group, Cog]') Coro = Coroutine[Any, Any, T] Error = Union[ - Callable[[GroupT, Interaction, AppCommandError], Coro[Any]], - Callable[[Interaction, AppCommandError], Coro[Any]], + Callable[[GroupT, 'Interaction', AppCommandError], Coro[Any]], + Callable[['Interaction', AppCommandError], Coro[Any]], ] if TYPE_CHECKING: CommandCallback = Union[ - Callable[Concatenate[GroupT, Interaction, P], Coro[T]], - Callable[Concatenate[Interaction, P], Coro[T]], + Callable[Concatenate[GroupT, 'Interaction', P], Coro[T]], + Callable[Concatenate['Interaction', P], Coro[T]], ] ContextMenuCallback = Union[ # If groups end up support context menus these would be uncommented - # Callable[[GroupT, Interaction, Member], Coro[Any]], - # Callable[[GroupT, Interaction, User], Coro[Any]], - # Callable[[GroupT, Interaction, Message], Coro[Any]], - # Callable[[GroupT, Interaction, Union[Member, User]], Coro[Any]], - Callable[[Interaction, Member], Coro[Any]], - Callable[[Interaction, User], Coro[Any]], - Callable[[Interaction, Message], Coro[Any]], - Callable[[Interaction, Union[Member, User]], Coro[Any]], + # Callable[[GroupT, 'Interaction', Member], Coro[Any]], + # Callable[[GroupT, 'Interaction', User], Coro[Any]], + # Callable[[GroupT, 'Interaction', Message], Coro[Any]], + # Callable[[GroupT, 'Interaction', Union[Member, User]], Coro[Any]], + Callable[['Interaction', Member], Coro[Any]], + Callable[['Interaction', User], Coro[Any]], + Callable[['Interaction', Message], Coro[Any]], + Callable[['Interaction', Union[Member, User]], Coro[Any]], ] AutocompleteCallback = Union[ - Callable[[GroupT, Interaction, ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]], - Callable[[Interaction, ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]], + Callable[[GroupT, 'Interaction', ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]], + Callable[['Interaction', ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]], ] else: CommandCallback = Callable[..., Coro[T]] diff --git a/discord/app_commands/namespace.py b/discord/app_commands/namespace.py index f2385452c..02cd22f49 100644 --- a/discord/app_commands/namespace.py +++ b/discord/app_commands/namespace.py @@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, Iterable, List, NamedTuple, Tuple -from ..interactions import Interaction from ..member import Member from ..object import Object from ..role import Role @@ -35,6 +34,7 @@ from ..enums import AppCommandOptionType from .models import AppCommandChannel, AppCommandThread if TYPE_CHECKING: + from ..interactions import Interaction from ..types.interactions import ResolvedData, ApplicationCommandInteractionDataOption __all__ = ('Namespace',) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index 6065fed57..98e52bf9c 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -878,10 +878,69 @@ class CommandTree(Generic[ClientT]): self.client.loop.create_task(wrapper(), name='CommandTree-invoker') + def _get_context_menu(self, data: ApplicationCommandInteractionData) -> Optional[ContextMenu]: + name = data['name'] + guild_id = _get_as_snowflake(data, 'guild_id') + return self._context_menus.get((name, guild_id, data.get('type', 1))) + + def _get_app_command_options( + self, data: ApplicationCommandInteractionData + ) -> Tuple[Command[Any, ..., Any], List[ApplicationCommandInteractionDataOption]]: + parents: List[str] = [] + name = data['name'] + + command_guild_id = _get_as_snowflake(data, 'guild_id') + if command_guild_id: + try: + guild_commands = self._guild_commands[command_guild_id] + except KeyError: + command = None + else: + command = guild_commands.get(name) + else: + command = self._global_commands.get(name) + + # If it's not found at this point then it's not gonna be found at any point + if command is None: + raise CommandNotFound(name, parents) + + # This could be done recursively but it'd be a bother due to the state needed + # to be tracked above like the parents, the actual command type, and the + # resulting options we care about + searching = True + options: List[ApplicationCommandInteractionDataOption] = data.get('options', []) + while searching: + for option in options: + # Find subcommands + if option.get('type', 0) in (1, 2): + parents.append(name) + name = option['name'] + command = command._get_internal_command(name) + if command is None: + raise CommandNotFound(name, parents) + options = option.get('options', []) + break + else: + searching = False + break + else: + break + + if isinstance(command, Group): + # Right now, groups can't be invoked. This is a Discord limitation in how they + # do slash commands. So if we're here and we have a Group rather than a Command instance + # then something in the code is out of date from the data that Discord has. + raise CommandSignatureMismatch(command) + + return (command, options) + async def _call_context_menu(self, interaction: Interaction, data: ApplicationCommandInteractionData, type: int) -> None: name = data['name'] guild_id = _get_as_snowflake(data, 'guild_id') ctx_menu = self._context_menus.get((name, guild_id, type)) + # Pre-fill the cached slot to prevent re-computation + interaction._cs_command = ctx_menu + if ctx_menu is None: raise CommandNotFound(name, [], AppCommandType(type)) @@ -936,56 +995,18 @@ class CommandTree(Generic[ClientT]): await self._call_context_menu(interaction, data, type) return - parents: List[str] = [] - name = data['name'] - - command_guild_id = _get_as_snowflake(data, 'guild_id') - if command_guild_id: - try: - guild_commands = self._guild_commands[command_guild_id] - except KeyError: - command = None - else: - command = guild_commands.get(name) - else: - command = self._global_commands.get(name) - - # If it's not found at this point then it's not gonna be found at any point - if command is None: - raise CommandNotFound(name, parents) - - # This could be done recursively but it'd be a bother due to the state needed - # to be tracked above like the parents, the actual command type, and the - # resulting options we care about - searching = True - options: List[ApplicationCommandInteractionDataOption] = data.get('options', []) - while searching: - for option in options: - # Find subcommands - if option.get('type', 0) in (1, 2): - parents.append(name) - name = option['name'] - command = command._get_internal_command(name) - if command is None: - raise CommandNotFound(name, parents) - options = option.get('options', []) - break - else: - searching = False - break - else: - break + command, options = self._get_app_command_options(data) - if isinstance(command, Group): - # Right now, groups can't be invoked. This is a Discord limitation in how they - # do slash commands. So if we're here and we have a Group rather than a Command instance - # then something in the code is out of date from the data that Discord has. - raise CommandSignatureMismatch(command) + # Pre-fill the cached slot to prevent re-computation + interaction._cs_command = command # At this point options refers to the arguments of the command # and command refers to the class type we care about namespace = Namespace(interaction, data.get('resolved', {}), options) + # Same pre-fill as above + interaction._cs_namespace = namespace + # Auto complete handles the namespace differently... so at this point this is where we decide where that is. if interaction.type is InteractionType.autocomplete: focused = next((opt['name'] for opt in options if opt.get('focused')), None) diff --git a/discord/interactions.py b/discord/interactions.py index 0a428cf58..4f1ae0e86 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -25,13 +25,13 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Sequence, Tuple, Union +from typing import Any, Dict, Optional, TYPE_CHECKING, Sequence, Tuple, Union import asyncio import datetime from . import utils from .enums import try_enum, Locale, InteractionType, InteractionResponseType -from .errors import InteractionResponded, HTTPException, ClientException +from .errors import InteractionResponded, HTTPException, ClientException, DiscordException from .flags import MessageFlags from .channel import PartialMessageable, ChannelType @@ -42,6 +42,7 @@ from .object import Object from .permissions import Permissions from .http import handle_message_parameters from .webhook.async_ import async_context, Webhook, interaction_response_params, interaction_message_response_params +from .app_commands.namespace import Namespace __all__ = ( 'Interaction', @@ -53,6 +54,7 @@ if TYPE_CHECKING: from .types.interactions import ( Interaction as InteractionPayload, InteractionData, + ApplicationCommandInteractionData, ) from .types.webhook import ( Webhook as WebhookPayload, @@ -69,6 +71,7 @@ if TYPE_CHECKING: from .ui.modal import Modal from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable from .threads import Thread + from .app_commands.commands import Command, ContextMenu InteractionChannel = Union[ VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable @@ -133,6 +136,8 @@ class Interaction: '_cs_response', '_cs_followup', '_cs_channel', + '_cs_namespace', + '_cs_command', ) def __init__(self, *, data: InteractionPayload, state: ConnectionState): @@ -220,6 +225,58 @@ class Interaction: """ return Permissions(self._permissions) + @utils.cached_slot_property('_cs_namespace') + def namespace(self) -> Namespace: + """:class:`app_commands.Namespace`: The resolved namespace for this interaction. + + If the interaction is not an application command related interaction or the client does not have a + tree attached to it then this returns an empty namespace. + """ + if self.type not in (InteractionType.application_command, InteractionType.autocomplete): + return Namespace(self, {}, []) + + tree = self._state._command_tree + if tree is None: + return Namespace(self, {}, []) + + # The type checker does not understand this narrowing + data: ApplicationCommandInteractionData = self.data # type: ignore + + try: + _, options = tree._get_app_command_options(data) + except DiscordException: + options = [] + + return Namespace(self, data.get('resolved', {}), options) + + @utils.cached_slot_property('_cs_command') + def command(self) -> Optional[Union[Command[Any, ..., Any], ContextMenu]]: + """Optional[Union[:class:`app_commands.Command`, :class:`app_commands.ContextMenu`]]: The command being called from + this interaction. + + If the interaction is not an application command related interaction or the command is not found in the client's + attached tree then ``None`` is returned. + """ + if self.type not in (InteractionType.application_command, InteractionType.autocomplete): + return None + + tree = self._state._command_tree + if tree is None: + return None + + # The type checker does not understand this narrowing + data: ApplicationCommandInteractionData = self.data # type: ignore + cmd_type = data.get('type', 1) + if cmd_type == 1: + try: + command, _ = tree._get_app_command_options(data) + except DiscordException: + return None + else: + return command + else: + return tree._get_context_menu(data) + @utils.cached_slot_property('_cs_response') def response(self) -> InteractionResponse: """:class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction.