diff --git a/discord/__init__.py b/discord/__init__.py index 7b78e6c8f..8864567f4 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -43,7 +43,7 @@ from .template import * from .widget import * from .object import * from .reaction import * -from . import utils, opus, abc, ui +from . import utils, opus, abc, ui, app_commands from .enums import * from .embeds import * from .mentions import * diff --git a/discord/app_commands/__init__.py b/discord/app_commands/__init__.py new file mode 100644 index 000000000..acc98fd41 --- /dev/null +++ b/discord/app_commands/__init__.py @@ -0,0 +1,16 @@ +""" +discord.app_commands +~~~~~~~~~~~~~~~~~~~~~ + +Application commands support for the Discord API + +:copyright: (c) 2015-present Rapptz +:license: MIT, see LICENSE for more details. + +""" + +from .commands import * +from .enums import * +from .errors import * +from .models import * +from .tree import * diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py new file mode 100644 index 000000000..ac902bb8b --- /dev/null +++ b/discord/app_commands/commands.py @@ -0,0 +1,743 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations +import inspect + +from typing import ( + Any, + Callable, + ClassVar, + Coroutine, + Dict, + Generic, + List, + Optional, + Set, + TYPE_CHECKING, + Tuple, + Type, + TypeVar, + Union, +) +from dataclasses import dataclass +from textwrap import TextWrapper + +import sys +import re + +from .enums import AppCommandOptionType, AppCommandType +from ..interactions import Interaction +from ..enums import ChannelType, try_enum +from .models import Choice +from .errors import CommandSignatureMismatch, CommandAlreadyRegistered +from ..utils import resolve_annotation, MISSING, is_inside_class +from ..user import User +from ..member import Member +from ..role import Role +from ..mixins import Hashable +from ..permissions import Permissions + +if TYPE_CHECKING: + from typing_extensions import ParamSpec, Concatenate + from ..interactions import Interaction + from ..types.interactions import ( + ResolvedData, + PartialThread, + PartialChannel, + ApplicationCommandInteractionDataOption, + ) + from ..state import ConnectionState + from .namespace import Namespace + +__all__ = ( + 'CommandParameter', + 'Command', + 'Group', + 'command', + 'describe', +) + +if TYPE_CHECKING: + P = ParamSpec('P') +else: + P = TypeVar('P') + +T = TypeVar('T') +GroupT = TypeVar('GroupT', bound='Group') +Coro = Coroutine[Any, Any, T] + +if TYPE_CHECKING: + CommandCallback = Union[ + Callable[Concatenate[GroupT, Interaction, P], Coro[T]], + Callable[Concatenate[Interaction, P], Coro[T]], + ] +else: + CommandCallback = Callable[..., Coro[T]] + + +VALID_SLASH_COMMAND_NAME = re.compile(r'^[\w-]{1,32}$') +CAMEL_CASE_REGEX = re.compile(r'(? str: + return _wrapper.fill(' '.join(input.strip().split())) + + +def _to_kebab_case(text: str) -> str: + return CAMEL_CASE_REGEX.sub('-', text).lower() + + +@dataclass +class CommandParameter: + """Represents a application command parameter. + + Attributes + ----------- + name: :class:`str` + The name of the parameter. + description: :class:`str` + The description of the parameter + required: :class:`bool` + Whether the parameter is required + choices: List[:class:`~discord.app_commands.Choice`] + A list of choices this parameter takes + type: :class:`~discord.app_commands.AppCommandOptionType` + The underlying type of this parameter. + channel_types: List[:class:`~discord.ChannelType`] + The channel types that are allowed for this parameter. + min_value: Optional[:class:`int`] + The minimum supported value for this parameter. + max_value: Optional[:class:`int`] + The maximum supported value for this parameter. + autocomplete: :class:`bool` + Whether this parameter enables autocomplete. + """ + + name: str = MISSING + description: str = MISSING + required: bool = MISSING + default: Any = MISSING + choices: List[Choice] = MISSING + type: AppCommandOptionType = MISSING + channel_types: List[ChannelType] = MISSING + min_value: Optional[int] = None + max_value: Optional[int] = None + autocomplete: bool = MISSING + annotation: Any = MISSING + # restrictor: Optional[RestrictorType] = None + + def to_dict(self) -> Dict[str, Any]: + base = { + 'type': self.type.value, + 'name': self.name, + 'description': self.description, + 'required': self.required, + } + + if self.choices: + base['choices'] = [choice.to_dict() for choice in self.choices] + if self.channel_types: + base['channel_types'] = [t.value for t in self.channel_types] + if self.autocomplete: + base['autocomplete'] = True + if self.min_value is not None: + base['min_value'] = self.min_value + if self.max_value is not None: + base['max_value'] = self.max_value + + return base + + +annotation_to_option_type: Dict[Any, AppCommandOptionType] = { + str: AppCommandOptionType.string, + int: AppCommandOptionType.integer, + float: AppCommandOptionType.number, + bool: AppCommandOptionType.boolean, + User: AppCommandOptionType.user, + Member: AppCommandOptionType.user, + Role: AppCommandOptionType.role, + # StageChannel: AppCommandOptionType.channel, + # StoreChannel: AppCommandOptionType.channel, + # VoiceChannel: AppCommandOptionType.channel, + # TextChannel: AppCommandOptionType.channel, +} + +NoneType = type(None) +allowed_default_types: Dict[AppCommandOptionType, Tuple[Type[Any], ...]] = { + AppCommandOptionType.string: (str, NoneType), + AppCommandOptionType.integer: (int, NoneType), + AppCommandOptionType.boolean: (bool, NoneType), +} + + +# Some sanity checks: +# str => string +# int => int +# User => user +# etc ... +# Optional[str] => string, required: false, default: None +# Optional[int] => integer, required: false, default: None +# Optional[Model] = None => resolved, required: false, default: None +# Optional[Model] can only have (CommandParameter, None) as default +# Optional[int | str | bool] can have (CommandParameter, None, int | str | bool) as a default +# Union[str, Member] => disallowed +# Union[int, str] => disallowed +# Union[Member, User] => user +# Optional[Union[Member, User]] => user, required: false, default: None +# Union[Member, User, Object] => mentionable +# Union[Models] => mentionable +# Optional[Union[Models]] => mentionable, required: false, default: None + + +def _annotation_to_type( + annotation: Any, + *, + mapping=annotation_to_option_type, + _none=NoneType, +) -> Tuple[AppCommandOptionType, Any]: + # Straight simple case, a regular ol' parameter + try: + option_type = mapping[annotation] + except KeyError: + pass + else: + return (option_type, MISSING) + + # Check if there's an origin + origin = getattr(annotation, '__origin__', None) + if origin is not Union: # TODO: Python 3.10 + # Only Union/Optional is supported so bail early + raise TypeError(f'unsupported type annotation {annotation!r}') + + default = MISSING + if annotation.__args__[-1] is _none: + if len(annotation.__args__) == 2: + underlying = annotation.__args__[0] + option_type = mapping.get(underlying) + if option_type is None: + raise TypeError(f'unsupported inner optional type {underlying!r}') + return (option_type, None) + else: + args = annotation.__args__[:-1] + default = None + else: + args = annotation.__args__ + + # At this point only models are allowed + # Since Optional[int | bool | str] will be taken care of above + # The only valid transformations here are: + # [Member, User] => user + # [Member, User, Role] => mentionable + # [Member | User, Role] => mentionable + supported_types: Set[Any] = {Role, Member, User} + if not all(arg in supported_types for arg in args): + raise TypeError(f'unsupported types given inside {annotation!r}') + if args == (User, Member) or args == (Member, User): + return (AppCommandOptionType.user, default) + + return (AppCommandOptionType.mentionable, default) + + +def _populate_descriptions(params: Dict[str, CommandParameter], descriptions: Dict[str, Any]) -> None: + for name, param in params.items(): + description = descriptions.pop(name, MISSING) + if description is MISSING: + param.description = '...' + continue + + if not isinstance(description, str): + raise TypeError('description must be a string') + + param.description = description + + if descriptions: + first = next(iter(descriptions)) + raise TypeError(f'unknown parameter given: {first}') + + +def _get_parameter(annotation: Any, parameter: inspect.Parameter) -> CommandParameter: + (type, default) = _annotation_to_type(annotation) + if default is MISSING: + default = parameter.default + if default is parameter.empty: + default = MISSING + + result = CommandParameter( + type=type, + default=default, + required=default is MISSING, + name=parameter.name, + ) + + if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.VAR_KEYWORD, parameter.VAR_POSITIONAL): + raise TypeError(f'unsupported parameter kind in callback: {parameter.kind!s}') + + # Verify validity of the default parameter + if result.default is not MISSING: + valid_types: Tuple[Any, ...] = allowed_default_types.get(result.type, (NoneType,)) + if not isinstance(result.default, valid_types): + raise TypeError(f'invalid default parameter type given ({result.default.__class__}), expected {valid_types}') + + result.annotation = annotation + return result + + +def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, CommandParameter]: + params = inspect.signature(func).parameters + cache = {} + required_params = is_inside_class(func) + 1 + if len(params) < required_params: + raise TypeError(f'callback must have more than {required_params - 1} parameter(s)') + + iterator = iter(params.values()) + for _ in range(0, required_params): + next(iterator) + + parameters: List[CommandParameter] = [] + for parameter in iterator: + if parameter.annotation is parameter.empty: + raise TypeError(f'annotation for {parameter.name} must be given') + + resolved = resolve_annotation(parameter.annotation, globalns, globalns, cache) + param = _get_parameter(resolved, parameter) + parameters.append(param) + + values = sorted(parameters, key=lambda a: a.required, reverse=True) + result = {v.name: v for v in values} + + try: + descriptions = func.__discord_app_commands_param_description__ + except AttributeError: + pass + else: + _populate_descriptions(result, descriptions) + + return result + + +class Command(Generic[GroupT, P, T]): + """A class that implements an application command. + + These are usually not created manually, instead they are created using + one of the following decorators: + + - :func:`~discord.app_commands.command` + - :meth:`Group.command ` + + .. versionadded:: 2.0 + + Attributes + ------------ + name: :class:`str` + The name of the application command. + type: :class:`AppCommandType` + The type of application command. + callback: :ref:`coroutine ` + The coroutine that is executed when the command is called. + description: :class:`str` + The description of the application command. This shows up in the UI to describe + the application command. + parent: Optional[:class:`CommandGroup`] + The parent application command. ``None`` if there isn't one. + """ + + def __init__( + self, + *, + name: str, + description: str, + callback: CommandCallback[GroupT, P, T], + type: AppCommandType = AppCommandType.chat_input, + parent: Optional[Group] = None, + ): + self.name: str = name + self.description: str = description + self._callback: CommandCallback[GroupT, P, T] = callback + self.parent: Optional[Group] = parent + self.binding: Optional[GroupT] = None + self.type: AppCommandType = type + self._params: Dict[str, CommandParameter] = _extract_parameters_from_callback(callback, callback.__globals__) + + def _copy_with_binding(self, binding: GroupT) -> Command: + cls = self.__class__ + copy = cls.__new__(cls) + copy.name = self.name + copy.description = self.description + copy._callback = self._callback + copy.parent = self.parent + copy.type = self.type + copy._params = self._params.copy() + copy.binding = binding + return copy + + def to_dict(self) -> Dict[str, Any]: + # If we have a parent then our type is a subcommand + # Otherwise, the type falls back to the specific command type (e.g. slash command or context menu) + option_type = self.type.value if self.parent is None else AppCommandOptionType.subcommand.value + return { + 'name': self.name, + 'description': self.description, + 'type': option_type, + 'options': [param.to_dict() for param in self._params.values()], + } + + async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T: + defaults = ((name, param.default) for name, param in self._params.items() if not param.required) + namespace._update_with_defaults(defaults) + # These type ignores are because the type checker doesn't quite understand the narrowing here + # Likewise, it thinks we're missing positional arguments when there aren't any. + try: + if self.binding is not None: + return await self._callback(self.binding, interaction, **namespace.__dict__) # type: ignore + return await self._callback(interaction, **namespace.__dict__) # type: ignore + except TypeError: + # In order to detect mismatch from the provided signature and the Discord data, + # there are many ways it can go wrong yet all of them eventually lead to a TypeError + # from the Python compiler showcasing that the signature is incorrect. This lovely + # piece of code essentially checks the last frame of the caller and checks if the + # locals contains our `self` reference. + # + # This is because there is a possibility that a TypeError is raised within the body + # of the function, and in that case the locals wouldn't contain a reference to + # the command object under the name `self`. + frame = inspect.trace()[-1].frame + if frame.f_locals.get('self') is self: + raise CommandSignatureMismatch(self) from None + raise + + def get_parameter(self, name: str) -> Optional[CommandParameter]: + """Returns the :class:`CommandParameter` with the given name. + + Parameters + ----------- + name: :class:`str` + The parameter name to get. + + Returns + -------- + Optional[:class:`CommandParameter`] + The command parameter, if found. + """ + return self._params.get(name) + + @property + def root_parent(self) -> Optional[Group]: + """Optional[:class:`Group`]: The root parent of this command.""" + if self.parent is None: + return None + parent = self.parent + return parent.parent or parent + + def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: + return None + + +class Group: + """A class that implements an application command group. + + These are usually inherited rather than created manually. + + .. versionadded:: 2.0 + + Attributes + ------------ + name: :class:`str` + The name of the group. If not given, it defaults to a lower-case + kebab-case version of the class name. + description: :class:`str` + The description of the group. This shows up in the UI to describe + the group. If not given, it defaults to the docstring of the + class shortened to 100 characters. + parent: Optional[:class:`CommandGroup`] + The parent group. ``None`` if there isn't one. + """ + + __discord_app_commands_group_children__: ClassVar[List[Union[Command, Group]]] = [] + __discord_app_commands_group_name__: str = MISSING + __discord_app_commands_group_description__: str = MISSING + + def __init_subclass__(cls, *, name: str = MISSING, description: str = MISSING) -> None: + cls.__discord_app_commands_group_children__ = children = [ + member for member in cls.__dict__.values() if isinstance(member, (Group, Command)) and member.parent is None + ] + + found = set() + for child in children: + if child.name in found: + raise TypeError(f'Command {child.name} is a duplicate') + found.add(child.name) + + if name is MISSING: + cls.__discord_app_commands_group_name__ = _to_kebab_case(cls.__name__) + else: + cls.__discord_app_commands_group_name__ = name + + if description is MISSING: + if cls.__doc__ is None: + cls.__discord_app_commands_group_description__ = '...' + else: + cls.__discord_app_commands_group_description__ = _shorten(cls.__doc__) + else: + cls.__discord_app_commands_group_description__ = description + + if len(children) > 25: + raise TypeError('groups cannot have more than 25 commands') + + def __init__( + self, + *, + name: str = MISSING, + description: str = MISSING, + parent: Optional[Group] = None, + ): + cls = self.__class__ + self.name: str = name if name is not MISSING else cls.__discord_app_commands_group_name__ + self.description: str = description or cls.__discord_app_commands_group_description__ + + if not self.description: + raise TypeError('groups must have a description') + + self.parent: Optional[Group] = parent + + self._children: Dict[str, Union[Command, Group]] = { + child.name: child._copy_with_binding(self) for child in self.__discord_app_commands_group_children__ + } + + for child in self._children.values(): + child.parent = self + + if parent is not None and parent.parent is not None: + raise ValueError('groups can only be nested at most one level') + + def _copy_with_binding(self, binding: Group) -> Group: + cls = self.__class__ + copy = cls.__new__(cls) + copy.name = self.name + copy.description = self.description + copy.parent = self.parent + copy._children = {child.name: child._copy_with_binding(binding) for child in self._children.values()} + return copy + + def to_dict(self) -> Dict[str, Any]: + # If this has a parent command then it's part of a subcommand group + # Otherwise, it's just a regular command + option_type = 1 if self.parent is None else AppCommandOptionType.subcommand_group.value + return { + 'name': self.name, + 'description': self.description, + 'type': option_type, + 'options': [child.to_dict() for child in self._children.values()], + } + + @property + def root_parent(self) -> Optional[Group]: + """Optional[:class:`Group`]: The parent of this group.""" + return self.parent + + def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: + return self._children.get(name) + + def add_command(self, command: Union[Command, Group], /, *, override: bool = False): + """Adds a command or group to this group's internal list of commands. + + Parameters + ----------- + command: Union[:class:`Command`, :class:`Group`] + The command or group to add. + override: :class:`bool` + Whether to override a pre-existing command or group with the same name. + If ``False`` then an exception is raised. + + Raises + ------- + CommandAlreadyRegistered + The command or group is already registered. Note that the :attr:`CommandAlreadyRegistered.guild_id` + attribute will always be ``None`` in this case. + ValueError + There are too many commands already registered. + """ + + if not override and command.name in self._children: + raise CommandAlreadyRegistered(command.name, guild_id=None) + + self._children[command.name] = command + if len(self._children) > 25: + raise ValueError('maximum number of child commands exceeded') + + def remove_command(self, name: str, /) -> Optional[Union[Command, Group]]: + """Remove a command or group from the internal list of commands. + + Parameters + ----------- + name: :class:`str` + The name of the command or group to remove. + + Returns + -------- + Optional[Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.Group`]] + The command that was removed. If nothing was removed + then ``None`` is returned instead. + """ + + self._children.pop(name, None) + + def get_command(self, name: str, /) -> Optional[Union[Command, Group]]: + """Retrieves a command or group from its name. + + Parameters + ----------- + name: :class:`str` + The name of the command or group to retrieve. + + Returns + -------- + Optional[Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.Group`]] + The command or group that was retrieved. If nothing was found + then ``None`` is returned instead. + """ + return self._children.get(name) + + def command( + self, + *, + name: str = MISSING, + description: str = MISSING, + ) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]: + """Creates an application command under this group. + + Parameters + ------------ + name: :class:`str` + The name of the application command. If not given, it defaults to a lower-case + version of the callback name. + description: :class:`str` + The description of the application command. This shows up in the UI to describe + the application command. If not given, it defaults to the first line of the docstring + of the callback shortened to 100 characters. + """ + + def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: + if not inspect.iscoroutinefunction(func): + raise TypeError('command function must be a coroutine function') + + if description is MISSING: + if func.__doc__ is None: + desc = '...' + else: + desc = _shorten(func.__doc__) + else: + desc = description + + command = Command( + name=name if name is not MISSING else func.__name__, + description=desc, + callback=func, + type=AppCommandType.chat_input, + parent=self, + ) + self.add_command(command) + return command + + return decorator + + +def command( + *, + name: str = MISSING, + description: str = MISSING, +) -> Callable[[CommandCallback[GroupT, P, T]], Command[GroupT, P, T]]: + """Creates an application command from a regular function. + + Parameters + ------------ + name: :class:`str` + The name of the application command. If not given, it defaults to a lower-case + version of the callback name. + description: :class:`str` + The description of the application command. This shows up in the UI to describe + the application command. If not given, it defaults to the first line of the docstring + of the callback shortened to 100 characters. + """ + + def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: + if not inspect.iscoroutinefunction(func): + raise TypeError('command function must be a coroutine function') + + if description is MISSING: + if func.__doc__ is None: + desc = '...' + else: + desc = _shorten(func.__doc__) + else: + desc = description + + return Command( + name=name if name is not MISSING else func.__name__, + description=desc, + callback=func, + type=AppCommandType.chat_input, + parent=None, + ) + + return decorator + + +def describe(**parameters: str) -> Callable[[T], T]: + r"""Describes the given parameters by their name using the key of the keyword argument + as the name. + + Example: + + .. code-block:: python3 + + @app_commands.command() + @app_commads.describe(member='the member to ban') + async def ban(interaction: discord.Interaction, member: discord.Member): + await interaction.response.send_message(f'Banned {member}') + + Parameters + ----------- + \*\*parameters + The description of the parameters. + + Raises + -------- + TypeError + The parameter name is not found. + """ + + def decorator(inner: T) -> T: + if isinstance(inner, Command): + _populate_descriptions(inner._params, parameters) + else: + inner.__discord_app_commands_param_description__ = parameters # type: ignore - Runtime attribute assignment + + return inner + + return decorator diff --git a/discord/app_commands/enums.py b/discord/app_commands/enums.py new file mode 100644 index 000000000..39e4f4613 --- /dev/null +++ b/discord/app_commands/enums.py @@ -0,0 +1,53 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from ..enums import Enum + +__all__ = ( + 'AppCommandOptionType', + 'AppCommandType', +) + + +class AppCommandOptionType(Enum): + subcommand = 1 + subcommand_group = 2 + string = 3 + integer = 4 + boolean = 5 + user = 6 + channel = 7 + role = 8 + mentionable = 9 + number = 10 + attachment = 11 + + def is_argument(self) -> bool: + return 11 >= self.value >= 3 + + +class AppCommandType(Enum): + chat_input = 1 + user = 2 + message = 3 diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py new file mode 100644 index 000000000..919004e27 --- /dev/null +++ b/discord/app_commands/errors.py @@ -0,0 +1,96 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Union +from ..errors import DiscordException + +__all__ = ( + 'CommandAlreadyRegistered', + 'CommandSignatureMismatch', + 'CommandNotFound', +) + +if TYPE_CHECKING: + from .commands import Command, Group + + +class CommandAlreadyRegistered(DiscordException): + """An exception raised when a command is already registered. + + Attributes + ----------- + name: :class:`str` + The name of the command already registered. + guild_id: Optional[:class:`int`] + The guild ID this command was already registered at. + If ``None`` then it was a global command. + """ + + def __init__(self, name: str, guild_id: Optional[int]): + self.name = name + self.guild_id = guild_id + super().__init__(f'Command {name!r} already registered.') + + +class CommandNotFound(DiscordException): + """An exception raised when an application command could not be found. + + Attributes + ------------ + name: :class:`str` + The name of the application command not found. + parents: List[:class:`str`] + A list of parent command names that were previously found + prior to the application command not being found. + """ + + def __init__(self, name: str, parents: List[str]): + self.name = name + self.parents = parents + super().__init__(f'Application command {name!r} not found') + + +class CommandSignatureMismatch(DiscordException): + """An exception raised when an application command from Discord has a different signature + from the one provided in the code. This happens because your command definition differs + from the command definition you provided Discord. Either your code is out of date or the + data from Discord is out of sync. + + Attributes + ------------ + command: Union[:class:`~discord.app_commands.Command`, :class:`~discord.app_commands.Group`] + The command that had the signature mismatch. + """ + + def __init__(self, command: Union[Command, Group]): + self.command: Union[Command, Group] = command + msg = ( + f'The signature for command {command!r} is different from the one provided by Discord. ' + 'This can happen because either your code is out of date or you have not synced the ' + 'commands with Discord, causing the mismatch in data. It is recommended to sync the ' + 'command tree to fix this issue.' + ) + super().__init__(msg) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py new file mode 100644 index 000000000..8ad96f9b0 --- /dev/null +++ b/discord/app_commands/models.py @@ -0,0 +1,592 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations +from datetime import datetime + + +from ..permissions import Permissions +from ..enums import ChannelType, try_enum +from ..mixins import Hashable +from ..utils import _get_as_snowflake, parse_time, snowflake_time +from .enums import AppCommandOptionType, AppCommandType +from typing import List, NamedTuple, TYPE_CHECKING, Optional, Union + +__all__ = ( + 'AppCommand', + 'AppCommandGroup', + 'AppCommandChannel', + 'AppCommandThread', + 'Argument', + 'Choice', +) + + +def is_app_command_argument_type(value: int) -> bool: + return 11 >= value >= 3 + + +if TYPE_CHECKING: + from ..types.command import ( + ApplicationCommand as ApplicationCommandPayload, + ApplicationCommandOptionChoice, + ApplicationCommandOption, + ) + from ..types.interactions import ( + PartialChannel, + PartialThread, + ) + from ..types.threads import ThreadMetadata + from ..state import ConnectionState + from ..guild import GuildChannel, Guild + from ..channel import TextChannel + from ..threads import Thread + + ApplicationCommandParent = Union['AppCommand', 'AppCommandGroup'] + + +class AppCommand(Hashable): + """Represents a application command. + + In common parlance this is referred to as a "Slash Command" or a + "Context Menu Command". + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two application commands are equal. + + .. describe:: x != y + + Checks if two application commands are not equal. + + .. describe:: hash(x) + + Returns the application command's hash. + + .. describe:: str(x) + + Returns the application command's name. + + Attributes + ----------- + id: :class:`int` + The application command's ID. + application_id: :class:`int` + The application command's application's ID. + type: :class:`ApplicationCommandType` + The application command's type. + name: :class:`str` + The application command's name. + description: :class:`str` + The application command's description. + """ + + __slots__ = ( + 'id', + 'type', + 'application_id', + 'name', + 'description', + 'options', + '_state', + ) + + def __init__(self, *, data: ApplicationCommandPayload, state=None): + self._state = state + self._from_data(data) + + def _from_data(self, data: ApplicationCommandPayload): + self.id: int = int(data['id']) + self.application_id: int = int(data['application_id']) + self.name: str = data['name'] + self.description: str = data['description'] + self.type: AppCommandType = try_enum(AppCommandType, data.get('type', 1)) + self.options = [app_command_option_factory(data=d, parent=self, state=self._state) for d in data.get('options', [])] + + def to_dict(self) -> ApplicationCommandPayload: + return { + 'id': self.id, + 'type': self.type.value, + 'application_id': self.application_id, + 'name': self.name, + 'description': self.description, + 'options': [opt.to_dict() for opt in self.options], + } # type: ignore -- Type checker does not understand this literal. + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} type={self.type!r}>' + + +class Choice(NamedTuple): + """Represents an application command argument choice. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two choices are equal. + + .. describe:: x != y + + Checks if two choices are not equal. + + Parameters + ----------- + name: :class:`str` + The name of the choice. Used for display purposes. + value: Union[:class:`int`, :class:`str`, :class:`float`] + The value of the choice. + """ + + name: str + value: Union[int, str, float] + + def to_dict(self) -> ApplicationCommandOptionChoice: + return { + 'name': self.name, + 'value': self.value, + } # type: ignore -- Type checker does not understand this literal. + + +class AppCommandChannel(Hashable): + """Represents an application command partially resolved channel object. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two channels are equal. + + .. describe:: x != y + + Checks if two channels are not equal. + + .. describe:: hash(x) + + Returns the channel's hash. + + .. describe:: str(x) + + Returns the channel's name. + + Attributes + ----------- + id: :class:`int` + The ID of the channel. + type: :class:`~discord.ChannelType` + The type of channel. + name: :class:`str` + The name of the channel. + permissions: :class:`~discord.Permissions` + The resolved permissions of the user who invoked + the application command in that channel. + guild_id: :class:`int` + The guild ID this channel belongs to. + """ + + __slots__ = ( + 'id', + 'type', + 'name', + 'permissions', + 'guild_id', + '_state', + ) + + def __init__( + self, + *, + state: ConnectionState, + data: PartialChannel, + guild_id: int, + ): + self._state = state + self.guild_id = guild_id + self.id = int(data['id']) + self.type = try_enum(ChannelType, data['type']) + self.name = data['name'] + self.permissions = Permissions(int(data['permissions'])) + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} type={self.type!r}>' + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`~discord.Guild`]: The channel's guild, from cache, if found.""" + return self._state._get_guild(self.guild_id) + + def resolve(self) -> Optional[GuildChannel]: + """Resolves the application command channel to the appropriate channel + from cache if found. + + Returns + -------- + Optional[:class:`.abc.GuildChannel`] + The resolved guild channel or ``None`` if not found in cache. + """ + guild = self._state._get_guild(self.guild_id) + if guild is not None: + return guild.get_channel(self.id) + return None + + async def fetch(self) -> GuildChannel: + """|coro| + + Fetches the partial channel to a full :class:`.abc.GuildChannel`. + + Raises + -------- + NotFound + The channel was not found. + Forbidden + You do not have the permissions required to get a channel. + HTTPException + Retrieving the channel failed. + + Returns + -------- + :class:`.abc.GuildChannel` + The full channel. + """ + client = self._state._get_client() + return await client.fetch_channel(self.id) # type: ignore -- This is explicit narrowing + + @property + def mention(self) -> str: + """:class:`str`: The string that allows you to mention the channel.""" + return f'<#{self.id}>' + + @property + def created_at(self) -> datetime: + """:class:`datetime.datetime`: An aware timestamp of when this channel was created in UTC.""" + return snowflake_time(self.id) + + +class AppCommandThread(Hashable): + """Represents an application command partially resolved thread object. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two thread are equal. + + .. describe:: x != y + + Checks if two thread are not equal. + + .. describe:: hash(x) + + Returns the thread's hash. + + .. describe:: str(x) + + Returns the thread's name. + + Attributes + ----------- + id: :class:`int` + The ID of the thread. + type: :class:`~discord.ChannelType` + The type of thread. + name: :class:`str` + The name of the thread. + parent_id: :class:`int` + The parent text channel ID this thread belongs to. + permissions: :class:`~discord.Permissions` + The resolved permissions of the user who invoked + the application command in that thread. + guild_id: :class:`int` + The guild ID this thread belongs to. + archived: :class:`bool` + Whether the thread is archived. + locked: :class:`bool` + Whether the thread is locked. + invitable: :class:`bool` + Whether non-moderators can add other non-moderators to this thread. + This is always ``True`` for public threads. + archiver_id: Optional[:class:`int`] + The user's ID that archived this thread. + auto_archive_duration: :class:`int` + The duration in minutes until the thread is automatically archived due to inactivity. + Usually a value of 60, 1440, 4320 and 10080. + archive_timestamp: :class:`datetime.datetime` + An aware timestamp of when the thread's archived status was last updated in UTC. + """ + + __slots__ = ( + 'id', + 'type', + 'name', + 'permissions', + 'guild_id', + 'parent_id', + 'archived', + 'archiver_id', + 'auto_archive_duration', + 'archive_timestamp', + 'locked', + 'invitable', + '_created_at', + '_state', + ) + + def __init__( + self, + *, + state: ConnectionState, + data: PartialThread, + guild_id: int, + ): + self._state = state + self.guild_id = guild_id + self.id = int(data['id']) + self.parent_id = int(data['parent_id']) + self.type = try_enum(ChannelType, data['type']) + self.name = data['name'] + self.permissions = Permissions(int(data['permissions'])) + self._unroll_metadata(data['thread_metadata']) + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} archived={self.archived} type={self.type!r}>' + + @property + def guild(self) -> Optional[Guild]: + """Optional[:class:`~discord.Guild`]: The channel's guild, from cache, if found.""" + return self._state._get_guild(self.guild_id) + + def _unroll_metadata(self, data: ThreadMetadata): + self.archived = data['archived'] + self.archiver_id = _get_as_snowflake(data, 'archiver_id') + self.auto_archive_duration = data['auto_archive_duration'] + self.archive_timestamp = parse_time(data['archive_timestamp']) + self.locked = data.get('locked', False) + self.invitable = data.get('invitable', True) + self._created_at = parse_time(data.get('create_timestamp')) + + @property + def parent(self) -> Optional[TextChannel]: + """Optional[:class:`TextChannel`]: The parent channel this thread belongs to.""" + return self.guild.get_channel(self.parent_id) # type: ignore + + @property + def mention(self) -> str: + """:class:`str`: The string that allows you to mention the thread.""" + return f'<#{self.id}>' + + @property + def created_at(self) -> Optional[datetime]: + """An aware timestamp of when the thread was created in UTC. + + .. note:: + + This timestamp only exists for threads created after 9 January 2022, otherwise returns ``None``. + """ + return self._created_at + + def resolve(self) -> Optional[Thread]: + """Resolves the application command channel to the appropriate channel + from cache if found. + + Returns + -------- + Optional[:class:`.abc.GuildChannel`] + The resolved guild channel or ``None`` if not found in cache. + """ + guild = self._state._get_guild(self.guild_id) + if guild is not None: + return guild.get_thread(self.id) + return None + + async def fetch(self) -> Thread: + """|coro| + + Fetches the partial channel to a full :class:`~discord.Thread`. + + Raises + -------- + NotFound + The thread was not found. + Forbidden + You do not have the permissions required to get a thread. + HTTPException + Retrieving the thread failed. + + Returns + -------- + :class:`~discord.Thread` + The full thread. + """ + client = self._state._get_client() + return await client.fetch_channel(self.id) # type: ignore -- This is explicit narrowing + + +class Argument: + """Represents a application command argument. + + .. versionadded:: 2.0 + + Attributes + ------------ + type: :class:`AppCommandOptionType` + The type of argument. + name: :class:`str` + The name of the argument. + description: :class:`str` + The description of the argument. + required: :class:`bool` + Whether the argument is required. + choices: List[:class:`Choice`] + A list of choices for the command to choose from for this argument. + parent: Union[:class:`AppCommand`, :class:`AppCommandGroup`] + The parent application command that has this argument. + """ + + __slots__ = ( + 'type', + 'name', + 'description', + 'required', + 'choices', + 'parent', + '_state', + ) + + def __init__(self, *, parent: ApplicationCommandParent, data: ApplicationCommandOption, state=None): + self._state = state + self.parent = parent + self._from_data(data) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name={self.name!r} type={self.type!r} required={self.required}>' + + def _from_data(self, data: ApplicationCommandOption): + self.type: AppCommandOptionType = try_enum(AppCommandOptionType, data['type']) + self.name: str = data['name'] + self.description: str = data['description'] + self.required: bool = data.get('required', False) + self.choices: List[Choice] = [Choice(name=d['name'], value=d['value']) for d in data.get('choices', [])] + + def to_dict(self) -> ApplicationCommandOption: + return { + 'name': self.name, + 'type': self.type.value, + 'description': self.description, + 'required': self.required, + 'choices': [choice.to_dict() for choice in self.choices], + 'options': [], + } # type: ignore -- Type checker does not understand this literal. + + +class AppCommandGroup: + """Represents a application command subcommand. + + .. versionadded:: 2.0 + + Attributes + ------------ + type: :class:`ApplicationCommandOptionType` + The type of subcommand. + name: :class:`str` + The name of the subcommand. + description: :class:`str` + The description of the subcommand. + required: :class:`bool` + Whether the subcommand is required. + choices: List[:class:`Choice`] + A list of choices for the command to choose from for this subcommand. + arguments: List[:class:`Argument`] + A list of arguments. + parent: Union[:class:`AppCommand`, :class:`AppCommandGroup`] + The parent application command. + """ + + __slots__ = ( + 'type', + 'name', + 'description', + 'required', + 'choices', + 'arguments', + 'parent', + '_state', + ) + + def __init__(self, *, parent: ApplicationCommandParent, data: ApplicationCommandOption, state=None): + self.parent = parent + self._state = state + self._from_data(data) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name={self.name!r} type={self.type!r} required={self.required}>' + + def _from_data(self, data: ApplicationCommandOption): + self.type: AppCommandOptionType = try_enum(AppCommandOptionType, data['type']) + self.name: str = data['name'] + self.description: str = data['description'] + self.required: bool = data.get('required', False) + self.choices: List[Choice] = [Choice(name=d['name'], value=d['value']) for d in data.get('choices', [])] + self.arguments: List[Argument] = [ + Argument(parent=self, state=self._state, data=d) + for d in data.get('options', []) + if is_app_command_argument_type(d['type']) + ] + + def to_dict(self) -> 'ApplicationCommandOption': + return { + 'name': self.name, + 'type': self.type.value, + 'description': self.description, + 'required': self.required, + 'choices': [choice.to_dict() for choice in self.choices], + 'options': [arg.to_dict() for arg in self.arguments], + } # type: ignore -- Type checker does not understand this literal. + + +def app_command_option_factory( + parent: ApplicationCommandParent, data: ApplicationCommandOption, *, state=None +) -> Union[Argument, AppCommandGroup]: + if is_app_command_argument_type(data['type']): + return Argument(parent=parent, data=data, state=state) + else: + return AppCommandGroup(parent=parent, data=data, state=state) diff --git a/discord/app_commands/namespace.py b/discord/app_commands/namespace.py new file mode 100644 index 000000000..25e85388e --- /dev/null +++ b/discord/app_commands/namespace.py @@ -0,0 +1,160 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Tuple +from ..interactions import Interaction +from ..member import Member +from ..object import Object +from ..role import Role +from ..message import Message, Attachment +from .models import AppCommandChannel, AppCommandThread + +if TYPE_CHECKING: + from ..types.interactions import ResolvedData, ApplicationCommandInteractionDataOption + + +class Namespace: + """An object that holds the parameters being passed to a command in a mostly raw state. + + This class is deliberately simple and just holds the option name and resolved value as a simple + key-pair mapping. These attributes can be accessed using dot notation. For example, an option + with the name of ``example`` can be accessed using ``ns.example``. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two namespaces are equal by checking if all attributes are equal. + .. describe:: x != y + + Checks if two namespaces are not equal. + + This namespace object converts resolved objects into their appropriate form depending on their + type. Consult the table below for conversion information. + + +------------------------------------------+-------------------------------------------------------------------------------+ + | Option Type | Resolved Type | + +==========================================+===============================================================================+ + | :attr:`AppCommandOptionType.string` | :class:`str` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.integer` | :class:`int` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.boolean` | :class:`bool` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.number` | :class:`float` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.user` | :class:`~discord.User` or :class:`~discord.Member` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.channel` | :class:`.AppCommandChannel` or :class:`.AppCommandThread` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.role` | :class:`~discord.Role` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.mentionable` | :class:`~discord.User` or :class:`~discord.Member`, or :class:`~discord.Role` | + +------------------------------------------+-------------------------------------------------------------------------------+ + | :attr:`AppCommandOptionType.attachment` | :class:`~discord.Attachment` | + +------------------------------------------+-------------------------------------------------------------------------------+ + """ + + def __init__( + self, + interaction: Interaction, + resolved: ResolvedData, + options: List[ApplicationCommandInteractionDataOption], + ): + completed: Dict[str, Any] = {} + state = interaction._state + members = resolved.get('members', {}) + guild_id = interaction.guild_id + guild = (state._get_guild(guild_id) or Object(id=guild_id)) if guild_id is not None else None + for (user_id, user_data) in resolved.get('users', {}).items(): + try: + member_data = members[user_id] + except KeyError: + completed[user_id] = state.create_user(user_data) + else: + member_data['user'] = user_data + # Guild ID can't be None in this case. + # There's a type mismatch here that I don't actually care about + member = Member(state=state, guild=guild, data=member_data) # type: ignore + completed[user_id] = member + + completed.update( + { + # The guild ID can't be None in this case. + role_id: Role(guild=guild, state=state, data=role_data) # type: ignore + for role_id, role_data in resolved.get('roles', {}).items() + } + ) + + for (channel_id, channel_data) in resolved.get('channels', {}).items(): + if channel_data['type'] in (10, 11, 12): + # The guild ID can't be none in this case + completed[channel_id] = AppCommandThread(state=state, data=channel_data, guild_id=guild_id) # type: ignore + else: + # The guild ID can't be none in this case + completed[channel_id] = AppCommandChannel(state=state, data=channel_data, guild_id=guild_id) # type: ignore + + completed.update( + { + attachment_id: Attachment(data=attachment_data, state=state) + for attachment_id, attachment_data in resolved.get('attachments', {}).items() + } + ) + + # TODO: messages + + for option in options: + opt_type = option['type'] + name = option['name'] + if opt_type in (3, 4, 5): # string, integer, boolean + value = option['value'] # type: ignore -- Key is there + self.__dict__[name] = value + elif opt_type == 10: # number + value = option['value'] # type: ignore -- Key is there + if value is None: + self.__dict__[name] = float('nan') + else: + self.__dict__[name] = float(value) + elif opt_type in (6, 7, 8, 9, 11): + # Remaining ones should be snowflake based ones with resolved data + snowflake: str = option['value'] # type: ignore -- Key is there + value = completed.get(snowflake) + self.__dict__[name] = value + + def __repr__(self) -> str: + items = (f'{k}={v!r}' for k, v in self.__dict__.items()) + return '<{} {}>'.format(self.__class__.__name__, ' '.join(items)) + + def __eq__(self, other: object) -> bool: + if isinstance(self, Namespace) and isinstance(other, Namespace): + return self.__dict__ == other.__dict__ + return NotImplemented + + def _update_with_defaults(self, defaults: Iterable[Tuple[str, Any]]) -> None: + for key, value in defaults: + self.__dict__.setdefault(key, value) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py new file mode 100644 index 000000000..6d7e63398 --- /dev/null +++ b/discord/app_commands/tree.py @@ -0,0 +1,416 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations +import inspect +from typing import Callable, Dict, List, Optional, TYPE_CHECKING, Tuple, Type, Union + + +from .namespace import Namespace +from .models import AppCommand +from .commands import Command, Group, _shorten +from .enums import AppCommandType +from .errors import CommandAlreadyRegistered, CommandNotFound, CommandSignatureMismatch +from ..errors import ClientException +from ..utils import MISSING + +if TYPE_CHECKING: + from ..types.interactions import ApplicationCommandInteractionData, ApplicationCommandInteractionDataOption + from ..interactions import Interaction + from ..client import Client + from ..abc import Snowflake + from .commands import CommandCallback, P, T + +__all__ = ('CommandTree',) + + +class CommandTree: + """Represents a container that holds application command information. + + Parameters + ----------- + client: :class:`Client` + The client instance to get application command information from. + """ + + def __init__(self, client: Client): + self.client = client + self._http = client.http + self._state = client._connection + self._state._command_tree = self + self._guild_commands: Dict[int, Dict[str, Union[Command, Group]]] = {} + self._global_commands: Dict[str, Union[Command, Group]] = {} + # (name, guild_id, command_type): Command + # The above two mappings can use this structure too but we need fast retrieval + # by name and guild_id in the above case while here it isn't as important since + # it's uncommon and N=5 anyway. + self._context_menus: Dict[Tuple[str, Optional[int], int], Command] = {} + + async def fetch_commands(self, *, guild: Optional[Snowflake] = None) -> List[AppCommand]: + """|coro| + + Fetches the application's current commands. + + If no guild is passed then global commands are fetched, otherwise + the guild's commands are fetched instead. + + Parameters + ----------- + guild: Optional[:class:`abc.Snowflake`] + The guild to fetch the commands from. If not passed then global commands + are fetched instead. + + Raises + ------- + HTTPException + Fetching the commands failed. + ClientException + The application ID could not be found. + + Returns + -------- + List[:class:`~discord.app_commands.AppCommand`] + The application's commands. + """ + if self.client.application_id is None: + raise ClientException('Client does not have an application ID set') + + if guild is None: + commands = await self._http.get_global_commands(self.client.application_id) + else: + commands = await self._http.get_guild_commands(self.client.application_id, guild.id) + + return [AppCommand(data=data, state=self._state) for data in commands] + + def add_command(self, command: Union[Command, Group], /, *, guild: Optional[Snowflake] = None, override: bool = False): + """Adds an application command to the tree. + + This only adds the command locally -- in order to sync the commands + and enable them in the client, :meth:`sync` must be called. + + The root parent of the command is added regardless of the type passed. + + Parameters + ----------- + command: Union[:class:`Command`, :class:`Group`] + The application command or group to add. + guild: Optional[:class:`abc.Snowflake`] + The guild to add the command to. If not given then it + becomes a global command instead. + override: :class:`bool` + Whether to override a command with the same name. If ``False`` + an exception is raised. Default is ``False``. + + Raises + -------- + ~discord.CommandAlreadyRegistered + The command was already registered and no override was specified. + TypeError + The application command passed is not a valid application command. + ValueError + The maximum number of commands was reached globally or for that guild. + This is currently 100 for slash commands and 5 for context menu commands. + """ + + if not isinstance(command, (Command, Group)): + raise TypeError(f'Expected a application command, received {command.__class__!r} instead') + + # todo: validate application command groups having children (required) + + root = command.root_parent or command + name = root.name + if guild is not None: + commands = self._guild_commands.setdefault(guild.id, {}) + found = name in commands + if found and not override: + raise CommandAlreadyRegistered(name, guild.id) + if len(commands) + found > 100: + raise ValueError('maximum number of slash commands exceeded (100)') + commands[name] = root + else: + found = name in self._global_commands + if found and not override: + raise CommandAlreadyRegistered(name, None) + if len(self._global_commands) + found > 100: + raise ValueError('maximum number of slash commands exceeded (100)') + self._global_commands[name] = root + + def remove_command(self, command: str, /, *, guild: Optional[Snowflake] = None) -> Optional[Union[Command, Group]]: + """Removes an application command from the tree. + + This only removes the command locally -- in order to sync the commands + and remove them in the client, :meth:`sync` must be called. + + Parameters + ----------- + command: :class:`str` + The name of the root command to remove. + guild: Optional[:class:`abc.Snowflake`] + The guild to remove the command from. If not given then it + removes a global command instead. + + Returns + --------- + Optional[Union[:class:`Command`, :class:`Group`]] + The application command that got removed. + If nothing was removed then ``None`` is returned instead. + """ + + if guild is None: + return self._global_commands.pop(command, None) + else: + try: + commands = self._guild_commands[guild.id] + except KeyError: + return None + else: + return commands.pop(command, None) + + def get_command(self, command: str, /, *, guild: Optional[Snowflake] = None) -> Optional[Union[Command, Group]]: + """Gets a application command from the tree. + + .. note:: + + This does *not* include context menu commands. + + Parameters + ----------- + command: :class:`str` + The name of the root command to get. + guild: Optional[:class:`abc.Snowflake`] + The guild to get the command from. If not given then it + gets a global command instead. + + Returns + --------- + Optional[Union[:class:`Command`, :class:`Group`]] + The application command that was found. + If nothing was found then ``None`` is returned instead. + """ + + if guild is None: + return self._global_commands.get(command) + else: + try: + commands = self._guild_commands[guild.id] + except KeyError: + return None + else: + return commands.get(command) + + def get_commands(self, *, guild: Optional[Snowflake] = None) -> List[Union[Command, Group]]: + """Gets all application commands from the tree. + + .. note:: + + This does *not* retrieve context menu commands. + + Parameters + ----------- + guild: Optional[:class:`~discord.abc.Snowflake`] + The guild to get the commands from. If not given then it + gets all global commands instead. + + Returns + --------- + List[Union[:class:`Command`, :class:`Group`]] + The application commands from the tree. + """ + + if guild is None: + return list(self._global_commands.values()) + else: + try: + commands = self._guild_commands[guild.id] + except KeyError: + return [] + else: + return list(commands.values()) + + def command( + self, + *, + name: str = MISSING, + description: str = MISSING, + guild: Optional[Snowflake] = None, + ) -> Callable[[CommandCallback[Group, P, T]], Command[Group, P, T]]: + """Creates an application command directly under this tree. + + Parameters + ------------ + name: :class:`str` + The name of the application command. If not given, it defaults to a lower-case + version of the callback name. + description: :class:`str` + The description of the application command. This shows up in the UI to describe + the application command. If not given, it defaults to the first line of the docstring + of the callback shortened to 100 characters. + guild: Optional[:class:`Snowflake`] + The guild to add the command to. If not given then it + becomes a global command instead. + """ + + def decorator(func: CommandCallback[Group, P, T]) -> Command[Group, P, T]: + if not inspect.iscoroutinefunction(func): + raise TypeError('command function must be a coroutine function') + + if description is MISSING: + if func.__doc__ is None: + desc = '...' + else: + desc = _shorten(func.__doc__) + else: + desc = description + + command = Command( + name=name if name is not MISSING else func.__name__, + description=desc, + callback=func, + type=AppCommandType.chat_input, + parent=None, + ) + self.add_command(command, guild=guild) + return command + + return decorator + + async def sync(self, *, guild: Optional[Snowflake]) -> List[AppCommand]: + """|coro| + + Syncs the application commands to Discord. + + This must be called for the application commands to show up. + + Global commands take up to 1-hour to propagate but guild + commands propagate instantly. + + Parameters + ----------- + guild: Optional[:class:`~discord.abc.Snowflake`] + The guild to sync the commands to. If ``None`` then it + syncs all global commands instead. + + Raises + ------- + HTTPException + Syncing the commands failed. + ClientException + The client does not have an application ID. + + Returns + -------- + List[:class:`~discord.AppCommand`] + The application's commands that got synced. + """ + + if self.client.application_id is None: + raise ClientException('Client does not have an application ID set') + + commands = self.get_commands(guild=guild) + payload = [command.to_dict() for command in commands] + if guild is None: + data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload) + else: + data = await self._http.bulk_upsert_guild_commands(self.client.application_id, guild.id, payload=payload) + + return [AppCommand(data=d, state=self._state) for d in data] + + def _from_interaction(self, interaction: Interaction): + async def wrapper(): + try: + await self.call(interaction) + except Exception as e: + print(f'Error:', e) + + self.client.loop.create_task(wrapper(), name='CommandTree-invoker') + + async def call(self, interaction: Interaction): + """|coro| + + Given an :class:`~discord.Interaction`, calls the matching + application command that's being invoked. + + This is usually called automatically by the library. + + Parameters + ----------- + interaction: :class:`~discord.Interaction` + The interaction to dispatch from. + + Raises + -------- + CommandNotFound + The application command referred to could not be found. + CommandSignatureMismatch + The interaction data referred to a parameter that was not found in the + application command definition. + """ + data: ApplicationCommandInteractionData = interaction.data # type: ignore + parents: List[str] = [] + name = data['name'] + command = self._global_commands.get(name) + if interaction.guild_id: + try: + guild_commands = self._guild_commands[interaction.guild_id] + except KeyError: + pass + else: + command = guild_commands.get(name) or command + + # 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) + + # 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) + await command._invoke_with_namespace(interaction, namespace) diff --git a/discord/state.py b/discord/state.py index 614b7d2c6..6c060f5a5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -69,6 +69,7 @@ if TYPE_CHECKING: from .voice_client import VoiceProtocol from .client import Client from .gateway import DiscordWebSocket + from .app_commands import CommandTree from .types.snowflake import Snowflake from .types.activity import Activity as ActivityPayload @@ -227,6 +228,7 @@ class ConnectionState: self._activity: Optional[ActivityPayload] = activity self._status: Optional[str] = status self._intents: Intents = intents + self._command_tree: Optional[CommandTree] = None if not intents.members or cache_flags._empty: self.store_user = self.store_user_no_intents # type: ignore - This reassignment is on purpose @@ -690,7 +692,9 @@ class ConnectionState: def parse_interaction_create(self, data: gw.InteractionCreateEvent) -> None: interaction = Interaction(data=data, state=self) - if data['type'] == 3: # interaction component + if data['type'] == 2 and self._command_tree: # application command + self._command_tree._from_interaction(interaction) + elif data['type'] == 3: # interaction component # These keys are always there for this interaction type custom_id = interaction.data['custom_id'] # type: ignore component_type = interaction.data['component_type'] # type: ignore diff --git a/setup.py b/setup.py index fcbae9591..29a4c12f9 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ packages = [ 'discord.types', 'discord.ui', 'discord.webhook', + 'discord.app_commands', 'discord.ext.commands', 'discord.ext.tasks', ]