From 74b5e0ceb14dea04b9243013c4619dafc7bc7664 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 28 Mar 2022 15:55:29 -0400 Subject: [PATCH] Implement some built in checks for app_commands A lot of these implementations are adapted from the equivalent ext.commands checks. These only implement the common ones that could not solely be done by Discord in the future. --- discord/app_commands/__init__.py | 1 + discord/app_commands/checks.py | 238 +++++++++++++++++++++++++++++++ discord/app_commands/errors.py | 121 ++++++++++++++++ docs/interactions/api.rst | 41 +++++- 4 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 discord/app_commands/checks.py diff --git a/discord/app_commands/__init__.py b/discord/app_commands/__init__.py index 3e88e4d4a..959a22d81 100644 --- a/discord/app_commands/__init__.py +++ b/discord/app_commands/__init__.py @@ -15,3 +15,4 @@ from .models import * from .tree import * from .namespace import * from .transformers import * +from . import checks as checks diff --git a/discord/app_commands/checks.py b/discord/app_commands/checks.py new file mode 100644 index 000000000..762b23c4a --- /dev/null +++ b/discord/app_commands/checks.py @@ -0,0 +1,238 @@ +""" +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 ( + Union, + Callable, + TypeVar, + TYPE_CHECKING, +) + +from .commands import check +from .errors import ( + NoPrivateMessage, + MissingRole, + MissingAnyRole, + MissingPermissions, + BotMissingPermissions, +) + +from ..user import User +from ..permissions import Permissions +from ..utils import get as utils_get + +T = TypeVar('T') + +if TYPE_CHECKING: + from ..interactions import Interaction + +__all__ = ( + 'has_role', + 'has_any_role', + 'has_permissions', + 'bot_has_permissions', +) + + +def has_role(item: Union[int, str], /) -> Callable[[T], T]: + """A :func:`~discord.app_commands.check` that is added that checks if the member invoking the + command has the role specified via the name or ID specified. + + If a string is specified, you must give the exact name of the role, including + caps and spelling. + + If an integer is specified, you must give the exact snowflake ID of the role. + + This check raises one of two special exceptions, :exc:`~discord.app_commands.MissingRole` + if the user is missing a role, or :exc:`~discord.app_commands.NoPrivateMessage` if + it is used in a private message. Both inherit from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + .. note:: + + This is different from the permission system that Discord provides for application + commands. This is done entirely locally in the program rather than being handled + by Discord. + + Parameters + ----------- + item: Union[:class:`int`, :class:`str`] + The name or ID of the role to check. + """ + + def predicate(interaction: Interaction) -> bool: + if isinstance(interaction.user, User): + raise NoPrivateMessage() + + if isinstance(item, int): + role = interaction.user.get_role(item) + else: + role = utils_get(interaction.user.roles, name=item) + + if role is None: + raise MissingRole(item) + return True + + return check(predicate) + + +def has_any_role(*items: Union[int, str]) -> Callable[[T], T]: + r"""A :func:`~discord.app_commands.check` that is added that checks if the member + invoking the command has **any** of the roles specified. This means that if they have + one out of the three roles specified, then this check will return ``True``. + + Similar to :func:`has_role`\, the names or IDs passed in must be exact. + + This check raises one of two special exceptions, :exc:`~discord.app_commands.MissingAnyRole` + if the user is missing all roles, or :exc:`~discord.app_commands.NoPrivateMessage` if + it is used in a private message. Both inherit from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + .. note:: + + This is different from the permission system that Discord provides for application + commands. This is done entirely locally in the program rather than being handled + by Discord. + + Parameters + ----------- + items: List[Union[:class:`str`, :class:`int`]] + An argument list of names or IDs to check that the member has roles wise. + + Example + -------- + + .. code-block:: python3 + + @tree.command() + @app_commands.checks.has_any_role('Library Devs', 'Moderators', 492212595072434186) + async def cool(interaction: discord.Interaction): + await interaction.response.send_message('You are cool indeed') + """ + + def predicate(interaction: Interaction) -> bool: + if isinstance(interaction.user, User): + raise NoPrivateMessage() + + if any( + interaction.user.get_role(item) is not None + if isinstance(item, int) + else utils_get(interaction.user.roles, name=item) is not None + for item in items + ): + return True + raise MissingAnyRole(list(items)) + + return check(predicate) + + +def has_permissions(**perms: bool) -> Callable[[T], T]: + r"""A :func:`~discord.app_commands.check` that is added that checks if the member + has all of the permissions necessary. + + Note that this check operates on the permissions given by + :attr:`discord.Interaction.permissions`. + + The permissions passed in must be exactly like the properties shown under + :class:`discord.Permissions`. + + This check raises a special exception, :exc:`~discord.app_commands.MissingPermissions` + that is inherited from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + .. note:: + + This is different from the permission system that Discord provides for application + commands. This is done entirely locally in the program rather than being handled + by Discord. + + Parameters + ------------ + \*\*perms: :class:`bool` + Keyword arguments denoting the permissions to check for. + + Example + --------- + + .. code-block:: python3 + + @tree.command() + @app_commands.checks.has_permissions(manage_messages=True) + async def test(interaction: discord.Interaction): + await interaction.response.send_message('You can manage messages.') + + """ + + invalid = perms.keys() - Permissions.VALID_FLAGS.keys() + if invalid: + raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + + def predicate(interaction: Interaction) -> bool: + permissions = interaction.permissions + + missing = [perm for perm, value in perms.items() if getattr(permissions, perm) != value] + + if not missing: + return True + + raise MissingPermissions(missing) + + return check(predicate) + + +def bot_has_permissions(**perms: bool) -> Callable[[T], T]: + """Similar to :func:`has_permissions` except checks if the bot itself has + the permissions listed. + + This check raises a special exception, :exc:`~discord.app_commands.BotMissingPermissions` + that is inherited from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + """ + + invalid = set(perms) - set(Permissions.VALID_FLAGS) + if invalid: + raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + + def predicate(interaction: Interaction) -> bool: + guild = interaction.guild + me = guild.me if guild is not None else interaction.client.user + if interaction.channel is None: + permissions = Permissions.none() + else: + permissions = interaction.channel.permissions_for(me) # type: ignore + + missing = [perm for perm, value in perms.items() if getattr(permissions, perm) != value] + + if not missing: + return True + + raise BotMissingPermissions(missing) + + return check(predicate) diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py index d6e8ddc27..7f14bc3a6 100644 --- a/discord/app_commands/errors.py +++ b/discord/app_commands/errors.py @@ -38,11 +38,17 @@ __all__ = ( 'CommandAlreadyRegistered', 'CommandSignatureMismatch', 'CommandNotFound', + 'NoPrivateMessage', + 'MissingRole', + 'MissingAnyRole', + 'MissingPermissions', + 'BotMissingPermissions', ) if TYPE_CHECKING: from .commands import Command, Group, ContextMenu from .transformers import Transformer + from ..types.snowflake import Snowflake, SnowflakeList class AppCommandError(DiscordException): @@ -141,6 +147,121 @@ class CheckFailure(AppCommandError): pass +class NoPrivateMessage(CheckFailure): + """An exception raised when a command does not work in a direct message. + + This inherits from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + """ + + def __init__(self, message: Optional[str] = None) -> None: + super().__init__(message or 'This command cannot be used in direct messages.') + + +class MissingRole(CheckFailure): + """An exception raised when the command invoker lacks a role to run a command. + + This inherits from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + Attributes + ----------- + missing_role: Union[:class:`str`, :class:`int`] + The required role that is missing. + This is the parameter passed to :func:`~discord.app_commands.checks.has_role`. + """ + + def __init__(self, missing_role: Snowflake) -> None: + self.missing_role: Snowflake = missing_role + message = f'Role {missing_role!r} is required to run this command.' + super().__init__(message) + + +class MissingAnyRole(CheckFailure): + """An exception raised when the command invoker lacks any of the roles + specified to run a command. + + This inherits from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + Attributes + ----------- + missing_roles: List[Union[:class:`str`, :class:`int`]] + The roles that the invoker is missing. + These are the parameters passed to :func:`~discord.app_commands.checks.has_any_role`. + """ + + def __init__(self, missing_roles: SnowflakeList) -> None: + self.missing_roles: SnowflakeList = missing_roles + + missing = [f"'{role}'" for role in missing_roles] + + if len(missing) > 2: + fmt = '{}, or {}'.format(', '.join(missing[:-1]), missing[-1]) + else: + fmt = ' or '.join(missing) + + message = f'You are missing at least one of the required roles: {fmt}' + super().__init__(message) + + +class MissingPermissions(CheckFailure): + """An exception raised when the command invoker lacks permissions to run a + command. + + This inherits from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + Attributes + ----------- + missing_permissions: List[:class:`str`] + The required permissions that are missing. + """ + + def __init__(self, missing_permissions: List[str], *args: Any) -> None: + self.missing_permissions: List[str] = missing_permissions + + missing = [perm.replace('_', ' ').replace('guild', 'server').title() for perm in missing_permissions] + + if len(missing) > 2: + fmt = '{}, and {}'.format(", ".join(missing[:-1]), missing[-1]) + else: + fmt = ' and '.join(missing) + message = f'You are missing {fmt} permission(s) to run this command.' + super().__init__(message, *args) + + +class BotMissingPermissions(CheckFailure): + """An exception raised when the bot's member lacks permissions to run a + command. + + This inherits from :exc:`~discord.app_commands.CheckFailure`. + + .. versionadded:: 2.0 + + Attributes + ----------- + missing_permissions: List[:class:`str`] + The required permissions that are missing. + """ + + def __init__(self, missing_permissions: List[str], *args: Any) -> None: + self.missing_permissions: List[str] = missing_permissions + + missing = [perm.replace('_', ' ').replace('guild', 'server').title() for perm in missing_permissions] + + if len(missing) > 2: + fmt = '{}, and {}'.format(", ".join(missing[:-1]), missing[-1]) + else: + fmt = ' and '.join(missing) + message = f'Bot requires {fmt} permission(s) to run this command.' + super().__init__(message, *args) + + class CommandAlreadyRegistered(AppCommandError): """An exception raised when a command is already registered. diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 5697009ad..3513d4c4e 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -468,15 +468,32 @@ Decorators .. autofunction:: discord.app_commands.choices :decorator: +.. autofunction:: discord.app_commands.autocomplete + :decorator: + +.. autofunction:: discord.app_commands.guilds + :decorator: + +Checks ++++++++ + .. autofunction:: discord.app_commands.check :decorator: -.. autofunction:: discord.app_commands.autocomplete +.. autofunction:: discord.app_commands.checks.has_role :decorator: -.. autofunction:: discord.app_commands.guilds +.. autofunction:: discord.app_commands.checks.has_any_role + :decorator: + +.. autofunction:: discord.app_commands.checks.has_permissions + :decorator: + +.. autofunction:: discord.app_commands.checks.bot_has_permissions :decorator: + + Namespace ~~~~~~~~~~ @@ -527,6 +544,21 @@ Exceptions .. autoexception:: discord.app_commands.CheckFailure :members: +.. autoexception:: discord.app_commands.NoPrivateMessage + :members: + +.. autoexception:: discord.app_commands.MissingRole + :members: + +.. autoexception:: discord.app_commands.MissingAnyRole + :members: + +.. autoexception:: discord.app_commands.MissingPermissions + :members: + +.. autoexception:: discord.app_commands.BotMissingPermissions + :members: + .. autoexception:: discord.app_commands.CommandAlreadyRegistered :members: @@ -546,6 +578,11 @@ Exception Hierarchy - :exc:`~discord.app_commands.CommandInvokeError` - :exc:`~discord.app_commands.TransformerError` - :exc:`~discord.app_commands.CheckFailure` + - :exc:`~discord.app_commands.NoPrivateMessage` + - :exc:`~discord.app_commands.MissingRole` + - :exc:`~discord.app_commands.MissingAnyRole` + - :exc:`~discord.app_commands.MissingPermissions` + - :exc:`~discord.app_commands.BotMissingPermissions` - :exc:`~discord.app_commands.CommandAlreadyRegistered` - :exc:`~discord.app_commands.CommandSignatureMismatch` - :exc:`~discord.app_commands.CommandNotFound`