Browse Source

Implement slash commands

pull/7492/head
Rapptz 3 years ago
parent
commit
0d2db90028
  1. 2
      discord/__init__.py
  2. 16
      discord/app_commands/__init__.py
  3. 743
      discord/app_commands/commands.py
  4. 53
      discord/app_commands/enums.py
  5. 96
      discord/app_commands/errors.py
  6. 592
      discord/app_commands/models.py
  7. 160
      discord/app_commands/namespace.py
  8. 416
      discord/app_commands/tree.py
  9. 6
      discord/state.py
  10. 1
      setup.py

2
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 *

16
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 *

743
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'(?<!^)(?=[A-Z])')
def _shorten(
input: str,
*,
_wrapper: TextWrapper = TextWrapper(width=100, max_lines=1, replace_whitespace=True, placeholder='...'),
) -> 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 <discord.app_commands.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 <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

53
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

96
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)

592
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)

160
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)

416
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)

6
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

1
setup.py

@ -58,6 +58,7 @@ packages = [
'discord.types',
'discord.ui',
'discord.webhook',
'discord.app_commands',
'discord.ext.commands',
'discord.ext.tasks',
]

Loading…
Cancel
Save