From c31c48f8535eb1401f922a8f965cfada7cef959f Mon Sep 17 00:00:00 2001 From: Michael Oliveira <34169552+Flame442@users.noreply.github.com> Date: Tue, 9 Jul 2024 20:24:39 -0400 Subject: [PATCH 1/6] Implement new checks --- redbot/core/app_commands/checks.py | 380 +++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) diff --git a/redbot/core/app_commands/checks.py b/redbot/core/app_commands/checks.py index 30ccc466fce..a7ef5387edd 100644 --- a/redbot/core/app_commands/checks.py +++ b/redbot/core/app_commands/checks.py @@ -15,6 +15,11 @@ has_permissions, ) +import discord +import enum +from typing import Dict, Optional +from . import BotMissingPermissions, check + __all__ = ( "bot_has_permissions", "cooldown", @@ -22,4 +27,379 @@ "has_any_role", "has_role", "has_permissions", + "is_owner", + "guildowner", + "admin", + "mod", + "guildowner_or_permissions", + "admin_or_permissions", + "mod_or_permissions", + "can_manage_channel", + "admin_or_can_manage_channel", + "mod_or_can_manage_channel", + "bot_can_manage_channel", + "bot_can_react", + "bot_in_a_guild", ) + + +class PrivilegeLevel(enum.IntEnum): + """Enumeration for special privileges.""" + + # Maintainer Note: do NOT re-order these. + # Each privilege level also implies access to the ones before it. + # Inserting new privilege levels at a later point is fine if that is considered. + + NONE = enum.auto() + """No special privilege level.""" + + MOD = enum.auto() + """User has the mod role.""" + + ADMIN = enum.auto() + """User has the admin role.""" + + GUILD_OWNER = enum.auto() + """User is the guild level.""" + + BOT_OWNER = enum.auto() + """User is a bot owner.""" + + @classmethod + async def from_interaction(cls, interaction: discord.Interaction) -> "PrivilegeLevel": + """Get a command author's PrivilegeLevel based on an interaction.""" + if await interaction.client.is_owner(interaction.user): + return cls.BOT_OWNER + elif interaction.guild is None: + return cls.NONE + elif interaction.user == interaction.guild.owner: + return cls.GUILD_OWNER + + # The following is simply an optimised way to check if the user has the + # admin or mod role. + guild_settings = interaction.client._config.guild(interaction.guild) + + for snowflake in await guild_settings.admin_role(): + if interaction.user.get_role(snowflake): + return cls.ADMIN + for snowflake in await guild_settings.mod_role(): + if interaction.user.get_role(snowflake): + return cls.MOD + + return cls.NONE + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}.{self.name}>" + + +def _validate_perms_dict(perms: Dict[str, bool]) -> None: + invalid_keys = set(perms.keys()) - set(discord.Permissions.VALID_FLAGS) + if invalid_keys: + raise TypeError(f"Invalid perm name(s): {', '.join(invalid_keys)}") + for perm, value in perms.items(): + if value is not True: + # We reject any permission not specified as 'True', since this is the only value which + # makes practical sense. + raise TypeError(f"Permission {perm} may only be specified as 'True', not {value}") + + +def _permissions_deco( + *, + privilege_level: Optional[PrivilegeLevel] = None, + user_perms: Optional[Dict[str, bool]] = None, +): + if user_perms is not None: + _validate_perms_dict(user_perms) + + async def predicate(interaction: discord.Interaction) -> bool: + if privilege_level is not None: + if await PrivilegeLevel.from_interaction(interaction) >= privilege_level: + return True + + if user_perms is not None: + permissions = interaction.permissions + missing = [ + perm for perm, value in user_perms.items() if getattr(permissions, perm) != value + ] + + if not missing: + return True + + return False + + return check(predicate) + + +def is_owner(): + """ + Restrict the command to bot owners. + + You probably should not use this check, since slash commands are not designed to be owner only. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.BOT_OWNER) + + +def guildowner(): + """ + Restrict the command to the guild owner. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.GUILD_OWNER) + + +def admin(): + """ + Restrict the command to users with the admin role. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.ADMIN) + + +def mod(): + """ + Restrict the command to users with the mod role. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.MOD) + + +def guildowner_or_permissions(**perms: bool): + """ + Restrict the command to the guild owner or users with these permissions. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.GUILD_OWNER, user_perms=perms) + + +def admin_or_permissions(**perms: bool): + """ + Restrict the command to users with the admin role or these permissions. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.ADMIN, user_perms=perms) + + +def mod_or_permissions(**perms: bool): + """ + Restrict the command to users with the mod role or these permissions. + + .. 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. + """ + return _permissions_deco(privilege_level=PrivilegeLevel.MOD, user_perms=perms) + + +def _can_manage_channel_deco( + *, privilege_level: Optional[PrivilegeLevel] = None, allow_thread_owner: bool = False +): + async def predicate(interaction: discord.Interaction) -> bool: + perms = interaction.permissions + if isinstance(interaction.channel, discord.Thread): + if perms.manage_threads or ( + allow_thread_owner and interaction.channel.owner_id == interaction.user.id + ): + return True + else: + if perms.manage_channels: + return True + + if privilege_level is not None: + if await PrivilegeLevel.from_interaction(interaction) >= privilege_level: + return True + + return False + + return check(predicate) + + +def can_manage_channel(*, allow_thread_owner: bool = False): + """ + Restrict the command to users with permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + .. 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 + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco(allow_thread_owner=allow_thread_owner) + + +def admin_or_can_manage_channel(*, allow_thread_owner: bool = False): + """ + Restrict the command to users with the admin role or permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + .. 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 + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco( + privilege_level=PrivilegeLevel.ADMIN, allow_thread_owner=allow_thread_owner + ) + + +def mod_or_can_manage_channel(*, allow_thread_owner: bool = False): + """ + Restrict the command to users with the mod role or permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + .. 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 + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the author is a thread owner. + This can, for example, be useful to check if the author can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + return _can_manage_channel_deco( + privilege_level=PrivilegeLevel.MOD, allow_thread_owner=allow_thread_owner + ) + + +def bot_can_manage_channel(*, allow_thread_owner: bool = False): + """ + Complain if the bot is missing permissions to manage channel. + + This check properly resolves the permissions for `discord.Thread` as well. + + .. 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 + ---------- + allow_thread_owner: bool + If ``True``, the command will also be allowed to run if the bot is a thread owner. + This can, for example, be useful to check if the bot can edit a channel/thread's name + as that, in addition to members with manage channel/threads permission, + can also be done by the thread owner. + """ + + def predicate(interaction: discord.Interaction) -> bool: + if interaction.guild is None: + return False + + perms = interaction.app_permissions + if isinstance(interaction.channel, discord.Thread): + if not ( + perms.manage_threads + or (allow_thread_owner and interaction.channel.owner_id == interaction.client.user.id) + ): + # This is a slight lie - thread owner *might* also be allowed + # but we just say that bot is missing the Manage Threads permission. + raise BotMissingPermissions(["manage_threads"]) + else: + if not perms.manage_channels: + raise BotMissingPermissions(["manage_channels"]) + + return True + + return check(predicate) + + +def bot_can_react(): + """ + Complain if the bot is missing permissions to react. + + This check properly resolves the permissions for `discord.Thread` as well. + + .. 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. + """ + + async def predicate(interaction: discord.Interaction) -> bool: + return not ( + isinstance(interaction.channel, discord.Thread) and interaction.channel.archived + ) + + def decorator(func): + func = bot_has_permissions(read_message_history=True, add_reactions=True)(func) + func = check(predicate)(func) + return func + + return decorator + + +def bot_in_a_guild(): + """ + Deny the command if the bot is not in a guild. + + .. 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. + """ + + async def predicate(interaction: discord.Interaction) -> bool: + return len(interaction.client.guilds) > 0 + + return check(predicate) From 6670add5de17ff34def4952b8be5c2b525199b82 Mon Sep 17 00:00:00 2001 From: Michael Oliveira <34169552+Flame442@users.noreply.github.com> Date: Tue, 9 Jul 2024 20:30:53 -0400 Subject: [PATCH 2/6] Style --- redbot/core/app_commands/checks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/redbot/core/app_commands/checks.py b/redbot/core/app_commands/checks.py index a7ef5387edd..6c024220ed1 100644 --- a/redbot/core/app_commands/checks.py +++ b/redbot/core/app_commands/checks.py @@ -348,7 +348,10 @@ def predicate(interaction: discord.Interaction) -> bool: if isinstance(interaction.channel, discord.Thread): if not ( perms.manage_threads - or (allow_thread_owner and interaction.channel.owner_id == interaction.client.user.id) + or ( + allow_thread_owner + and interaction.channel.owner_id == interaction.client.user.id + ) ): # This is a slight lie - thread owner *might* also be allowed # but we just say that bot is missing the Manage Threads permission. From 067a3cda65fd0bd324f3baad3d8a9c36c22f1d2d Mon Sep 17 00:00:00 2001 From: Michael Oliveira <34169552+Flame442@users.noreply.github.com> Date: Tue, 9 Jul 2024 20:36:02 -0400 Subject: [PATCH 3/6] Add autodocs --- docs/framework_checks_app_commands.rst | 11 +++++++++++ docs/index.rst | 1 + 2 files changed, 12 insertions(+) create mode 100644 docs/framework_checks_app_commands.rst diff --git a/docs/framework_checks_app_commands.rst b/docs/framework_checks_app_commands.rst new file mode 100644 index 00000000000..cc4cc5ee722 --- /dev/null +++ b/docs/framework_checks_app_commands.rst @@ -0,0 +1,11 @@ +.. _checks_app_commands: + +======================== +App Command Check Decorators +======================== + +The following are all decorators for app commands, which add restrictions to where and when they can be +run. + +.. automodule:: redbot.core.app_commands.checks + :members: diff --git a/docs/index.rst b/docs/index.rst index 33911469b7a..3617ec5da4a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -70,6 +70,7 @@ Welcome to Red - Discord Bot's documentation! framework_bank framework_bot framework_checks + framework_checks_app_commands framework_commands framework_config framework_datamanager From e9b11b3de2f220c3bf88de7653bd5760dba5ebfc Mon Sep 17 00:00:00 2001 From: Michael Oliveira <34169552+Flame442@users.noreply.github.com> Date: Tue, 9 Jul 2024 20:41:39 -0400 Subject: [PATCH 4/6] Fix docs bars --- docs/framework_checks_app_commands.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/framework_checks_app_commands.rst b/docs/framework_checks_app_commands.rst index cc4cc5ee722..2caf20b798c 100644 --- a/docs/framework_checks_app_commands.rst +++ b/docs/framework_checks_app_commands.rst @@ -1,8 +1,8 @@ .. _checks_app_commands: -======================== +============================ App Command Check Decorators -======================== +============================ The following are all decorators for app commands, which add restrictions to where and when they can be run. From a5ffa9d11bdf4baeff6c0a0b888347693ef23b43 Mon Sep 17 00:00:00 2001 From: Michael Oliveira <34169552+Flame442@users.noreply.github.com> Date: Tue, 9 Jul 2024 21:10:23 -0400 Subject: [PATCH 5/6] Fix ambiguous references --- CHANGES.rst | 2 +- docs/incompatible_changes/3.5.rst | 7 ++++--- redbot/core/bank.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cada592b081..b0083bee9aa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -650,7 +650,7 @@ Additions - **Core - Commands Package** - Added `RawUserIdConverter` (:issue:`4486`) - |cool| **Core - Commands Package** - Added support for hybrid commands (:issue:`5681`) - **Core - Commands Package** - Added `positive_int` and `finite_float` converters (:issue:`5939`, :issue:`5969`) -- **Core - Commands Package** - Added new checks for proper permission resolution in both channels and threads: `bot_can_manage_channel()`, `bot_can_react()`, `can_manage_channel()`, `guildowner_or_can_manage_channel()`, `admin_or_can_manage_channel()`, `mod_or_can_manage_channel()` (:issue:`5600`) +- **Core - Commands Package** - Added new checks for proper permission resolution in both channels and threads: `redbot.core.commands.bot_can_manage_channel()`, `redbot.core.commands.bot_can_react()`, `redbot.core.commands.can_manage_channel()`, `redbot.core.commands.guildowner_or_can_manage_channel()`, `redbot.core.commands.admin_or_can_manage_channel()`, `redbot.core.commands.mod_or_can_manage_channel()` (:issue:`5600`) - **Core - Dependencies** - Added ``red_commons`` as a dependency (:issue:`5624`) - **Core - Modlog** - Added `Case.parent_channel` and `Case.parent_channel_id` (support for threads) (:issue:`5600`) - **Core - Utils Package** - Added `SimpleMenu`, a template view subclass (:issue:`5634`) diff --git a/docs/incompatible_changes/3.5.rst b/docs/incompatible_changes/3.5.rst index 60f95cad56b..d0e68e47efc 100644 --- a/docs/incompatible_changes/3.5.rst +++ b/docs/incompatible_changes/3.5.rst @@ -469,9 +469,10 @@ To help support some of the newer features, we've also added a few things: - Utilities that allow to **properly** check whether a member can do something in threads (or channels) for cases where it isn't as simple as checking permissions: - - Command check decorators: `bot_can_manage_channel()`, `bot_can_react()` - - Permission check decorators: `can_manage_channel()`, `guildowner_or_can_manage_channel()`, - `admin_or_can_manage_channel()`, `mod_or_can_manage_channel()` + - Command check decorators: `redbot.core.commands.bot_can_manage_channel()`, `redbot.core.commands.bot_can_react()` + - Permission check decorators: `redbot.core.commands.can_manage_channel()`, + `redbot.core.commands.guildowner_or_can_manage_channel()`, `redbot.core.commands.admin_or_can_manage_channel()`, + `redbot.core.commands.mod_or_can_manage_channel()` - Functions: `can_user_send_messages_in()`, `can_user_manage_channel()`, `can_user_react_in()` - New module (`redbot.core.utils.views`) with useful `discord.ui.View` subclasses diff --git a/redbot/core/bank.py b/redbot/core/bank.py index 9af9dd8abba..3f8bf8de7e0 100644 --- a/redbot/core/bank.py +++ b/redbot/core/bank.py @@ -156,7 +156,7 @@ def is_owner_if_bank_global(): otherwise ensure it's used in guild (WITHOUT checking any user permissions). When used on the command, this should be combined - with permissions check like `guildowner_or_permissions()`. + with permissions check like `redbot.core.commands.guildowner_or_permissions()`. This is a `command check `. From 204ab587f399931b8a1baafeb9036bdd124f6008 Mon Sep 17 00:00:00 2001 From: Michael Oliveira <34169552+Flame442@users.noreply.github.com> Date: Tue, 9 Jul 2024 21:17:15 -0400 Subject: [PATCH 6/6] Categorize new file for autolabeler --- .github/labeler.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index 3eff6854081..dabf8de6a99 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -141,6 +141,8 @@ "Category: Core - API - App Commands Package": # Source - redbot/core/app_commands/* + # Docs + - docs/framework_checks_app_commands.rst # Tests - tests/core/test_app_commands.py "Category: Core - API - Commands Package":