mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-05 18:58:53 -05:00
Application Command Manager (#5992)
Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com>
This commit is contained in:
parent
b2e17775a0
commit
f06b734e15
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
@ -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
|
||||
|
||||
@ -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_name> [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:**
|
||||
- ``<command_name>`` - 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 <cog_name>
|
||||
|
||||
**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:**
|
||||
- ``<cog_name>`` - 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_name> [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:**
|
||||
- ``<command_name>`` - 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 <cog_name>
|
||||
|
||||
**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:**
|
||||
- ``<cog_name>`` - 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:
|
||||
|
||||
^^^^^^^^^
|
||||
|
||||
21
docs/framework_tree.rst
Normal file
21
docs/framework_tree.rst
Normal file
@ -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:
|
||||
@ -77,6 +77,7 @@ Welcome to Red - Discord Bot's documentation!
|
||||
framework_i18n
|
||||
framework_modlog
|
||||
framework_rpc
|
||||
framework_tree
|
||||
framework_utils
|
||||
version_guarantees
|
||||
|
||||
|
||||
@ -1625,6 +1625,12 @@ class Downloader(commands.Cog):
|
||||
command=inline(f"{ctx.clean_prefix}cog info <repo> <cog>")
|
||||
)
|
||||
)
|
||||
# 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 += (
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:**
|
||||
- `<command_name>` - 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:**
|
||||
- `<command_name>` - 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:**
|
||||
- `<cog_name>` - 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:**
|
||||
- `<cog_name>` - 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:
|
||||
|
||||
332
redbot/core/tree.py
Normal file
332
redbot/core/tree.py
Normal file
@ -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]
|
||||
Loading…
x
Reference in New Issue
Block a user