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
8 changed files with 974 additions and 4 deletions

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: