From 155f29491cf8ae668b97c730e3d86f565899f57f Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Mon, 10 Jan 2022 13:49:23 -0600 Subject: [PATCH 1/8] Initial commit for command registration rewrite --- discord/bot.py | 832 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 555 insertions(+), 277 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index ec21bd3e39..d081c5caec 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -27,10 +27,10 @@ import asyncio import collections +import copy import inspect +import sys import traceback -from .commands.errors import CheckFailure - from typing import ( Any, Callable, @@ -40,14 +40,10 @@ Optional, Type, TypeVar, - Union, -) - -import sys + Union, ) from .client import Client -from .shard import AutoShardedClient -from .utils import MISSING, get, find, async_all +from .cog import CogMixin from .commands import ( SlashCommand, SlashCommandGroup, @@ -58,12 +54,13 @@ AutocompleteContext, command, ) -from .cog import CogMixin - -from .errors import Forbidden, DiscordException -from .interactions import Interaction +from .commands.errors import CheckFailure from .enums import InteractionType +from .errors import DiscordException +from .interactions import Interaction +from .shard import AutoShardedClient from .user import User +from .utils import MISSING, get, async_all CoroFunc = Callable[..., Coroutine[Any, Any, Any]] CFT = TypeVar('CFT', bound=CoroFunc) @@ -74,6 +71,7 @@ 'AutoShardedBot', ) + class ApplicationCommandMixin: """A mixin that implements common functionality for classes that need application command compatibility. @@ -134,7 +132,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. @@ -167,10 +165,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. @@ -194,270 +192,547 @@ 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): """|coro| + """ + # TODO: Write docstring + # We can suggest the user to upsert, edit, delete, or bulk upsert the commands - 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. + return_value = [] + cmds = copy.deepcopy(self.pending_application_commands) - This should usually be used instead of :meth:`.register_commands` when commands are already registered and you - want to add more. + 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]: + if (hasattr(match, opt) and (getattr(cmd, opt) + if hasattr(cmd, opt) + else as_dict[opt]) != match[opt] + ): + # We have a difference + return_value.append({ + "command": cmd, + "action": "edit" + }) + 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| - 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. + 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. - .. versionadded:: 2.0 - """ - # TODO: Write this function as described in the docstring (bob will do this) - raise NotImplementedError + 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. - async def register_commands(self) -> None: + Returns + ------- + :class:`~.ApplicationCommand` + The command that was registered + """ + # TODO: Write this + return + + async def register_commands( + self, + commands: Optional[List[ApplicationCommand]] = None, + guild_id: Optional[int] = None, + force: bool = False + ): """|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. + + Register some 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. + 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. """ - 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) + count_deleted = len(list(filter(lambda a: a["action"] == "delete", pending_actions))) # DEBUG + print(f'Bulk registered {len(filtered_deleted)} commands with {count_deleted} deleted.') # DEBUG 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: + print('No changes') # DEBUG + for cmd in pending_actions: + if cmd["action"] == "delete": + print('Deleted a command') # DEBUG + await register("delete", cmd["command"]) + continue + if cmd["action"] == "edit": + print('Edited a command') # DEBUG + registered.append(await register("edit", cmd["id"], cmd["command"].to_dict())) + elif cmd["action"] == "upsert": + print('Upserted a command') # DEBUG + 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 - - 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) + raise ValueError(f"Unknown action: {cmd['action']}") else: - commands = registered_commands + data = [cmd.to_dict() for cmd in pending] + registered = await register("bulk", data) + print('force') # DEBUG + count_deleted = len(list(filter(lambda a: a["action"] == "delete", pending_actions))) # DEBUG + print(f'Force Bulk registered {len(pending)} commands with unknown amount deleted.') # DEBUG + + # 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: + registered = await self.http.get_guild_commands(self.user.id, guild_id) - for i in commands: + for i in registered: cmd = get( self.pending_application_commands, name=i["name"], - guild_ids=None, type=i["type"], ) - if cmd: - cmd.id = i["id"] - self._application_commands[cmd.id] = cmd - - # 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] - ) - - # 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} - ) - - # 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"], - } - ) - 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 ( - 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: - new_cmd_perm["permissions"].append( - { - "id": app.owner.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) - - # 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 + 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 + + # TODO: Use get_desynced_commands to find which commands we need to register. Use rate-limits and command amounts + # to determine if a bulk update should be done or if individual command updates are sufficient. Maybe only + # bulk update if all commands need to be re-registered. Don't edit this function until #634 is merged though, + # to avoid merge conflicts. + async def sync_commands( + self, + commands: Optional[List[ApplicationCommand]] = None, + force: bool = False, + guild_ids: Optional[List[int]] = None, + register_guild_commands: bool = False, + ) -> 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 + :attr:`~.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. + + .. 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. + """ + + if commands is None: + commands = self.pending_application_commands + + if guild_ids is not None: + for cmd in commands: + cmd.guild_ids = guild_ids + + await self.register_commands(commands, force=force) + print("Registered global commands") # DEBUG + + ids = [] + for cmd in commands: + if cmd.guild_ids is not None: + ids.extend(cmd.guild_ids) + for guild_id in set(ids): + await self.register_commands(commands, guild_id=guild_id, force=force) + print(f'Registered commands for guild {guild_id}') # DEBUG + + # Begin old code + # commands_to_bulk = [] + # + # needs_bulk = False + # + # # Global Command Permissions + # global_permissions: List = [] + # + # 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] + # 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 + # 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 + # + # 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) + # else: + # commands = registered_commands + # + # for i in commands: + # cmd = get( + # self.pending_application_commands, + # name=i["name"], + # guild_ids=None, + # type=i["type"], + # ) + # if cmd: + # cmd.id = i["id"] + # self._application_commands[cmd.id] = cmd + # + # # 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] + # ) + # + # # 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} + # ) + # + # # 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"], + # } + # ) + # 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 ( + # 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: + # new_cmd_perm["permissions"].append( + # { + # "id": app.owner.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) + # + # # 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) -> None: """|coro| @@ -471,8 +746,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 @@ -482,8 +757,8 @@ async def process_application_commands(self, interaction: Interaction) -> None: The interaction to process """ if interaction.type not in ( - InteractionType.application_command, - InteractionType.auto_complete + InteractionType.application_command, + InteractionType.auto_complete ): return @@ -492,8 +767,9 @@ 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", None) in cmd.guild_ids + or interaction.data.get("guild_id", None) == cmd.guild_ids) ): command = cmd break @@ -503,7 +779,7 @@ async def process_application_commands(self, interaction: Interaction) -> None: 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) @@ -600,10 +876,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`. @@ -631,10 +907,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`. @@ -656,6 +932,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__, @@ -667,6 +944,7 @@ def inner(cls: Type[SlashCommandGroup]) -> SlashCommandGroup: ) self.add_application_command(group) return group + return inner slash_group = group @@ -685,7 +963,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| @@ -715,7 +993,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| @@ -745,9 +1023,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) @@ -770,7 +1048,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}" @@ -782,13 +1060,13 @@ def __init__(self, description=None, *args, **options): self._after_invoke = None async def on_connect(self): - await self.register_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| @@ -920,7 +1198,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 From b7e499f6a858af1f015e0e58953ac43f5231aa8a Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Wed, 12 Jan 2022 08:26:32 -0600 Subject: [PATCH 2/8] Fix faulty check in registration --- discord/bot.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index d081c5caec..efd60eea24 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -244,14 +244,17 @@ async def get_desynced_commands(self, guild_id: Optional[int] = None): for check in to_check: if type(to_check[check]) == list: for opt in to_check[check]: - if (hasattr(match, opt) and (getattr(cmd, opt) - if hasattr(cmd, opt) - else as_dict[opt]) != match[opt] + if ( + hasattr(cmd, opt) and (not hasattr(match, opt) + or getattr(cmd, opt) != getattr(match, opt)) + or as_dict.get(opt) is not None and (not hasattr(match, opt) + or getattr(cmd, opt) != getattr(match, opt)) ): # We have a difference return_value.append({ "command": cmd, - "action": "edit" + "action": "edit", + "id": int(registered_commands_dict[cmd.name]["id"]) }) break else: From 1fd07da362aa0f5d1f6b11e7aa56e51fdeab048a Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Wed, 12 Jan 2022 09:42:55 -0600 Subject: [PATCH 3/8] Fix logic in command registration --- discord/bot.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index efd60eea24..713819ca02 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -244,12 +244,16 @@ async def get_desynced_commands(self, guild_id: Optional[int] = None): for check in to_check: if type(to_check[check]) == list: for opt in to_check[check]: - if ( - hasattr(cmd, opt) and (not hasattr(match, opt) - or getattr(cmd, opt) != getattr(match, opt)) - or as_dict.get(opt) is not None and (not hasattr(match, opt) - or getattr(cmd, opt) != getattr(match, opt)) - ): + + 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 + match_vals = [val.get(opt, MISSING) for val in match[check]] + if cmd_vals != match_vals: # We have a difference return_value.append({ "command": cmd, From 4cfddc67f21b73744b524052b103852668d0d721 Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Thu, 27 Jan 2022 11:11:01 -0600 Subject: [PATCH 4/8] Document get_desynced_commands --- discord/bot.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 713819ca02..68828e414c 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -40,7 +40,7 @@ Optional, Type, TypeVar, - Union, ) + Union, Dict, ) from .client import Client from .cog import CogMixin @@ -199,10 +199,30 @@ def get_application_command( return return command - async def get_desynced_commands(self, guild_id: Optional[int] = None): + async def get_desynced_commands(self, guild_id: Optional[int] = None) -> List[Dict[str, Any]]: """|coro| + + 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. + + .. versionadded:: 2.0 + + .. note:: + This function is meant to be used internally, and should only be used if you want to override the default + command registration behavior. + + 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 docstring # We can suggest the user to upsert, edit, delete, or bulk upsert the commands return_value = [] From b3e96c10f99010bfe39989370faf94e6cca6c1d5 Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Thu, 27 Jan 2022 12:05:43 -0600 Subject: [PATCH 5/8] Fix logic in registration and update docstring --- discord/bot.py | 59 +++++++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 68828e414c..5875495f40 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -205,12 +205,13 @@ async def get_desynced_commands(self, guild_id: Optional[int] = None) -> List[Di 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. - .. versionadded:: 2.0 - .. 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`] @@ -343,7 +344,7 @@ async def register_commands( ): """|coro| - Register some commands. + Register a list of commands. If ``commands`` .. versionadded:: 2.0 @@ -351,10 +352,13 @@ async def register_commands( ---------- 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 allows the bot to dynamically - remove stale commands. Defaults to False. + 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. """ if commands is None: commands = self.pending_application_commands @@ -471,27 +475,27 @@ def register(method: str, *args, **kwargs): cmd.id = i["id"] self._application_commands[cmd.id] = cmd - # TODO: Use get_desynced_commands to find which commands we need to register. Use rate-limits and command amounts - # to determine if a bulk update should be done or if individual command updates are sufficient. Maybe only - # bulk update if all commands need to be re-registered. Don't edit this function until #634 is merged though, - # to avoid merge conflicts. async def sync_commands( self, commands: Optional[List[ApplicationCommand]] = None, force: bool = False, guild_ids: Optional[List[int]] = None, - register_guild_commands: bool = False, + register_guild_commands: bool = True, ) -> 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 - :attr:`~.pending_application_commands` + 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. + 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 @@ -506,6 +510,8 @@ async def sync_commands( 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. """ if commands is None: @@ -518,14 +524,17 @@ async def sync_commands( await self.register_commands(commands, force=force) print("Registered global commands") # DEBUG - ids = [] - for cmd in commands: - if cmd.guild_ids is not None: - ids.extend(cmd.guild_ids) - for guild_id in set(ids): - await self.register_commands(commands, guild_id=guild_id, force=force) - print(f'Registered commands for guild {guild_id}') # DEBUG - + if register_guild_commands: + ids = [] + for cmd in commands: + if cmd.guild_ids is not None: + ids.extend(cmd.guild_ids) + for guild_id in set(ids): + await self.register_commands(commands, guild_id=guild_id, force=force) + print(f'Registered commands for guild {guild_id}') # DEBUG + + # TODO: Remove this code after using it to help write the permissions registration + # Also remove the lines that have the "DEBUG" comment, they aren't needed. # Begin old code # commands_to_bulk = [] # From 4b34d6120d7e8db6668101f328e9cd3e2932f56e Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Fri, 28 Jan 2022 09:49:11 -0600 Subject: [PATCH 6/8] Fix error in docs --- discord/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/bot.py b/discord/bot.py index 39b7c7b3a2..46f6f363c1 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -350,7 +350,7 @@ async def register_commands( ): """|coro| - Register a list of commands. If ``commands`` + Register a list of commands. .. versionadded:: 2.0 From 905f6e4301db725cdb231890218a9df2df035fc0 Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Thu, 3 Feb 2022 19:35:34 -0600 Subject: [PATCH 7/8] Finish application command registration --- discord/bot.py | 461 +++++++++++++++++++++---------------------------- 1 file changed, 194 insertions(+), 267 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index 46f6f363c1..e8ba8183a2 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -40,7 +40,7 @@ Optional, Type, TypeVar, - Union, + Union, Dict, ) @@ -57,16 +57,15 @@ command, ) from .commands.errors import CheckFailure -from .errors import Forbidden, DiscordException -from .interactions import Interaction -from .shard import AutoShardedClient -from .utils import MISSING, get, find, async_all from .enums import InteractionType from .errors import DiscordException +from .errors import Forbidden from .interactions import Interaction from .shard import AutoShardedClient +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) @@ -279,8 +278,8 @@ async def get_desynced_commands(self, guild_id: Optional[int] = None) -> List[Di falsy_vals = (False, []) if val in falsy_vals: cmd_vals[i] = MISSING - match_vals = [val.get(opt, MISSING) for val in match[check]] - if cmd_vals != match_vals: + 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, @@ -347,7 +346,7 @@ async def register_commands( commands: Optional[List[ApplicationCommand]] = None, guild_id: Optional[int] = None, force: bool = False - ): + ) -> List[interactions.ApplicationCommand]: """|coro| Register a list of commands. @@ -439,30 +438,22 @@ def register(method: str, *args, **kwargs): # 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) - count_deleted = len(list(filter(lambda a: a["action"] == "delete", pending_actions))) # DEBUG - print(f'Bulk registered {len(filtered_deleted)} commands with {count_deleted} deleted.') # DEBUG else: if len(pending_actions) == 0: - print('No changes') # DEBUG + registered = [] for cmd in pending_actions: if cmd["action"] == "delete": - print('Deleted a command') # DEBUG await register("delete", cmd["command"]) continue if cmd["action"] == "edit": - print('Edited a command') # DEBUG registered.append(await register("edit", cmd["id"], cmd["command"].to_dict())) elif cmd["action"] == "upsert": - print('Upserted a command') # DEBUG registered.append(await register("upsert", cmd["command"].to_dict())) else: raise ValueError(f"Unknown action: {cmd['action']}") else: data = [cmd.to_dict() for cmd in pending] registered = await register("bulk", data) - print('force') # DEBUG - count_deleted = len(list(filter(lambda a: a["action"] == "delete", pending_actions))) # DEBUG - print(f'Force Bulk registered {len(pending)} commands with unknown amount deleted.') # DEBUG # 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: @@ -481,12 +472,15 @@ def register(method: str, *args, **kwargs): 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| @@ -518,6 +512,11 @@ async def sync_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: @@ -527,256 +526,163 @@ async def sync_commands( for cmd in commands: cmd.guild_ids = guild_ids - await self.register_commands(commands, force=force) - print("Registered global commands") # DEBUG + registered_commands = await self.register_commands(commands, force=force) + + cmd_guild_ids = [] + registered_guild_commands = {} if register_guild_commands: - ids = [] for cmd in commands: if cmd.guild_ids is not None: - ids.extend(cmd.guild_ids) - for guild_id in set(ids): - await self.register_commands(commands, guild_id=guild_id, force=force) - print(f'Registered commands for guild {guild_id}') # DEBUG - - # TODO: Remove this code after using it to help write the permissions registration - # Also remove the lines that have the "DEBUG" comment, they aren't needed. - # Begin old code - # commands_to_bulk = [] - # - # needs_bulk = False - # - # # Global Command Permissions - # global_permissions: List = [] - # - # 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] - # 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 - # 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 - # - # 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) - # else: - # commands = registered_commands - # - # for i in commands: - # cmd = get( - # self.pending_application_commands, - # name=i["name"], - # guild_ids=None, - # type=i["type"], - # ) - # if cmd: - # cmd.id = i["id"] - # self._application_commands[cmd.id] = cmd - # - # # 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] - # ) - # - # # 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} - # ) - # - # # 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"], - # } - # ) - # 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 ( - # 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: - # new_cmd_perm["permissions"].append( - # { - # "id": app.owner.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) - # - # # 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) -> 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"], + guild_ids=None, + type=i["type"], + ) + if cmd: + cmd.id = i["id"] + self._application_commands[cmd.id] = cmd + + # Permissions (Roles will be converted to IDs just before Upsert for Global Commands) + global_permissions.append({"id": i["id"], "permissions": cmd.permissions}) + + 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} + ) + + 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"], + ) + + # 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"] + ) + ) + # 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: + new_cmd_perm["permissions"].append( + { + "id": app.owner.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) + + # 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 @@ -797,7 +703,13 @@ 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 @@ -810,13 +722,21 @@ async def process_application_commands(self, interaction: Interaction) -> None: for cmd in self.application_commands: if ( cmd.name == interaction.data["name"] - and (interaction.data.get("guild_id", None) in cmd.guild_ids - or interaction.data.get("guild_id", None) == cmd.guild_ids) + 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 @@ -1083,6 +1003,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) @@ -1465,10 +1386,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 From 9205fc6e365fc204bce198e425820d8f8679a73b Mon Sep 17 00:00:00 2001 From: BobDotCom <71356958+BobDotCom@users.noreply.github.com> Date: Thu, 3 Feb 2022 20:22:12 -0600 Subject: [PATCH 8/8] Prep for beta 2 --- discord/bot.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/bot.py b/discord/bot.py index e8ba8183a2..8690c6bd23 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -339,7 +339,7 @@ async def register_command( The command that was registered """ # TODO: Write this - return + raise NotImplementedError("This function has not been implemented yet") async def register_commands( self, @@ -1023,7 +1023,8 @@ def __init__(self, description=None, *args, **options): self._after_invoke = None async def on_connect(self): - await self.sync_commands() + if self.auto_sync_commands: + await self.sync_commands() async def on_interaction(self, interaction): await self.process_application_commands(interaction)