From f06b734e158064198d4700bd65ebb609cb08e9e6 Mon Sep 17 00:00:00 2001 From: Flame442 <34169552+Flame442@users.noreply.github.com> Date: Mon, 20 Mar 2023 16:31:37 -0400 Subject: [PATCH] Application Command Manager (#5992) Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- .github/labeler.yml | 2 + docs/cog_guides/core.rst | 146 ++++++++++ docs/framework_tree.rst | 21 ++ docs/index.rst | 1 + redbot/cogs/downloader/downloader.py | 6 + redbot/core/bot.py | 72 ++++- redbot/core/core_commands.py | 398 ++++++++++++++++++++++++++- redbot/core/tree.py | 332 ++++++++++++++++++++++ 8 files changed, 974 insertions(+), 4 deletions(-) create mode 100644 docs/framework_tree.rst create mode 100644 redbot/core/tree.py diff --git a/.github/labeler.yml b/.github/labeler.yml index cf44d089c..8f31f0d33 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -163,9 +163,11 @@ - redbot/core/cog_manager.py # TODO: privatize cog manager module - redbot/core/data_manager.py - redbot/core/errors.py + - redbot/core/tree.py # Docs - docs/framework_cogmanager.rst # TODO: privatize cog manager module - docs/framework_datamanager.rst + - docs/framework_tree.rst # Tests - redbot/pytest/cog_manager.py # TODO: privatize cog manager module - redbot/pytest/data_manager.py diff --git a/docs/cog_guides/core.rst b/docs/cog_guides/core.rst index ccc591563..ae72dc5a4 100644 --- a/docs/cog_guides/core.rst +++ b/docs/cog_guides/core.rst @@ -4045,6 +4045,152 @@ This is the recommended method for shutting down the bot. **Arguments:** - ``[silently]`` - Whether to skip sending the shutdown message. Defaults to False. +.. _core-command-slash: + +^^^^^ +slash +^^^^^ + +.. note:: |owner-lock| + +**Syntax** + +.. code-block:: none + + [p]slash + +**Description** + +Base command for managing what application commands are able to be used on Red. + +.. _core-command-slash-disable: + +""""""""""""" +slash disable +""""""""""""" + +**Syntax** + +.. code-block:: none + + [p]slash disable [command_type] + +**Description** + +Marks an application command as being disabled, preventing it from being added to the bot. +See commands available to disable with ``[p]slash list``. This command does NOT sync the +enabled commands with Discord, that must be done manually with ``[p]slash sync`` for +commands to appear in users' clients. + +**Arguments:** + - ```` - The command name to disable. Only the top level name of a group command should be used. + - ``[command_type]`` - What type of application command to disable. Must be one of ``slash``, ``message``, or ``user``. Defaults to ``slash``. + +.. _core-command-slash-disablecog: + +"""""""""""""""" +slash disablecog +"""""""""""""""" + +**Syntax** + +.. code-block:: none + + [p]slash disablecog + +**Description** + +Marks all application commands in a cog as being disabled, preventing them from being +added to the bot. See a list of cogs with application commands with ``[p]slash list``. +This command does NOT sync the enabled commands with Discord, that must be done manually +with ``[p]slash sync`` for commands to appear in users' clients. + +**Arguments:** + - ```` - The cog to disable commands from. This argument is case sensitive. + +.. _core-command-slash-enable: + +"""""""""""" +slash enable +"""""""""""" + +**Syntax** + +.. code-block:: none + + [p]slash enable [command_type] + +**Description** + +Marks an application command as being enabled, allowing it to be added to the bot. +See commands available to enable with ``[p]slash list``. This command does NOT sync the +enabled commands with Discord, that must be done manually with ``[p]slash sync`` for +commands to appear in users' clients. + +**Arguments:** + - ```` - The command name to enable. Only the top level name of a group command should be used. + - ``[command_type]`` - What type of application command to enable. Must be one of ``slash``, ``message``, or ``user``. Defaults to ``slash``. + +.. _core-command-slash-enablecog: + +""""""""""""""" +slash enablecog +""""""""""""""" + +**Syntax** + +.. code-block:: none + + [p]slash enablecog + +**Description** + +Marks all application commands in a cog as being enabled, allowing them to be +added to the bot. See a list of cogs with application commands with ``[p]slash list``. +This command does NOT sync the enabled commands with Discord, that must be done manually +with ``[p]slash sync`` for commands to appear in users' clients. + +**Arguments:** + - ```` - The cog to enable commands from. This argument is case sensitive. + +.. _core-command-slash-list: + +"""""""""" +slash list +"""""""""" + +**Syntax** + +.. code-block:: none + + [p]slash list + +**Description** + +List the slash commands the bot can see, and whether or not they are enabled. +This command shows the state that will be changed to when ``[p]slash sync`` is run. + +.. _core-command-slash-sync: + +"""""""""" +slash sync +"""""""""" + +**Syntax** + +.. code-block:: none + + [p]slash sync [guild] + +**Description** + +Syncs the slash settings to discord. +Settings from ``[p]slash list`` will be synced with discord, changing what commands appear for users. +This should be run sparingly, make all necessary changes before running this command. + +**Arguments:** + - ``[guild]`` - If provided, syncs commands for that guild. Otherwise, syncs global commands. + .. _core-command-traceback: ^^^^^^^^^ diff --git a/docs/framework_tree.rst b/docs/framework_tree.rst new file mode 100644 index 000000000..17a518394 --- /dev/null +++ b/docs/framework_tree.rst @@ -0,0 +1,21 @@ +.. tree module docs + +==== +Tree +==== + +Red uses a subclass of discord.py's ``CommandTree`` object in order to allow Cog Creators to add application commands to their cogs without worrying about the command count limit and to support caching ``AppCommand`` objects. When an app command is added to the bot's tree, it will not show up in ``tree.get_commands`` or other similar methods unless the command is "enabled" with ``[p]slash enable`` (similar to "load"ing a cog) and ``tree.red_check_enabled`` has been run since the command was added to the tree. + +.. note:: + + If you are adding app commands to the tree during load time, the loading process will call ``tree.red_check_enabled`` for your cog and its app commands. If you are adding app commands to the bot **outside of load time**, a call to ``tree.red_check_enabled`` after adding the commands is required to ensure the commands will appear properly. + + If application commands from your cog show up in ``[p]slash list`` as enabled from an ``(unknown)`` cog and disabled from your cog at the same time, you did not follow the instructions above. You must manually call ``tree.red_check_enabled`` **after** adding the commands to the tree. + +.. automodule:: redbot.core.tree + +RedTree +^^^^^^^ + +.. autoclass:: RedTree + :members: diff --git a/docs/index.rst b/docs/index.rst index 432effdd4..2eebf2788 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -77,6 +77,7 @@ Welcome to Red - Discord Bot's documentation! framework_i18n framework_modlog framework_rpc + framework_tree framework_utils version_guarantees diff --git a/redbot/cogs/downloader/downloader.py b/redbot/cogs/downloader/downloader.py index c9242dfb6..e4cc6d362 100644 --- a/redbot/cogs/downloader/downloader.py +++ b/redbot/cogs/downloader/downloader.py @@ -1625,6 +1625,12 @@ class Downloader(commands.Cog): command=inline(f"{ctx.clean_prefix}cog info ") ) ) + # If the bot has any slash commands enabled, warn them to sync + enabled_slash = await self.bot.list_enabled_app_commands() + if any(enabled_slash.values()): + message += _( + "\nYou may need to resync your slash commands with `{prefix}slash sync`." + ).format(prefix=ctx.prefix) if failed_cogs: cognames = [cog.name for cog in failed_cogs] message += ( diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 8baf2c46b..1f7ee8ca5 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -53,6 +53,7 @@ from .settings_caches import ( I18nManager, ) from .rpc import RPCMixin +from .tree import RedTree from .utils import can_user_send_messages_in, common_filters, AsyncIter from .utils._internal_utils import send_to_owners_with_prefix_replaced @@ -144,6 +145,9 @@ class Red( datarequests__allow_user_requests=True, datarequests__user_requests_are_strict=True, use_buttons=False, + enabled_slash_commands={}, + enabled_user_commands={}, + enabled_message_commands={}, ) self._config.register_guild( @@ -231,7 +235,7 @@ class Red( self._main_dir = bot_dir self._cog_mgr = CogManager() self._use_team_features = cli_flags.use_team_features - super().__init__(*args, help_command=None, **kwargs) + super().__init__(*args, help_command=None, tree_cls=RedTree, **kwargs) # Do not manually use the help formatter attribute here, see `send_help_for`, # for a documented API. The internals of this object are still subject to change. self._help_formatter = commands.help.RedHelpFormatter() @@ -1650,6 +1654,7 @@ class Red( try: await lib.setup(self) + await self.tree.red_check_enabled() except Exception as e: await self._remove_module_references(lib.__name__) await self._call_module_finalizers(lib, name) @@ -1686,6 +1691,71 @@ class Red( return cog + async def enable_app_command( + self, + command_name: str, + command_type: discord.AppCommandType = discord.AppCommandType.chat_input, + ) -> None: + """ + Mark an application command as being enabled. + + Enabled commands are able to be added to the bot's tree, are able to be synced, and can be invoked. + + Raises + ------ + CommandLimitReached + Raised when attempting to enable a command that would exceed the command limit. + """ + if command_type is discord.AppCommandType.chat_input: + cfg = self._config.enabled_slash_commands() + limit = 100 + elif command_type is discord.AppCommandType.message: + cfg = self._config.enabled_message_commands() + limit = 5 + elif command_type is discord.AppCommandType.user: + cfg = self._config.enabled_user_commands() + limit = 5 + else: + raise TypeError("command type must be one of chat_input, message, user") + async with cfg as curr_commands: + if len(curr_commands) >= limit: + raise discord.app_commands.CommandLimitReached(None, limit, type=command_type) + if command_name not in curr_commands: + curr_commands[command_name] = None + + async def disable_app_command( + self, + command_name: str, + command_type: discord.AppCommandType = discord.AppCommandType.chat_input, + ) -> None: + """ + Mark an application command as being disabled. + + Disabled commands are not added to the bot's tree, are not able to be synced, and cannot be invoked. + """ + if command_type is discord.AppCommandType.chat_input: + cfg = self._config.enabled_slash_commands() + elif command_type is discord.AppCommandType.message: + cfg = self._config.enabled_message_commands() + elif command_type is discord.AppCommandType.user: + cfg = self._config.enabled_user_commands() + else: + raise TypeError("command type must be one of chat_input, message, user") + async with cfg as curr_commands: + if command_name in curr_commands: + del curr_commands[command_name] + + async def list_enabled_app_commands(self) -> Dict[str, Dict[str, Optional[int]]]: + """List the currently enabled application command names.""" + curr_slash_commands = await self._config.enabled_slash_commands() + curr_message_commands = await self._config.enabled_message_commands() + curr_user_commands = await self._config.enabled_user_commands() + return { + "slash": curr_slash_commands, + "message": curr_message_commands, + "user": curr_user_commands, + } + async def is_automod_immune( self, to_check: Union[discord.Message, commands.Context, discord.abc.User, discord.Role] ) -> bool: diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index e03b554b6..59e48de50 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -17,6 +17,7 @@ import getpass import pip import traceback from pathlib import Path +from collections import defaultdict from redbot.core import data_manager from redbot.core.utils.menus import menu from redbot.core.utils.views import SetApiView @@ -1940,6 +1941,394 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): for page in pagify(total_message): await ctx.send(page) + @staticmethod + def _is_submodule(parent: str, child: str): + return parent == child or child.startswith(parent + ".") + + # TODO: Guild owner permissions for guild scope slash commands and syncing? + @commands.group() + @checks.is_owner() + async def slash(self, ctx: commands.Context): + """Base command for managing what application commands are able to be used on [botname].""" + + @slash.command(name="enable") + async def slash_enable( + self, + ctx: commands.Context, + command_name: str, + command_type: Literal["slash", "message", "user"] = "slash", + ): + """Marks an application command as being enabled, allowing it to be added to the bot. + + See commands available to enable with `[p]slash list`. + + This command does NOT sync the enabled commands with Discord, that must be done manually with `[p]slash sync` for commands to appear in users' clients. + + **Arguments:** + - `` - The command name to enable. Only the top level name of a group command should be used. + - `[command_type]` - What type of application command to enable. Must be one of `slash`, `message`, or `user`. Defaults to `slash`. + """ + command_type = command_type.lower().strip() + + if command_type == "slash": + raw_type = discord.AppCommandType.chat_input + command_list = self.bot.tree._disabled_global_commands + key = command_name + elif command_type == "message": + raw_type = discord.AppCommandType.message + command_list = self.bot.tree._disabled_context_menus + key = (command_name, None, raw_type.value) + elif command_type == "user": + raw_type = discord.AppCommandType.user + command_list = self.bot.tree._disabled_context_menus + key = (command_name, None, raw_type.value) + else: + await ctx.send(_("Command type must be one of `slash`, `message`, or `user`.")) + return + + current_settings = await self.bot.list_enabled_app_commands() + current_settings = current_settings[command_type] + + if command_name in current_settings: + await ctx.send(_("That application command is already enabled.")) + return + + if key not in command_list: + await ctx.send( + _( + "That application command could not be found. " + "Use `{prefix}slash list` to see all application commands. " + "You may need to double check the command type." + ).format(prefix=ctx.prefix) + ) + return + + try: + await self.bot.enable_app_command(command_name, raw_type) + except discord.app_commands.CommandLimitReached: + await ctx.send(_("The command limit has been reached. Disable a command first.")) + return + + await self.bot.tree.red_check_enabled() + await ctx.send( + _("Enabled {command_type} application command `{command_name}`").format( + command_type=command_type, command_name=command_name + ) + ) + + @slash.command(name="disable") + async def slash_disable( + self, + ctx: commands.Context, + command_name: str, + command_type: Literal["slash", "message", "user"] = "slash", + ): + """Marks an application command as being disabled, preventing it from being added to the bot. + + See commands available to disable with `[p]slash list`. + + This command does NOT sync the enabled commands with Discord, that must be done manually with `[p]slash sync` for commands to appear in users' clients. + + **Arguments:** + - `` - The command name to disable. Only the top level name of a group command should be used. + - `[command_type]` - What type of application command to disable. Must be one of `slash`, `message`, or `user`. Defaults to `slash`. + """ + command_type = command_type.lower().strip() + + if command_type == "slash": + raw_type = discord.AppCommandType.chat_input + elif command_type == "message": + raw_type = discord.AppCommandType.message + elif command_type == "user": + raw_type = discord.AppCommandType.user + else: + await ctx.send(_("Command type must be one of `slash`, `message`, or `user`.")) + return + + current_settings = await self.bot.list_enabled_app_commands() + current_settings = current_settings[command_type] + + if command_name not in current_settings: + await ctx.send(_("That application command is already disabled.")) + return + + await self.bot.disable_app_command(command_name, raw_type) + await self.bot.tree.red_check_enabled() + await ctx.send( + _("Disabled {command_type} application command `{command_name}`").format( + command_type=command_type, command_name=command_name + ) + ) + + @slash.command(name="enablecog") + @commands.max_concurrency(1, wait=True) + async def slash_enablecog(self, ctx: commands.Context, cog_name: str): + """Marks all application commands in a cog as being enabled, allowing them to be added to the bot. + + See a list of cogs with application commands with `[p]slash list`. + + This command does NOT sync the enabled commands with Discord, that must be done manually with `[p]slash sync` for commands to appear in users' clients. + + **Arguments:** + - `` - The cog to enable commands from. This argument is case sensitive. + """ + enabled_commands = await self.bot.list_enabled_app_commands() + to_add_slash = [] + to_add_message = [] + to_add_user = [] + + # Fetch a list of command names to enable + for name, com in self.bot.tree._disabled_global_commands.items(): + if self._is_submodule(cog_name, com.module): + to_add_slash.append(name) + for key, com in self.bot.tree._disabled_context_menus.items(): + if self._is_submodule(cog_name, com.module): + name, guild_id, com_type = key + com_type = discord.AppCommandType(com_type) + if com_type is discord.AppCommandType.message: + to_add_message.append(name) + elif com_type is discord.AppCommandType.user: + to_add_user.append(name) + + # Check that we are going to enable at least one command, for user feedback + if not (to_add_slash or to_add_message or to_add_user): + await ctx.send( + _( + "Couldn't find any disabled commands from the cog `{cog_name}`. Use `{prefix}slash list` to see all cogs with application commands." + ).format(cog_name=cog_name, prefix=ctx.prefix) + ) + return + + SLASH_CAP = 100 + CONTEXT_CAP = 5 + total_slash = len(enabled_commands["slash"]) + len(to_add_slash) + total_message = len(enabled_commands["message"]) + len(to_add_message) + total_user = len(enabled_commands["user"]) + len(to_add_user) + + # If enabling would exceed any limit, exit early to not enable only a subset + if total_slash > SLASH_CAP: + await ctx.send( + _( + "Enabling all application commands from that cog would enable a total of {count} " + "commands, exceeding the {cap} command limit for slash commands. " + "Disable some commands first." + ).format(count=total_slash, cap=SLASH_CAP) + ) + return + if total_message > CONTEXT_CAP: + await ctx.send( + _( + "Enabling all application commands from that cog would enable a total of {count} " + "commands, exceeding the {cap} command limit for message commands. " + "Disable some commands first." + ).format(count=total_message, cap=CONTEXT_CAP) + ) + return + if total_user > CONTEXT_CAP: + await ctx.send( + _( + "Enabling all application commands from that cog would enable a total of {count} " + "commands, exceeding the {cap} command limit for user commands. " + "Disable some commands first." + ).format(count=total_user, cap=CONTEXT_CAP) + ) + return + + # Enable the cogs + for name in to_add_slash: + await self.bot.enable_app_command(name, discord.AppCommandType.chat_input) + for name in to_add_message: + await self.bot.enable_app_command(name, discord.AppCommandType.message) + for name in to_add_user: + await self.bot.enable_app_command(name, discord.AppCommandType.user) + + # Update the tree with the new list of enabled cogs + await self.bot.tree.red_check_enabled() + + # Output processing + count = len(to_add_slash) + len(to_add_message) + len(to_add_user) + names = to_add_slash.copy() + names.extend(to_add_message) + names.extend(to_add_user) + formatted_names = humanize_list([inline(name) for name in names]) + await ctx.send( + _("Enabled {count} commands from `{cog_name}`:\n{names}").format( + count=count, cog_name=cog_name, names=formatted_names + ) + ) + + @slash.command(name="disablecog") + async def slash_disablecog(self, ctx: commands.Context, cog_name): + """Marks all application commands in a cog as being disabled, preventing them from being added to the bot. + + See a list of cogs with application commands with `[p]slash list`. + + This command does NOT sync the enabled commands with Discord, that must be done manually with `[p]slash sync` for commands to appear in users' clients. + + **Arguments:** + - `` - The cog to disable commands from. This argument is case sensitive. + """ + removed = [] + for name, com in self.bot.tree._global_commands.items(): + if self._is_submodule(cog_name, com.module): + await self.bot.disable_app_command(name, discord.AppCommandType.chat_input) + removed.append(name) + for key, com in self.bot.tree._context_menus.items(): + if self._is_submodule(cog_name, com.module): + name, guild_id, com_type = key + await self.bot.disable_app_command(name, discord.AppCommandType(com_type)) + removed.append(name) + if not removed: + await ctx.send( + _( + "Couldn't find any enabled commands from the `{cog_name}` cog. Use `{prefix}slash list` to see all cogs with application commands." + ).format(cog_name=cog_name, prefix=ctx.prefix) + ) + return + await self.bot.tree.red_check_enabled() + formatted_names = humanize_list([inline(name) for name in removed]) + await ctx.send( + _("Disabled {count} commands from `{cog_name}`:\n{names}").format( + count=len(removed), cog_name=cog_name, names=formatted_names + ) + ) + + @slash.command(name="list") + async def slash_list(self, ctx: commands.Context): + """List the slash commands the bot can see, and whether or not they are enabled. + + This command shows the state that will be changed to when `[p]slash sync` is run. + """ + cog_commands = defaultdict(list) + slash_command_names = set() + message_command_names = set() + user_command_names = set() + + for command in self.bot.tree._global_commands.values(): + module = command.module + if "." in module: + module = module[: module.find(".")] + cog_commands[module].append((command.name, discord.AppCommandType.chat_input, True)) + slash_command_names.add(command.name) + for command in self.bot.tree._disabled_global_commands.values(): + module = command.module + if "." in module: + module = module[: module.find(".")] + cog_commands[module].append((command.name, discord.AppCommandType.chat_input, False)) + for key, command in self.bot.tree._context_menus.items(): + # Filter out guild context menus + if key[1] is not None: + continue + module = command.module + if "." in module: + module = module[: module.find(".")] + cog_commands[module].append((command.name, command.type, True)) + if command.type is discord.AppCommandType.message: + message_command_names.add(command.name) + elif command.type is discord.AppCommandType.user: + user_command_names.add(command.name) + for command in self.bot.tree._disabled_context_menus.values(): + module = command.module + if "." in module: + module = module[: module.find(".")] + cog_commands[module].append((command.name, command.type, False)) + + # Commands added with evals will come from __main__, make them unknown instead + if "__main__" in cog_commands: + main_data = cog_commands["__main__"] + del cog_commands["__main__"] + cog_commands["(unknown)"] = main_data + + # Commands enabled but unloaded won't appear unless accounted for + enabled_commands = await self.bot.list_enabled_app_commands() + unknown_slash = set(enabled_commands["slash"]) - slash_command_names + unknown_message = set(enabled_commands["message"]) - message_command_names + unknown_user = set(enabled_commands["user"]) - user_command_names + + unknown_slash = [(n, discord.AppCommandType.chat_input, True) for n in unknown_slash] + unknown_message = [(n, discord.AppCommandType.message, True) for n in unknown_message] + unknown_user = [(n, discord.AppCommandType.user, True) for n in unknown_user] + + cog_commands["(unknown)"].extend(unknown_slash) + cog_commands["(unknown)"].extend(unknown_message) + cog_commands["(unknown)"].extend(unknown_user) + # Hide it when empty + if not cog_commands["(unknown)"]: + del cog_commands["(unknown)"] + + if not cog_commands: + await ctx.send(_("There are no application commands to list.")) + return + + msg = "" + for cog in sorted(cog_commands.keys()): + msg += cog + "\n" + for name, raw_command_type, enabled in sorted(cog_commands[cog], key=lambda v: v[0]): + diff = "+ " if enabled else "- " + command_type = "unknown" + if raw_command_type is discord.AppCommandType.chat_input: + command_type = "slash" + elif raw_command_type is discord.AppCommandType.message: + command_type = "message" + elif raw_command_type is discord.AppCommandType.user: + command_type = "user" + msg += diff + command_type.ljust(7) + " | " + name + "\n" + msg += "\n" + + pages = pagify(msg, delims=["\n\n", "\n"]) + pages = [box(page, lang="diff") for page in pages] + await menu(ctx, pages) + + @slash.command(name="sync") + @commands.cooldown(1, 60) + async def slash_sync(self, ctx: commands.Context, guild: discord.Guild = None): + """Syncs the slash settings to discord. + + Settings from `[p]slash list` will be synced with discord, changing what commands appear for users. + This should be run sparingly, make all necessary changes before running this command. + + **Arguments:** + - `[guild]` - If provided, syncs commands for that guild. Otherwise, syncs global commands. + """ + # This command should not be automated due to the restrictive rate limits associated with it. + if ctx.assume_yes: + return + commands = [] + async with ctx.typing(): + try: + commands = await self.bot.tree.sync(guild=guild) + except discord.Forbidden as e: + # Should only be possible when syncing a guild, but just in case + if not guild: + raise e + await ctx.send( + _( + "I need the `applications.commands` scope in this server to be able to do that. " + "You can tell the bot to add that scope to invite links using `{prefix}inviteset commandscope`, " + "and can then run `{prefix}invite` to get an invite that will give the bot the scope. " + "You do not need to kick the bot to enable the scope, just use that invite to " + "re-auth the bot with the scope enabled." + ).format(prefix=ctx.prefix) + ) + return + except Exception as e: + raise e + await ctx.send(_("Synced {count} commands.").format(count=len(commands))) + + @slash_sync.error + async def slash_sync_error(self, ctx: commands.Context, error: commands.CommandError): + """Custom cooldown error message.""" + if not isinstance(error, commands.CommandOnCooldown): + return await ctx.bot.on_command_error(ctx, error, unhandled_by_cog=True) + await ctx.send( + _( + "You seem to be attempting to sync after recently syncing. Discord does not like it " + "when bots sync more often than neccecary, so this command has a cooldown. You " + "should enable/disable all commands you want to change first, and run this command " + "one time only after all changes have been made. " + ) + ) + @commands.command(name="shutdown") @checks.is_owner() async def _shutdown(self, ctx: commands.Context, silently: bool = False): @@ -2671,7 +3060,8 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): - `[p]set status listening jams` **Arguments:** - - `[listening]` - The text to follow `Listening to`. Leave blank to clear the current activity status.""" + - `[listening]` - The text to follow `Listening to`. Leave blank to clear the current activity status. + """ status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online if listening: @@ -2706,7 +3096,8 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): - `[p]set status watching [p]help` **Arguments:** - - `[watching]` - The text to follow `Watching`. Leave blank to clear the current activity status.""" + - `[watching]` - The text to follow `Watching`. Leave blank to clear the current activity status. + """ status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online if watching: @@ -2737,7 +3128,8 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): - `[p]set status competing London 2012 Olympic Games` **Arguments:** - - `[competing]` - The text to follow `Competing in`. Leave blank to clear the current activity status.""" + - `[competing]` - The text to follow `Competing in`. Leave blank to clear the current activity status. + """ status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online if competing: diff --git a/redbot/core/tree.py b/redbot/core/tree.py new file mode 100644 index 000000000..127a1eed3 --- /dev/null +++ b/redbot/core/tree.py @@ -0,0 +1,332 @@ +import discord +from discord.abc import Snowflake +from discord.utils import MISSING +from discord.app_commands import ( + Command, + Group, + ContextMenu, + AppCommand, + AppCommandError, + BotMissingPermissions, + CheckFailure, + CommandAlreadyRegistered, + CommandInvokeError, + CommandNotFound, + CommandOnCooldown, + NoPrivateMessage, + TransformerError, +) + +from .i18n import Translator +from .utils.chat_formatting import humanize_list, inline + +import logging +import traceback +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Tuple, Union, Optional, Sequence + + +log = logging.getLogger("red") + +_ = Translator(__name__, __file__) + + +class RedTree(discord.app_commands.CommandTree): + """A container that holds application command information. + + Internally does not actually add commands to the tree unless they are + enabled with ``[p]slash enable``, to support Red's modularity. + See ``discord.app_commands.CommandTree`` for more information. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Same structure as superclass + self._disabled_global_commands: Dict[str, Union[Command, Group]] = {} + self._disabled_context_menus: Dict[Tuple[str, Optional[int], int], ContextMenu] = {} + + def add_command( + self, + command: Union[Command, ContextMenu, Group], + /, + *args, + guild: Optional[Snowflake] = MISSING, + guilds: Sequence[Snowflake] = MISSING, + override: bool = False, + **kwargs, + ) -> None: + """Adds an application command to the tree. + + Commands will be internally stored until enabled by ``[p]slash enable``. + """ + # Allow guild specific commands to bypass the internals for development + if guild is not MISSING or guilds is not MISSING: + return super().add_command( + command, *args, guild=guild, guilds=guilds, override=override, **kwargs + ) + + if isinstance(command, ContextMenu): + name = command.name + type = command.type.value + key = (name, None, type) + + # Handle cases where the command already is in the tree + if not override and key in self._disabled_context_menus: + raise CommandAlreadyRegistered(name, None) + if key in self._context_menus: + if not override: + raise discord.errors.CommandAlreadyRegistered(name, None) + del self._context_menus[key] + + self._disabled_context_menus[key] = command + return + + if not isinstance(command, (Command, Group)): + raise TypeError( + f"Expected an application command, received {command.__class__.__name__} instead" + ) + + root = command.root_parent or command + name = root.name + + # Handle cases where the command already is in the tree + if not override and name in self._disabled_global_commands: + raise discord.errors.CommandAlreadyRegistered(name, None) + if name in self._global_commands: + if not override: + raise discord.errors.CommandAlreadyRegistered(name, None) + del self._global_commands[name] + + self._disabled_global_commands[name] = root + + def remove_command( + self, + command: str, + /, + *args, + guild: Optional[Snowflake] = None, + type: discord.AppCommandType = discord.AppCommandType.chat_input, + **kwargs, + ) -> Optional[Union[Command, ContextMenu, Group]]: + """Removes an application command from this tree.""" + if guild is not None: + return super().remove_command(command, *args, guild=guild, type=type, **kwargs) + if type is discord.AppCommandType.chat_input: + return self._disabled_global_commands.pop(command, None) or super().remove_command( + command, *args, guild=guild, type=type, **kwargs + ) + elif type in (discord.AppCommandType.user, discord.AppCommandType.message): + key = (command, None, type.value) + return self._disabled_context_menus.pop(key, None) or super().remove_command( + command, *args, guild=guild, type=type, **kwargs + ) + + def clear_commands( + self, + *args, + guild: Optional[Snowflake], + type: Optional[discord.AppCommandType] = None, + **kwargs, + ) -> None: + """Clears all application commands from the tree.""" + if guild is not None: + return super().clear_commands(*args, guild=guild, type=type, **kwargs) + + if type is None or type is discord.AppCommandType.chat_input: + self._global_commands.clear() + self._disabled_global_commands.clear() + + if type is None: + self._disabled_context_menus.clear() + else: + self._disabled_context_menus = { + (name, _guild_id, value): cmd + for (name, _guild_id, value), cmd in self._disabled_context_menus.items() + if value != type.value + } + return super().clear_commands(*args, guild=guild, type=type, **kwargs) + + async def sync(self, *args, guild: Optional[Snowflake] = None, **kwargs) -> List[AppCommand]: + """Wrapper to store command IDs when commands are synced.""" + commands = await super().sync(*args, guild=guild, **kwargs) + if guild: + return commands + async with self.client._config.all() as cfg: + for command in commands: + if command.type is discord.AppCommandType.chat_input: + cfg["enabled_slash_commands"][command.name] = command.id + elif command.type is discord.AppCommandType.message: + cfg["enabled_message_commands"][command.name] = command.id + elif command.type is discord.AppCommandType.user: + cfg["enabled_user_commands"][command.name] = command.id + return commands + + async def red_check_enabled(self) -> None: + """Restructures the commands in this tree, enabling commands that are enabled and disabling commands that are disabled. + + After running this function, the tree will be populated with enabled commands only. + If commands are manually added to the tree outside of the standard cog loading process, this must be run + for them to be usable. + """ + enabled_commands = await self.client.list_enabled_app_commands() + + to_add_commands = [] + to_add_context = [] + to_remove_commands = [] + to_remove_context = [] + + # Add commands + for command in enabled_commands["slash"]: + if command in self._disabled_global_commands: + to_add_commands.append(command) + + # Add context + for command in enabled_commands["message"]: + key = (command, None, discord.AppCommandType.message.value) + if key in self._disabled_context_menus: + to_add_context.append(key) + for command in enabled_commands["user"]: + key = (command, None, discord.AppCommandType.user.value) + if key in self._disabled_context_menus: + to_add_context.append(key) + + # Remove commands + for command in self._global_commands: + if command not in enabled_commands["slash"]: + to_remove_commands.append((command, discord.AppCommandType.chat_input)) + + # Remove context + for command, guild_id, command_type in self._context_menus: + if guild_id is not None: + continue + if ( + discord.AppCommandType(command_type) is discord.AppCommandType.message + and command not in enabled_commands["message"] + ): + to_remove_context.append((command, discord.AppCommandType.message)) + elif ( + discord.AppCommandType(command_type) is discord.AppCommandType.user + and command not in enabled_commands["user"] + ): + to_remove_context.append((command, discord.AppCommandType.user)) + + # Actually add/remove + for command in to_add_commands: + super().add_command(self._disabled_global_commands[command]) + del self._disabled_global_commands[command] + for key in to_add_context: + super().add_command(self._disabled_context_menus[key]) + del self._disabled_context_menus[key] + for command, type in to_remove_commands: + com = super().remove_command(command, type=type) + self._disabled_global_commands[command] = com + for command, type in to_remove_context: + com = super().remove_command(command, type=type) + self._disabled_context_menus[(command, None, type.value)] = com + + @staticmethod + async def _send_from_interaction(interaction, *args, **kwargs): + """Util for safely sending a message from an interaction.""" + if interaction.response.is_done(): + if interaction.is_expired(): + return await interaction.channel.send(*args, **kwargs) + return await interaction.followup.send(*args, ephemeral=True, **kwargs) + return await interaction.response.send_message(*args, ephemeral=True, **kwargs) + + @staticmethod + def _is_submodule(parent: str, child: str): + return parent == child or child.startswith(parent + ".") + + async def on_error( + self, interaction: discord.Interaction, error: AppCommandError, /, *args, **kwargs + ) -> None: + """Fallback error handler for app commands.""" + if isinstance(error, CommandNotFound): + await self._send_from_interaction(interaction, _("Command not found.")) + log.warning( + f"Application command {error.name} could not be resolved. " + "It may be from a cog that was updated or unloaded. " + "Consider running [p]slash sync to resolve this issue." + ) + elif isinstance(error, CommandInvokeError): + log.exception( + "Exception in command '{}'".format(error.command.qualified_name), + exc_info=error.original, + ) + exception_log = "Exception in command '{}'\n" "".format(error.command.qualified_name) + exception_log += "".join( + traceback.format_exception(type(error), error, error.__traceback__) + ) + interaction.client._last_exception = exception_log + + message = await interaction.client._config.invoke_error_msg() + if not message: + if interaction.user.id in interaction.client.owner_ids: + message = inline( + _("Error in command '{command}'. Check your console or logs for details.") + ) + else: + message = inline(_("Error in command '{command}'.")) + await self._send_from_interaction( + interaction, message.replace("{command}", error.command.qualified_name) + ) + elif isinstance(error, TransformerError): + if error.__cause__: + log.exception("Error in an app command transformer.", exc_info=error.__cause__) + await self._send_from_interaction(interaction, str(error)) + elif isinstance(error, BotMissingPermissions): + formatted = [ + '"' + perm.replace("_", " ").title() + '"' for perm in error.missing_permissions + ] + formatted = humanize_list(formatted).replace("Guild", "Server") + if len(error.missing_permissions) == 1: + msg = _("I require the {permission} permission to execute that command.").format( + permission=formatted + ) + else: + msg = _("I require {permission_list} permissions to execute that command.").format( + permission_list=formatted + ) + await self._send_from_interaction(interaction, msg) + elif isinstance(error, NoPrivateMessage): + # Seems to be only called normally by the has_role check + await self._send_from_interaction( + interaction, _("That command is not available in DMs.") + ) + elif isinstance(error, CommandOnCooldown): + relative_time = discord.utils.format_dt( + datetime.now(timezone.utc) + timedelta(seconds=error.retry_after), "R" + ) + msg = _("This command is on cooldown. Try again {relative_time}.").format( + relative_time=relative_time + ) + await self._send_from_interaction(interaction, msg, delete_after=error.retry_after) + elif isinstance(error, CheckFailure): + await self._send_from_interaction( + interaction, _("You are not permitted to use this command.") + ) + else: + log.exception(type(error).__name__, exc_info=error) + + # DEP-WARN + def _remove_with_module(self, name: str, *args, **kwargs) -> None: + """Handles cases where a module raises an exception in the loading process, but added commands to the tree. + + Duplication of the logic in the super class, but for the containers used by this subclass. + """ + super()._remove_with_module(name, *args, **kwargs) + remove = [] + for key, cmd in self._disabled_context_menus.items(): + if cmd.module is not None and self._is_submodule(name, cmd.module): + remove.append(key) + + for key in remove: + del self._disabled_context_menus[key] + + remove = [] + for key, cmd in self._disabled_global_commands.items(): + if cmd.module is not None and self._is_submodule(name, cmd.module): + remove.append(key) + + for key in remove: + del self._disabled_global_commands[key]