Application Command Manager (#5992)

Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com>
This commit is contained in:
Flame442 2023-03-20 16:31:37 -04:00 committed by GitHub
parent b2e17775a0
commit f06b734e15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 974 additions and 4 deletions

2
.github/labeler.yml vendored
View File

@ -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

View File

@ -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
View 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:

View File

@ -77,6 +77,7 @@ Welcome to Red - Discord Bot's documentation!
framework_i18n
framework_modlog
framework_rpc
framework_tree
framework_utils
version_guarantees

View File

@ -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 += (

View File

@ -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:

View File

@ -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
View 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]