From 396082a1cc3ce81090dc301c39aee6d0d4d7875b Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Thu, 3 Feb 2022 20:37:03 -0600 Subject: [PATCH] Command registration rewrite (#759) * Initial commit for command registration rewrite * Fix faulty check in registration * Fix logic in command registration * Document get_desynced_commands * Fix logic in registration and update docstring * Fix error in docs * Finish application command registration * Prep for beta 2 Co-authored-by: Lala Sabathil --- discord/bot.py | 741 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 496 insertions(+), 245 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index cd1850f71f..ee97576952 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -27,6 +27,7 @@ import asyncio import collections +import copy import inspect import sys import traceback @@ -40,6 +41,7 @@ Type, TypeVar, Union, + Dict, ) from .client import Client @@ -55,12 +57,15 @@ command, ) from .commands.errors import CheckFailure -from .errors import Forbidden, DiscordException +from .enums import InteractionType +from .errors import DiscordException +from .errors import Forbidden from .interactions import Interaction from .shard import AutoShardedClient -from .utils import MISSING, get, find, async_all -from .enums import InteractionType +from .types import interactions from .user import User +from .utils import MISSING, get, async_all +from .utils import find CoroFunc = Callable[..., Coroutine[Any, Any, Any]] CFT = TypeVar('CFT', bound=CoroFunc) @@ -71,6 +76,7 @@ 'AutoShardedBot', ) + class ApplicationCommandMixin: """A mixin that implements common functionality for classes that need application command compatibility. @@ -135,7 +141,7 @@ def add_application_command(self, command: ApplicationCommand) -> None: self._pending_application_commands.append(command) def remove_application_command( - self, command: ApplicationCommand + self, command: ApplicationCommand ) -> Optional[ApplicationCommand]: """Remove a :class:`.ApplicationCommand` from the internal list of commands. @@ -175,10 +181,10 @@ def get_command(self): return self.get_application_command def get_application_command( - self, - name: str, - guild_ids: Optional[List[int]] = None, - type: Type[ApplicationCommand] = SlashCommand, + self, + name: str, + guild_ids: Optional[List[int]] = None, + type: Type[ApplicationCommand] = SlashCommand, ) -> Optional[ApplicationCommand]: """Get a :class:`.ApplicationCommand` from the internal list of commands. @@ -202,116 +208,357 @@ def get_application_command( for command in self._application_commands.values(): if ( - command.name == name - and isinstance(command, type) + command.name == name + and isinstance(command, type) ): if guild_ids is not None and command.guild_ids != guild_ids: return return command - async def sync_commands(self) -> None: + async def get_desynced_commands(self, guild_id: Optional[int] = None) -> List[Dict[str, Any]]: """|coro| - Registers all commands that have been added through :meth:`.add_application_command` - since :meth:`.register_commands`. This does not remove any registered commands that are not in the internal - cache, like :meth:`.register_commands` does, but rather just adds new ones. - - This should usually be used instead of :meth:`.register_commands` when commands are already registered and you - want to add more. + Gets the list of commands that are desynced from discord. If ``guild_id`` is specified, it will only return + guild commands that are desynced from said guild, else it will return global commands. - This can cause bugs if you run this command excessively without using register_commands, as the bot's internal - cache can get un-synced with discord's registered commands. + .. note:: + This function is meant to be used internally, and should only be used if you want to override the default + command registration behavior. .. versionadded:: 2.0 + + + Parameters + ---------- + guild_id: Optional[:class:`int`] + The guild id to get the desynced commands for, else global commands if unspecified. + + Returns + ------- + List[Dict[str, Any]] + A list of the desynced commands. Each will come with at least the ``cmd`` and ``action`` keys, which + respectively contain the command and the action to perform. Other keys may also be present depending on + the action, including ``id``. """ - # TODO: Write this function as described in the docstring (bob will do this) - raise NotImplementedError + # We can suggest the user to upsert, edit, delete, or bulk upsert the commands - async def register_commands(self) -> None: + return_value = [] + cmds = copy.deepcopy(self.pending_application_commands) + + if guild_id is None: + registered_commands = await self.http.get_global_commands(self.user.id) + pending = [cmd for cmd in cmds if cmd.guild_ids is None] + else: + registered_commands = await self.http.get_guild_commands(self.user.id, guild_id) + pending = [cmd for cmd in cmds if cmd.guild_ids is not None and guild_id in cmd.guild_ids] + + registered_commands_dict = {cmd["name"]: cmd for cmd in registered_commands} + to_check = { + "default_permission": None, + "name": None, + "description": None, + "options": [ + "type", + "name", + "description", + "autocomplete", + "choices" + ] + } + # First let's check if the commands we have locally are the same as the ones on discord + for cmd in pending: + match = registered_commands_dict.get(cmd.name) + if match is None: + # We don't have this command registered + return_value.append({ + "command": cmd, + "action": "upsert" + }) + continue + + as_dict = cmd.to_dict() + + for check in to_check: + if type(to_check[check]) == list: + for opt in to_check[check]: + + cmd_vals = [val.get(opt, MISSING) for val in as_dict[check]] + for i, val in enumerate(cmd_vals): + # We need to do some falsy conversion here + # The API considers False (autocomplete) and [] (choices) to be falsy values + falsy_vals = (False, []) + if val in falsy_vals: + cmd_vals[i] = MISSING + if ((not match.get(check, MISSING) is MISSING) + and cmd_vals != [val.get(opt, MISSING) for val in match[check]]): + # We have a difference + return_value.append({ + "command": cmd, + "action": "edit", + "id": int(registered_commands_dict[cmd.name]["id"]) + }) + break + else: + if getattr(cmd, check) != match[check]: + # We have a difference + return_value.append({ + "command": cmd, + "action": "edit", + "id": int(registered_commands_dict[cmd.name]["id"]) + }) + break + + # Now let's see if there are any commands on discord that we need to delete + for cmd in registered_commands_dict: + match = get(pending, name=registered_commands_dict[cmd]["name"]) + if match is None: + # We have this command registered but not in our list + return_value.append({ + "command": registered_commands_dict[cmd]["name"], + "id": int(registered_commands_dict[cmd]["id"]), + "action": "delete" + }) + continue + + return return_value + + async def register_command( + self, + command: ApplicationCommand, + force: bool = True, + guild_ids: List[int] = None + ) -> None: """|coro| - Registers all commands that have been added through :meth:`.add_application_command`. - This method cleans up all commands over the API and should sync them with the internal cache of commands. - This will only be rolled out to Discord if :meth:`.http.get_global_commands` has certain keys that differ from :data:`.pending_application_commands` - By default, this coroutine is called inside the :func:`.on_connect` - event. If you choose to override the :func:`.on_connect` event, then - you should invoke this coroutine as well. + + Registers a command. If the command has guild_ids set, or if the guild_ids parameter is passed, the command will + be registered as a guild command for those guilds. + + Parameters + ---------- + command: :class:`~.ApplicationCommand` + The command to register. + force: :class:`bool` + Whether to force the command to be registered. If this is set to False, the command will only be registered + if it seems to already be registered and up to date with our internal cache. Defaults to True. + guild_ids: :class:`list` + A list of guild ids to register the command for. If this is not set, the command's + :attr:`~.ApplicationCommand.guild_ids` attribute will be used. + + Returns + ------- + :class:`~.ApplicationCommand` + The command that was registered + """ + # TODO: Write this + raise NotImplementedError("This function has not been implemented yet") + + async def register_commands( + self, + commands: Optional[List[ApplicationCommand]] = None, + guild_id: Optional[int] = None, + force: bool = False + ) -> List[interactions.ApplicationCommand]: + """|coro| + + Register a list of commands. + .. versionadded:: 2.0 + + Parameters + ---------- + commands: Optional[List[:class:`~.ApplicationCommand`]] + A list of commands to register. If this is not set (None), then all commands will be registered. + guild_id: Optional[int] + If this is set, the commands will be registered as a guild command for the respective guild. If it is not + set, the commands will be registered according to their :attr:`~.ApplicationCommand.guild_ids` attribute. + force: :class:`bool` + Registers the commands regardless of the state of the command on discord, this can take up more API calls + but is sometimes a more foolproof method of registering commands. This also sometimes causes minor bugs + where the command can temporarily appear as an invalid command on the user's side. Defaults to False. """ - commands_to_bulk = [] + if commands is None: + commands = self.pending_application_commands - needs_bulk = False + commands = copy.deepcopy(commands) - # Global Command Permissions - global_permissions: List = [] + for cmd in commands: + to_rep_with = [guild_id] if guild_id is not None else guild_id + cmd.guild_ids = to_rep_with - registered_commands = await self.http.get_global_commands(self.user.id) - # 'Your app cannot have two global commands with the same name. Your app cannot have two guild commands within the same name on the same guild.' - # We can therefore safely use the name of the command in our global slash commands as a unique identifier - registered_commands_dict = {cmd["name"]:cmd for cmd in registered_commands} - global_pending_application_commands_dict = {} - - for command in [ - cmd for cmd in self.pending_application_commands if cmd.guild_ids is None - ]: - as_dict = command.to_dict() - - global_pending_application_commands_dict[command.name] = as_dict - if command.name in registered_commands_dict: - match = registered_commands_dict[command.name] + is_global = guild_id is None + + registered = [] + + if is_global: + pending = list(filter(lambda c: c.guild_ids is None, commands)) + registration_methods = { + "bulk": self.http.bulk_upsert_global_commands, + "upsert": self.http.upsert_global_command, + "delete": self.http.delete_global_command, + "edit": self.http.edit_global_command, + } + + def register(method: str, *args, **kwargs): + return registration_methods[method](self.user.id, *args, **kwargs) + + else: + pending = list(filter(lambda c: c.guild_ids is not None and guild_id in c.guild_ids, commands)) + registration_methods = { + "bulk": self.http.bulk_upsert_guild_commands, + "upsert": self.http.upsert_guild_command, + "delete": self.http.delete_guild_command, + "edit": self.http.edit_guild_command, + } + + def register(method: str, *args, **kwargs): + return registration_methods[method](self.user.id, guild_id, *args, **kwargs) + + pending_actions = [] + + if not force: + desynced = await self.get_desynced_commands(guild_id=guild_id) + + for cmd in desynced: + if cmd["action"] == "delete": + pending_actions.append({ + "action": "delete", + "command": cmd["id"], + "name": cmd["command"] + }) + continue + # We can assume the command item is a command, since it's only a string if action is delete + match = get(pending, name=cmd["command"].name) + if match is None: + continue + if cmd["action"] == "edit": + pending_actions.append({ + "action": "edit", + "command": match, + "id": cmd["id"], + }) + elif cmd["action"] == "upsert": + pending_actions.append({ + "action": "upsert", + "command": match, + }) + else: + raise ValueError(f"Unknown action: {cmd['action']}") + + filtered_deleted = list(filter(lambda a: a["action"] != "delete", pending_actions)) + if len(filtered_deleted) == len(pending): + # It appears that all the commands need to be modified, so we can just do a bulk upsert + data = [cmd['command'].to_dict() for cmd in filtered_deleted] + registered = await register("bulk", data) else: - match = None - # TODO: There is probably a far more efficient way of doing this - # We want to check if the registered global command on Discord servers matches the given global commands - if match: - as_dict["id"] = match["id"] - - keys_to_check = {"default_permission": True, "name": True, "description": True, "options": ["type", "name", "description", "autocomplete", "choices"]} - for key, more_keys in { - key:more_keys - for key, more_keys in keys_to_check.items() - if key in as_dict.keys() - if key in match.keys() - }.items(): - if key == "options": - for i, option_dict in enumerate(as_dict[key]): - if command.name == "recent": - print(option_dict, "|||||", match[key][i]) - for key2 in more_keys: - pendingVal = None - if key2 in option_dict.keys(): - pendingVal = option_dict[key2] - if pendingVal == False or pendingVal == []: # Registered commands are not available if choices is an empty array or if autocomplete is false - pendingVal = None - matchVal = None - if key2 in match[key][i].keys(): - matchVal = match[key][i][key2] - if matchVal == False or matchVal == []: # Registered commands are not available if choices is an empty array or if autocomplete is false - matchVal = None - - if pendingVal != matchVal: - # When a property in the options of a pending global command is changed - needs_bulk = True + if len(pending_actions) == 0: + registered = [] + for cmd in pending_actions: + if cmd["action"] == "delete": + await register("delete", cmd["command"]) + continue + if cmd["action"] == "edit": + registered.append(await register("edit", cmd["id"], cmd["command"].to_dict())) + elif cmd["action"] == "upsert": + registered.append(await register("upsert", cmd["command"].to_dict())) else: - if as_dict[key] != match[key]: - # When a property in a pending global command is changed - needs_bulk = True - else: - # When a name of a pending global command is not registered in Discord - needs_bulk = True + raise ValueError(f"Unknown action: {cmd['action']}") + else: + data = [cmd.to_dict() for cmd in pending] + registered = await register("bulk", data) - commands_to_bulk.append(as_dict) - - for name, command in registered_commands_dict.items(): - if not name in global_pending_application_commands_dict.keys(): - # When a registered global command is not available in the pending global commands - needs_bulk = True - - if needs_bulk: - commands = await self.http.bulk_upsert_global_commands(self.user.id, commands_to_bulk) + # TODO: Our lists dont work sometimes, see if that can be fixed so we can avoid this second API call + if guild_id is None: + registered = await self.http.get_global_commands(self.user.id) else: - commands = registered_commands + registered = await self.http.get_guild_commands(self.user.id, guild_id) + + for i in registered: + cmd = get( + self.pending_application_commands, + name=i["name"], + type=i["type"], + ) + if not cmd: + raise ValueError(f"Registered command {i['name']}, type {i['type']} not found in pending commands") + cmd.id = i["id"] + self._application_commands[cmd.id] = cmd + + return registered + + async def sync_commands( + self, + commands: Optional[List[ApplicationCommand]] = None, + force: bool = False, + guild_ids: Optional[List[int]] = None, + register_guild_commands: bool = True, + unregister_guilds: Optional[List[int]] = None, + ) -> None: + """|coro| + + Registers all commands that have been added through :meth:`.add_application_command`. This method cleans up all + commands over the API and should sync them with the internal cache of commands. It attempts to register the + commands in the most efficient way possible, unless ``force`` is set to ``True``, in which case it will always + register all commands. + + By default, this coroutine is called inside the :func:`.on_connect` event. If you choose to override the + :func:`.on_connect` event, then you should invoke this coroutine as well. + + .. note:: + If you remove all guild commands from a particular guild, the library may not be able to detect and update + the commands accordingly, as it would have to individually check for each guild. To force the library to + unregister a guild's commands, call this function with ``commands=[]`` and ``guild_ids=[guild_id]``. + + .. versionadded:: 2.0 + + Parameters + ---------- + commands: Optional[List[:class:`~.ApplicationCommand`]] + A list of commands to register. If this is not set (None), then all commands will be registered. + force: :class:`bool` + Registers the commands regardless of the state of the command on discord, this can take up more API calls + but is sometimes a more foolproof method of registering commands. This also allows the bot to dynamically + remove stale commands. Defaults to False. + guild_ids: Optional[List[:class:`int`]] + A list of guild ids to register the commands for. If this is not set, the commands' + :attr:`~.ApplicationCommand.guild_ids` attribute will be used. + register_guild_commands: :class:`bool` + Whether to register guild commands. Defaults to True. + unregister_guilds: Optional[List[:class:`int`]] + A list of guilds ids to check for commands to unregister, since the bot would otherwise have to check all + guilds. Unlike ``guild_ids``, this does not alter the commands' :attr:`~.ApplicationCommand.guild_ids` + attribute, instead it adds the guild ids to a list of guilds to sync commands for. If + ``register_guild_commands`` is set to False, then this parameter is ignored. + """ + + if commands is None: + commands = self.pending_application_commands + + if guild_ids is not None: + for cmd in commands: + cmd.guild_ids = guild_ids + + registered_commands = await self.register_commands(commands, force=force) + + cmd_guild_ids = [] + registered_guild_commands = {} - for i in commands: + if register_guild_commands: + for cmd in commands: + if cmd.guild_ids is not None: + cmd_guild_ids.extend(cmd.guild_ids) + if unregister_guilds is not None: + cmd_guild_ids.extend(unregister_guilds) + for guild_id in set(cmd_guild_ids): + registered_guild_commands[guild_id] = await self.register_commands( + commands, + guild_id=guild_id, + force=force) + + # TODO: 2.1: Remove this and favor permissions v2 + # Global Command Permissions + global_permissions: List = [] + + for i in registered_commands: cmd = get( self.pending_application_commands, name=i["name"], @@ -325,149 +572,128 @@ async def register_commands(self) -> None: # Permissions (Roles will be converted to IDs just before Upsert for Global Commands) global_permissions.append({"id": i["id"], "permissions": cmd.permissions}) - update_guild_commands = {} - async for guild in self.fetch_guilds(limit=None): - update_guild_commands[guild.id] = [] - for command in [ - cmd - for cmd in self.pending_application_commands - if cmd.guild_ids is not None - ]: - as_dict = command.to_dict() - for guild_id in command.guild_ids: - to_update = update_guild_commands[guild_id] - update_guild_commands[guild_id] = to_update + [as_dict] - - for guild_id, guild_data in update_guild_commands.items(): - try: - commands = await self.http.bulk_upsert_guild_commands( - self.user.id, guild_id, update_guild_commands[guild_id] + for guild_id, guild_data in registered_guild_commands.items(): + commands = registered_guild_commands[guild_id] + guild_permissions: List = [] + + for i in commands: + cmd = find(lambda cmd: cmd.name == i["name"] and cmd.type == i["type"] and int(i["guild_id"]) in + cmd.guild_ids, self.pending_application_commands) + cmd.id = i["id"] + self._application_commands[cmd.id] = cmd + + # Permissions + permissions = [ + perm.to_dict() + for perm in cmd.permissions + if perm.guild_id is None + or ( + perm.guild_id == guild_id and perm.guild_id in cmd.guild_ids + ) + ] + guild_permissions.append( + {"id": i["id"], "permissions": permissions} ) - # Permissions for this Guild - guild_permissions: List = [] - except Forbidden: - if not guild_data: - continue - print(f"Failed to add command to guild {guild_id}", file=sys.stderr) - raise - else: - for i in commands: - cmd = find(lambda cmd: cmd.name == i["name"] and cmd.type == i["type"] and int(i["guild_id"]) in cmd.guild_ids, self.pending_application_commands) - cmd.id = i["id"] - self._application_commands[cmd.id] = cmd - - # Permissions - permissions = [ - perm.to_dict() - for perm in cmd.permissions - if perm.guild_id is None - or ( - perm.guild_id == guild_id and perm.guild_id in cmd.guild_ids - ) - ] - guild_permissions.append( - {"id": i["id"], "permissions": permissions} - ) + for global_command in global_permissions: + permissions = [ + perm.to_dict() + for perm in global_command["permissions"] + if perm.guild_id is None + or ( + perm.guild_id == guild_id and perm.guild_id in cmd.guild_ids + ) + ] + guild_permissions.append( + {"id": global_command["id"], "permissions": permissions} + ) - for global_command in global_permissions: - permissions = [ - perm.to_dict() - for perm in global_command["permissions"] - if perm.guild_id is None - or ( - perm.guild_id == guild_id and perm.guild_id in cmd.guild_ids - ) - ] - guild_permissions.append( - {"id": global_command["id"], "permissions": permissions} - ) + # Collect & Upsert Permissions for Each Guild + # Command Permissions for this Guild + guild_cmd_perms: List = [] + + # Loop through Commands Permissions available for this Guild + for item in guild_permissions: + new_cmd_perm = {"id": item["id"], "permissions": []} + + # Replace Role / Owner Names with IDs + for permission in item["permissions"]: + if isinstance(permission["id"], str): + # Replace Role Names + if permission["type"] == 1: + role = get( + self.get_guild(guild_id).roles, + name=permission["id"], + ) - # Collect & Upsert Permissions for Each Guild - # Command Permissions for this Guild - guild_cmd_perms: List = [] - - # Loop through Commands Permissions available for this Guild - for item in guild_permissions: - new_cmd_perm = {"id": item["id"], "permissions": []} - - # Replace Role / Owner Names with IDs - for permission in item["permissions"]: - if isinstance(permission["id"], str): - # Replace Role Names - if permission["type"] == 1: - role = get( - self.get_guild(guild_id).roles, - name=permission["id"], + # If not missing + if role is not None: + new_cmd_perm["permissions"].append( + { + "id": role.id, + "type": 1, + "permission": permission["permission"], + } ) - - # If not missing - if role is not None: - new_cmd_perm["permissions"].append( - { - "id": role.id, - "type": 1, - "permission": permission["permission"], - } - ) - else: - print( - "No Role ID found in Guild ({guild_id}) for Role ({role})".format( - guild_id=guild_id, role=permission["id"] - ) + else: + print( + "No Role ID found in Guild ({guild_id}) for Role ({role})".format( + guild_id=guild_id, role=permission["id"] ) - # Add owner IDs - elif ( + ) + # Add owner IDs + elif ( permission["type"] == 2 and permission["id"] == "owner" - ): - app = await self.application_info() # type: ignore - if app.team: - for m in app.team.members: - new_cmd_perm["permissions"].append( - { - "id": m.id, - "type": 2, - "permission": permission["permission"], - } - ) - else: + ): + app = await self.application_info() # type: ignore + if app.team: + for m in app.team.members: new_cmd_perm["permissions"].append( { - "id": app.owner.id, + "id": m.id, "type": 2, "permission": permission["permission"], } ) - # Add the rest - else: - new_cmd_perm["permissions"].append(permission) - - # Make sure we don't have over 10 overwrites - if len(new_cmd_perm["permissions"]) > 10: - print( - "Command '{name}' has more than 10 permission overrides in guild ({guild_id}).\nwill only use the first 10 permission overrides.".format( - name=self._application_commands[new_cmd_perm["id"]].name, - guild_id=guild_id, - ) - ) - new_cmd_perm["permissions"] = new_cmd_perm["permissions"][:10] - - # Append to guild_cmd_perms - guild_cmd_perms.append(new_cmd_perm) + else: + new_cmd_perm["permissions"].append( + { + "id": app.owner.id, + "type": 2, + "permission": permission["permission"], + } + ) + # Add the rest + else: + new_cmd_perm["permissions"].append(permission) - # Upsert - try: - await self.http.bulk_upsert_command_permissions( - self.user.id, guild_id, guild_cmd_perms - ) - except Forbidden: + # Make sure we don't have over 10 overwrites + if len(new_cmd_perm["permissions"]) > 10: print( - f"Failed to add command permissions to guild {guild_id}", - file=sys.stderr, + "Command '{name}' has more than 10 permission overrides in guild ({guild_id}).\nwill only use " + "the first 10 permission overrides.".format( + name=self._application_commands[new_cmd_perm["id"]].name, + guild_id=guild_id, + ) ) - raise + new_cmd_perm["permissions"] = new_cmd_perm["permissions"][:10] - async def process_application_commands(self, interaction: Interaction) -> None: + # Append to guild_cmd_perms + guild_cmd_perms.append(new_cmd_perm) + + # Upsert + try: + await self.http.bulk_upsert_command_permissions( + self.user.id, guild_id, guild_cmd_perms + ) + except Forbidden: + print( + f"Failed to add command permissions to guild {guild_id}", + file=sys.stderr, + ) + raise + + async def process_application_commands(self, interaction: Interaction, auto_sync: bool = None) -> None: """|coro| This function processes the commands that have been registered @@ -479,8 +705,8 @@ async def process_application_commands(self, interaction: Interaction) -> None: you should invoke this coroutine as well. This function finds a registered command matching the interaction id from - :attr:`.ApplicationCommandMixin.application_commands` and runs :meth:`ApplicationCommand.invoke` on it. If no matching - command was found, it replies to the interaction with a default message. + :attr:`.ApplicationCommandMixin.application_commands` and runs :meth:`ApplicationCommand.invoke` on it. If no + matching command was found, it replies to the interaction with a default message. .. versionadded:: 2.0 @@ -488,10 +714,16 @@ async def process_application_commands(self, interaction: Interaction) -> None: ----------- interaction: :class:`discord.Interaction` The interaction to process + auto_sync: :class:`bool` + Whether to automatically sync and unregister the command if it is not found in the internal cache. This will + invoke the :meth:`~.Bot.sync_commands` method on the context of the command, either globally or per-guild, + based on the type of the command, respectively. Defaults to :attr:`.Bot.auto_sync_commands`. """ + if auto_sync is None: + auto_sync = self.auto_sync_commands if interaction.type not in ( - InteractionType.application_command, - InteractionType.auto_complete + InteractionType.application_command, + InteractionType.auto_complete ): return @@ -500,18 +732,27 @@ async def process_application_commands(self, interaction: Interaction) -> None: except KeyError: for cmd in self.application_commands: if ( - cmd.name == interaction.data["name"] - and interaction.data.get("guild_id", None) in cmd.guild_ids + cmd.name == interaction.data["name"] + and (interaction.data.get("guild_id") == cmd.guild_ids + or (isinstance(cmd.guild_ids, list) + and interaction.data.get("guild_id") in cmd.guild_ids)) ): command = cmd break else: - return self.dispatch("unknown_command", interaction) + if auto_sync: + guild_id = interaction.data.get("guild_id") + if guild_id is None: + await self.sync_commands() + else: + await self.sync_commands(unregister_guilds=[guild_id]) + return self.dispatch("unknown_application_command", interaction) + if interaction.type is InteractionType.auto_complete: ctx = await self.get_autocomplete_context(interaction) ctx.command = command return await command.invoke_autocomplete_callback(ctx) - + ctx = await self.get_application_context(interaction) ctx.command = command self.dispatch("application_command", ctx) @@ -608,10 +849,10 @@ def command(self, **kwargs): return self.application_command(**kwargs) def create_group( - self, - name: str, - description: Optional[str] = None, - guild_ids: Optional[List[int]] = None, + self, + name: str, + description: Optional[str] = None, + guild_ids: Optional[List[int]] = None, ) -> SlashCommandGroup: """A shortcut method that creates a slash command group with no subcommands and adds it to the internal command list via :meth:`~.ApplicationCommandMixin.add_application_command`. @@ -639,10 +880,10 @@ def create_group( return group def group( - self, - name: Optional[str] = None, - description: Optional[str] = None, - guild_ids: Optional[List[int]] = None, + self, + name: Optional[str] = None, + description: Optional[str] = None, + guild_ids: Optional[List[int]] = None, ) -> Callable[[Type[SlashCommandGroup]], SlashCommandGroup]: """A shortcut decorator that initializes the provided subclass of :class:`.SlashCommandGroup` and adds it to the internal command list via :meth:`~.ApplicationCommandMixin.add_application_command`. @@ -664,6 +905,7 @@ def group( Callable[[Type[SlashCommandGroup]], SlashCommandGroup] The slash command group that was created. """ + def inner(cls: Type[SlashCommandGroup]) -> SlashCommandGroup: group = cls( name or cls.__name__, @@ -675,6 +917,7 @@ def inner(cls: Type[SlashCommandGroup]) -> SlashCommandGroup: ) self.add_application_command(group) return group + return inner slash_group = group @@ -693,7 +936,7 @@ def walk_application_commands(self) -> Generator[ApplicationCommand, None, None] yield command async def get_application_context( - self, interaction: Interaction, cls=None + self, interaction: Interaction, cls=None ) -> ApplicationContext: r"""|coro| @@ -723,7 +966,7 @@ class be provided, it must be similar enough to return cls(self, interaction) async def get_autocomplete_context( - self, interaction: Interaction, cls=None + self, interaction: Interaction, cls=None ) -> AutocompleteContext: r"""|coro| @@ -753,9 +996,9 @@ class be provided, it must be similar enough to return cls(self, interaction) - class BotBase(ApplicationCommandMixin, CogMixin): _supports_prefixed_commands = False + # TODO I think def __init__(self, description=None, *args, **options): # super(Client, self).__init__(*args, **kwargs) @@ -771,6 +1014,7 @@ def __init__(self, description=None, *args, **options): self.description = inspect.cleandoc(description) if description else "" self.owner_id = options.get("owner_id") self.owner_ids = options.get("owner_ids", set()) + self.auto_sync_commands = options.get("auto_sync_commands", True) self.debug_guilds = options.pop("debug_guilds", None) @@ -778,7 +1022,7 @@ def __init__(self, description=None, *args, **options): raise TypeError("Both owner_id and owner_ids are set.") if self.owner_ids and not isinstance( - self.owner_ids, collections.abc.Collection + self.owner_ids, collections.abc.Collection ): raise TypeError( f"owner_ids must be a collection not {self.owner_ids.__class__!r}" @@ -790,13 +1034,14 @@ def __init__(self, description=None, *args, **options): self._after_invoke = None async def on_connect(self): - await self.register_commands() + if self.auto_sync_commands: + await self.sync_commands() async def on_interaction(self, interaction): await self.process_application_commands(interaction) async def on_application_command_error( - self, context: ApplicationContext, exception: DiscordException + self, context: ApplicationContext, exception: DiscordException ) -> None: """|coro| @@ -919,7 +1164,7 @@ def whitelist(ctx): return func async def can_run( - self, ctx: ApplicationContext, *, call_once: bool = False + self, ctx: ApplicationContext, *, call_once: bool = False ) -> bool: data = self._check_once if call_once else self._checks @@ -1153,10 +1398,16 @@ class Bot(BotBase, Client): for the collection. You cannot set both ``owner_id`` and ``owner_ids``. .. versionadded:: 1.3 - debug_guilds: Optional[List[:class:`int`]] Guild IDs of guilds to use for testing commands. This is similar to debug_guild. The bot will not create any global commands if a debug_guilds is passed. + + ..versionadded:: 2.0 + auto_sync_commands: :class:`bool` + Whether or not to automatically sync slash commands. This will call sync_commands in on_connect, and in + :attr:`.process_application_commands` if the command is not found. Defaults to ``True``. + + ..versionadded:: 2.0 """ pass