diff --git a/changelog/260.bugfix.rst b/changelog/260.bugfix.rst new file mode 100644 index 0000000000..3a77eb513c --- /dev/null +++ b/changelog/260.bugfix.rst @@ -0,0 +1 @@ +|commands| Allow registering 2 commands with the same type and name in different guilds. diff --git a/changelog/260.deprecate.0.rst b/changelog/260.deprecate.0.rst new file mode 100644 index 0000000000..51658a46cd --- /dev/null +++ b/changelog/260.deprecate.0.rst @@ -0,0 +1 @@ +|commands| :attr:`.ext.commands.InteractionBot.all_slash_commands`, :attr:`.ext.commands.InteractionBot.all_user_commands`, :attr:`.ext.commands.InteractionBot.all_message_commands` are deprecated. diff --git a/changelog/260.deprecate.1.rst b/changelog/260.deprecate.1.rst new file mode 100644 index 0000000000..d383c8394b --- /dev/null +++ b/changelog/260.deprecate.1.rst @@ -0,0 +1 @@ +|commands| :meth:`.ext.commands.InteractionBot.add_slash_command`, :meth:`.ext.commands.InteractionBot.add_user_command`, :meth:`.ext.commands.InteractionBot.add_message_command` are deprecated. diff --git a/changelog/260.deprecate.2.rst b/changelog/260.deprecate.2.rst new file mode 100644 index 0000000000..bf20c12a10 --- /dev/null +++ b/changelog/260.deprecate.2.rst @@ -0,0 +1 @@ +|commands| :meth:`.ext.commands.InteractionBot.remove_slash_command`, :meth:`.ext.commands.InteractionBot.remove_user_command`, :meth:`.ext.commands.InteractionBot.remove_message_command` are deprecated. diff --git a/changelog/260.feature.0.rst b/changelog/260.feature.0.rst new file mode 100644 index 0000000000..2c5ae38652 --- /dev/null +++ b/changelog/260.feature.0.rst @@ -0,0 +1 @@ +|commands| Add :meth:`.ext.commands.InteractionBot.add_app_command` and :meth:`.ext.commands.InteractionBot.remove_app_command`. diff --git a/changelog/260.feature.1.rst b/changelog/260.feature.1.rst new file mode 100644 index 0000000000..e48cb5a22e --- /dev/null +++ b/changelog/260.feature.1.rst @@ -0,0 +1 @@ +|commands| Add ``guild_id`` parameter to :meth:`.ext.commands.InteractionBot.get_slash_command`, :meth:`.ext.commands.InteractionBot.get_user_command`, and :meth:`.ext.commands.InteractionBot.get_message_command`. diff --git a/changelog/260.feature.2.rst b/changelog/260.feature.2.rst new file mode 100644 index 0000000000..191f973b7e --- /dev/null +++ b/changelog/260.feature.2.rst @@ -0,0 +1 @@ +|commands| Add :class:`.ext.commands.ApplicationCommandRegistrationError`. diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index b5e0498399..f613b73d63 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -12,6 +12,7 @@ Callable, Dict, List, + NamedTuple, Optional, Tuple, TypeVar, @@ -49,7 +50,7 @@ ] -__all__ = ("InvokableApplicationCommand", "default_member_permissions") +__all__ = ("AppCmdIndex", "InvokableApplicationCommand", "default_member_permissions") T = TypeVar("T") @@ -79,6 +80,26 @@ async def wrapped(*args, **kwargs): return wrapped +class AppCmdIndex(NamedTuple): + """A named tuple used for indexation of :class:`InvokableApplicationCommand` + objects stored in bot's cache. + + Attributes + ---------- + type: :class:`disnake.ApplicationCommandType` + The type of the application command being stored. + name: :class:`str` + The name of the application command being stored. + guild_id: Optional[:class:`int`] + One of the guild IDs this command should be registered to, + or ``None`` if it's a global command. + """ + + type: ApplicationCommandType + name: str + guild_id: Optional[int] + + class InvokableApplicationCommand(ABC): """A base class that implements the protocol for a bot application command. diff --git a/disnake/ext/commands/cog.py b/disnake/ext/commands/cog.py index 01fd59937c..e353435f30 100644 --- a/disnake/ext/commands/cog.py +++ b/disnake/ext/commands/cog.py @@ -27,7 +27,7 @@ from ._types import _BaseCommand from .base_core import InvokableApplicationCommand from .ctx_menus_core import InvokableMessageCommand, InvokableUserCommand -from .slash_core import InvokableSlashCommand +from .slash_core import InvokableSlashCommand, SubCommand, SubCommandGroup if TYPE_CHECKING: from typing_extensions import Self @@ -753,22 +753,16 @@ def _inject(self, bot: AnyBot) -> Self: for index, command in enumerate(self.__cog_app_commands__): command.cog = self + if isinstance(command, (SubCommand, SubCommandGroup)): + continue try: - if isinstance(command, InvokableSlashCommand): - bot.add_slash_command(command) - elif isinstance(command, InvokableUserCommand): - bot.add_user_command(command) - elif isinstance(command, InvokableMessageCommand): - bot.add_message_command(command) + bot.add_app_command(command) except Exception: # undo our additions for to_undo in self.__cog_app_commands__[:index]: - if isinstance(to_undo, InvokableSlashCommand): - bot.remove_slash_command(to_undo.name) - elif isinstance(to_undo, InvokableUserCommand): - bot.remove_user_command(to_undo.name) - elif isinstance(to_undo, InvokableMessageCommand): - bot.remove_message_command(to_undo.name) + bot._remove_app_commands( + to_undo.body.type, to_undo.name, guild_ids=to_undo.guild_ids + ) raise if not hasattr(self.cog_load.__func__, "__cog_special_method__"): @@ -841,12 +835,9 @@ def _eject(self, bot: AnyBot) -> None: bot.remove_command(command.name) # type: ignore for app_command in self.__cog_app_commands__: - if isinstance(app_command, InvokableSlashCommand): - bot.remove_slash_command(app_command.name) - elif isinstance(app_command, InvokableUserCommand): - bot.remove_user_command(app_command.name) - elif isinstance(app_command, InvokableMessageCommand): - bot.remove_message_command(app_command.name) + bot._remove_app_commands( + app_command.body.type, app_command.name, guild_ids=app_command.guild_ids + ) for name, method_name in self.__cog_listeners__: bot.remove_listener(getattr(self, method_name), name) diff --git a/disnake/ext/commands/ctx_menus_core.py b/disnake/ext/commands/ctx_menus_core.py index 835bd674e6..cee4073d23 100644 --- a/disnake/ext/commands/ctx_menus_core.py +++ b/disnake/ext/commands/ctx_menus_core.py @@ -266,9 +266,11 @@ def user_command( auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True``. - guild_ids: Sequence[:class:`int`] - If specified, the client will register the command in these guilds. - Otherwise, this command will be registered globally. + guild_ids: Optional[Sequence[:class:`int`]] + If specified, the client will register the command to these guilds. + Otherwise the command will be registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command will be registered to those guilds. extras: Dict[:class:`str`, Any] A dict of user provided extras to attach to the command. @@ -348,9 +350,11 @@ def message_command( auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True``. - guild_ids: Sequence[:class:`int`] - If specified, the client will register the command in these guilds. - Otherwise, this command will be registered globally. + guild_ids: Optional[Sequence[:class:`int`]] + If specified, the client will register the command to these guilds. + Otherwise the command will be registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command will be registered to those guilds. extras: Dict[:class:`str`, Any] A dict of user provided extras to attach to the command. diff --git a/disnake/ext/commands/errors.py b/disnake/ext/commands/errors.py index cfa4c12f03..3d3bcb1b31 100644 --- a/disnake/ext/commands/errors.py +++ b/disnake/ext/commands/errors.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Type, Union +from disnake.enums import ApplicationCommandType from disnake.errors import ClientException, DiscordException from disnake.utils import humanize_list @@ -74,6 +75,7 @@ "ExtensionFailed", "ExtensionNotFound", "CommandRegistrationError", + "ApplicationCommandRegistrationError", "FlagError", "BadFlagArgument", "MissingFlagArgument", @@ -1025,6 +1027,46 @@ def __init__(self, name: str, *, alias_conflict: bool = False) -> None: super().__init__(f"The {type_} {name} is already an existing command or alias.") +# we inherit CommandRegistrationError for backwards compatibility, +# because this error replaced CommandRegistrationError in several places +class ApplicationCommandRegistrationError(CommandRegistrationError): + """An exception raised when the app command can't be added + because a command with the same key already exists. + A key is determined by command type, name, and guild_id. + + This inherits from :exc:`CommandRegistrationError` + + .. versionadded:: 2.10 + + Attributes + ---------- + cmd_type: :class:`disnake.ApplicationCommandType` + The command type. + name: :class:`str` + The command name. + guild_id: Optional[:class:`int`] + The ID of the guild where the command was supposed to be registered + or ``None`` if it was a global command. + """ + + def __init__( + self, cmd_type: ApplicationCommandType, name: str, guild_id: Optional[int] + ) -> None: + self.cmd_type: ApplicationCommandType = cmd_type + self.name: str = name + self.guild_id: Optional[int] = guild_id + # backwards compatibility + self.alias_conflict: bool = False + # fixed API naming here because no one calls slash commands "chat input" + type_ = "slash" if cmd_type is ApplicationCommandType.chat_input else cmd_type.name + if guild_id is None: + msg = f"Global {type_} command {name} was specified earlier in your code." + else: + msg = f"Local {type_} command {name} with guild ID {guild_id} was specified earlier in your code." + # this bypasses CommandRegistrationError.__init__ + super(CommandRegistrationError, self).__init__(msg) + + class FlagError(BadArgument): """The base exception type for all flag parsing related errors. diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index 5349abeb3e..92b1805473 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -7,7 +7,7 @@ import sys import traceback import warnings -from itertools import chain +from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, @@ -22,16 +22,17 @@ TypedDict, TypeVar, Union, + cast, ) import disnake from disnake.app_commands import ApplicationCommand, Option from disnake.custom_warnings import SyncWarning from disnake.enums import ApplicationCommandType -from disnake.utils import warn_deprecated +from disnake.utils import deprecated, warn_deprecated from . import errors -from .base_core import InvokableApplicationCommand +from .base_core import AppCmdIndex, InvokableApplicationCommand from .common_bot_base import CommonBotBase from .ctx_menus_core import ( InvokableMessageCommand, @@ -39,7 +40,7 @@ message_command, user_command, ) -from .errors import CommandRegistrationError +from .errors import ApplicationCommandRegistrationError from .flags import CommandSyncFlags from .slash_core import InvokableSlashCommand, SubCommand, SubCommandGroup, slash_command @@ -139,6 +140,24 @@ def _format_diff(diff: _Diff) -> str: return "\n".join(f"| {line}" for line in lines) +def _match_subcommand_chain( + command: InvokableSlashCommand, chain: List[str] +) -> Union[InvokableSlashCommand, SubCommand, SubCommandGroup, None]: + """An internal function that returns a subcommand with a route matching the chain. + If there's no match then ``None`` is returned. + """ + if command.name != chain[0]: + return None + if len(chain) == 1: + return command + if len(chain) == 2: + return command.children.get(chain[1]) + if len(chain) == 3: + group = command.children.get(chain[1]) + if isinstance(group, SubCommandGroup): + return group.children.get(chain[2]) + + class InteractionBotBase(CommonBotBase): def __init__( self, @@ -213,9 +232,7 @@ def __init__( self._before_message_command_invoke = None self._after_message_command_invoke = None - self.all_slash_commands: Dict[str, InvokableSlashCommand] = {} - self.all_user_commands: Dict[str, InvokableUserCommand] = {} - self.all_message_commands: Dict[str, InvokableMessageCommand] = {} + self._all_app_commands: Dict[AppCmdIndex, InvokableApplicationCommand] = {} @disnake.utils.copy_doc(disnake.Client.login) async def login(self, token: str) -> None: @@ -231,33 +248,129 @@ def command_sync_flags(self) -> CommandSyncFlags: """ return CommandSyncFlags._from_value(self._command_sync_flags.value) - def application_commands_iterator(self) -> Iterable[InvokableApplicationCommand]: - return chain( - self.all_slash_commands.values(), - self.all_user_commands.values(), - self.all_message_commands.values(), - ) + @property + def all_app_commands(self) -> MappingProxyType[AppCmdIndex, InvokableApplicationCommand]: + """Mapping[:class:`AppCmdIndex`, :class:`InvokableApplicationCommand`]: + A read-only mapping with all application commands the bot has. + """ + return MappingProxyType(self._all_app_commands) @property def application_commands(self) -> Set[InvokableApplicationCommand]: """Set[:class:`InvokableApplicationCommand`]: A set of all application commands the bot has.""" - return set(self.application_commands_iterator()) + return set(self._all_app_commands.values()) @property def slash_commands(self) -> Set[InvokableSlashCommand]: """Set[:class:`InvokableSlashCommand`]: A set of all slash commands the bot has.""" - return set(self.all_slash_commands.values()) + return { + cmd for cmd in self._all_app_commands.values() if isinstance(cmd, InvokableSlashCommand) + } @property def user_commands(self) -> Set[InvokableUserCommand]: """Set[:class:`InvokableUserCommand`]: A set of all user commands the bot has.""" - return set(self.all_user_commands.values()) + return { + cmd for cmd in self._all_app_commands.values() if isinstance(cmd, InvokableUserCommand) + } @property def message_commands(self) -> Set[InvokableMessageCommand]: """Set[:class:`InvokableMessageCommand`]: A set of all message commands the bot has.""" - return set(self.all_message_commands.values()) + return { + cmd + for cmd in self._all_app_commands.values() + if isinstance(cmd, InvokableMessageCommand) + } + + @property + @deprecated("slash_commands") + def all_slash_commands(self) -> Dict[str, InvokableSlashCommand]: + # no docstring because it was an attribute and now it's deprecated + return { + cmd.name: cmd + for cmd in self._all_app_commands.values() + if isinstance(cmd, InvokableSlashCommand) + } + + @property + @deprecated("user_commands") + def all_user_commands(self) -> Dict[str, InvokableUserCommand]: + # no docstring because it was an attribute and now it's deprecated + return { + cmd.name: cmd + for cmd in self._all_app_commands.values() + if isinstance(cmd, InvokableUserCommand) + } + + @property + @deprecated("message_commands") + def all_message_commands(self) -> Dict[str, InvokableMessageCommand]: + # no docstring because it was an attribute and now it's deprecated + return { + cmd.name: cmd + for cmd in self._all_app_commands.values() + if isinstance(cmd, InvokableMessageCommand) + } + + def add_app_command(self, app_command: InvokableApplicationCommand) -> None: + """Adds an :class:`InvokableApplicationCommand` into the internal list of app commands. + + This is usually not called, instead shortcut decorators are used, such as + :meth:`.slash_command`, :meth:`.user_command` or :meth:`.message_command`. + + The app command is registered to guilds specified in the ``guild_ids`` attribute. + If this attribute is ``None`` then the command is registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command is registered to those guilds. + + .. versionadded:: 2.10 + + Parameters + ---------- + app_command: :class:`InvokableApplicationCommand` + The app command to add. + + Raises + ------ + ApplicationCommandRegistrationError + The app command is already registered. + TypeError + The app command passed is not an instance of :class:`InvokableApplicationCommand`. + """ + if not isinstance(self, disnake.Client): + raise NotImplementedError("This method is only usable in disnake.Client subclasses") + + if not isinstance(app_command, InvokableApplicationCommand): + raise TypeError( + "The app_command passed must be an instance of InvokableApplicationCommand" + ) + if isinstance(app_command, (SubCommand, SubCommandGroup)): + raise TypeError( + "The app_command passed must be a top level command, " + "not an instance of SubCommand or SubCommandGroup" + ) + + test_guilds = (None,) if self._test_guilds is None else self._test_guilds + guild_ids = app_command.guild_ids or test_guilds + + for guild_id in guild_ids: + cmd_index = AppCmdIndex( + type=app_command.body.type, name=app_command.name, guild_id=guild_id + ) + if cmd_index in self._all_app_commands: + raise ApplicationCommandRegistrationError( + cmd_index.type, cmd_index.name, cmd_index.guild_id + ) + + # localization may be called multiple times for the same command but it's harmless + app_command.body.localize(self.i18n) + # note that we're adding the same command object for each guild_id + # this ensures that any changes that happen to app_command after add_app_command + # (such as hook attachments or permission modifications) apply properly + self._all_app_commands[cmd_index] = app_command + @deprecated("add_app_command") def add_slash_command(self, slash_command: InvokableSlashCommand) -> None: """Adds an :class:`InvokableSlashCommand` into the internal list of slash commands. @@ -276,18 +389,9 @@ def add_slash_command(self, slash_command: InvokableSlashCommand) -> None: TypeError The slash command passed is not an instance of :class:`InvokableSlashCommand`. """ - if not isinstance(self, disnake.Client): - raise NotImplementedError("This method is only usable in disnake.Client subclasses") - - if not isinstance(slash_command, InvokableSlashCommand): - raise TypeError("The slash_command passed must be an instance of InvokableSlashCommand") - - if slash_command.name in self.all_slash_commands: - raise CommandRegistrationError(slash_command.name) - - slash_command.body.localize(self.i18n) - self.all_slash_commands[slash_command.name] = slash_command + self.add_app_command(slash_command) + @deprecated("add_app_command") def add_user_command(self, user_command: InvokableUserCommand) -> None: """Adds an :class:`InvokableUserCommand` into the internal list of user commands. @@ -306,18 +410,9 @@ def add_user_command(self, user_command: InvokableUserCommand) -> None: TypeError The user command passed is not an instance of :class:`InvokableUserCommand`. """ - if not isinstance(self, disnake.Client): - raise NotImplementedError("This method is only usable in disnake.Client subclasses") - - if not isinstance(user_command, InvokableUserCommand): - raise TypeError("The user_command passed must be an instance of InvokableUserCommand") - - if user_command.name in self.all_user_commands: - raise CommandRegistrationError(user_command.name) - - user_command.body.localize(self.i18n) - self.all_user_commands[user_command.name] = user_command + self.add_app_command(user_command) + @deprecated("add_app_command") def add_message_command(self, message_command: InvokableMessageCommand) -> None: """Adds an :class:`InvokableMessageCommand` into the internal list of message commands. @@ -336,19 +431,73 @@ def add_message_command(self, message_command: InvokableMessageCommand) -> None: TypeError The message command passed is not an instance of :class:`InvokableMessageCommand`. """ - if not isinstance(self, disnake.Client): - raise NotImplementedError("This method is only usable in disnake.Client subclasses") + self.add_app_command(message_command) - if not isinstance(message_command, InvokableMessageCommand): - raise TypeError( - "The message_command passed must be an instance of InvokableMessageCommand" - ) + def remove_app_command( + self, cmd_type: ApplicationCommandType, name: str, *, guild_id: Optional[int] + ) -> Optional[InvokableApplicationCommand]: + """Removes an :class:`InvokableApplicationCommand` from the internal list of app commands. + + .. versionadded:: 2.10 + + Parameters + ---------- + cmd_type: :class:`disnake.ApplicationCommandType` + The type of the app command to remove. + name: :class:`str` + The name of the app command to remove. + guild_id: Optional[:class:`int`] + The ID of the guild from which this command should be removed, + or ``None`` if it's global. + + Returns + ------- + Optional[:class:`InvokableApplicationCommand`] + The app command that was removed. If no matching command was found, then ``None`` is returned instead. + """ + if guild_id is not None or self._test_guilds is None: + cmd_index = AppCmdIndex(type=cmd_type, name=name, guild_id=guild_id) + return self._all_app_commands.pop(cmd_index, None) + + result = None + for guild_id in self._test_guilds: + cmd_index = AppCmdIndex(type=cmd_type, name=name, guild_id=guild_id) + cmd = self._all_app_commands.pop(cmd_index, None) + if result is None: + result = cmd - if message_command.name in self.all_message_commands: - raise CommandRegistrationError(message_command.name) + return result - message_command.body.localize(self.i18n) - self.all_message_commands[message_command.name] = message_command + def _remove_app_commands( + self, cmd_type: ApplicationCommandType, name: str, *, guild_ids: Optional[Sequence[int]] + ) -> None: + test_guilds = (None,) if self._test_guilds is None else self._test_guilds + # this is consistent with the behavior of command synchronisation + final_guild_ids = guild_ids or test_guilds + + for guild_id in final_guild_ids: + cmd_index = AppCmdIndex(type=cmd_type, name=name, guild_id=guild_id) + self._all_app_commands.pop(cmd_index, None) + + def _emulate_old_app_command_remove(self, cmd_type: ApplicationCommandType, name: str) -> Any: + type_info = "slash" if cmd_type is ApplicationCommandType.chat_input else cmd_type.name + warn_deprecated( + f"remove_{type_info}_command is deprecated and will be removed in a future version. " + "Use remove_app_command instead.", + stacklevel=3, + ) + bad_keys: List[AppCmdIndex] = [] + for key in self._all_app_commands.keys(): + if key.type is cmd_type and key.name == name: + bad_keys.append(key) + + result: Optional[InvokableApplicationCommand] = None + for key in bad_keys: + cmd = self._all_app_commands.pop(key, None) + if result is None: + result = cmd + + return result def remove_slash_command(self, name: str) -> Optional[InvokableSlashCommand]: """Removes an :class:`InvokableSlashCommand` from the internal list @@ -364,10 +513,7 @@ def remove_slash_command(self, name: str) -> Optional[InvokableSlashCommand]: Optional[:class:`InvokableSlashCommand`] The slash command that was removed. If the name is not valid then ``None`` is returned instead. """ - command = self.all_slash_commands.pop(name, None) - if command is None: - return None - return command + return self._emulate_old_app_command_remove(ApplicationCommandType.chat_input, name) def remove_user_command(self, name: str) -> Optional[InvokableUserCommand]: """Removes an :class:`InvokableUserCommand` from the internal list @@ -383,10 +529,7 @@ def remove_user_command(self, name: str) -> Optional[InvokableUserCommand]: Optional[:class:`InvokableUserCommand`] The user command that was removed. If the name is not valid then ``None`` is returned instead. """ - command = self.all_user_commands.pop(name, None) - if command is None: - return None - return command + return self._emulate_old_app_command_remove(ApplicationCommandType.user, name) def remove_message_command(self, name: str) -> Optional[InvokableMessageCommand]: """Removes an :class:`InvokableMessageCommand` from the internal list @@ -402,13 +545,10 @@ def remove_message_command(self, name: str) -> Optional[InvokableMessageCommand] Optional[:class:`InvokableMessageCommand`] The message command that was removed. If the name is not valid then ``None`` is returned instead. """ - command = self.all_message_commands.pop(name, None) - if command is None: - return None - return command + return self._emulate_old_app_command_remove(ApplicationCommandType.message, name) def get_slash_command( - self, name: str + self, name: str, guild_id: Optional[int] = MISSING ) -> Optional[Union[InvokableSlashCommand, SubCommandGroup, SubCommand]]: """Works like ``Bot.get_command``, but for slash commands. @@ -421,11 +561,17 @@ def get_slash_command( ---------- name: :class:`str` The name of the slash command to get. + guild_id: Optional[:class:`int`] + The guild ID corresponding to the slash command or ``None`` if it's a global command. + If this is not specified then the first match is returned instead. Raises ------ TypeError The name is not a string. + ValueError + Parameter ``guild_id`` was not provided in a case where different slash commands + have the same name but different guild_ids. Returns ------- @@ -436,20 +582,37 @@ def get_slash_command( raise TypeError(f"Expected name to be str, not {name.__class__}") chain = name.split() - slash = self.all_slash_commands.get(chain[0]) - if slash is None: - return None - - if len(chain) == 1: - return slash - elif len(chain) == 2: - return slash.children.get(chain[1]) - elif len(chain) == 3: - group = slash.children.get(chain[1]) - if isinstance(group, SubCommandGroup): - return group.children.get(chain[2]) - - def get_user_command(self, name: str) -> Optional[InvokableUserCommand]: + if guild_id is not MISSING: + cmd_index = AppCmdIndex( + type=ApplicationCommandType.chat_input, name=chain[0], guild_id=guild_id + ) + command = self._all_app_commands.get(cmd_index) + if command is None: + return None + return _match_subcommand_chain(command, chain) # type: ignore + + # this is mostly for backwards compatibility, as previously guild_id arg didn't exist + result = None + for command in self._all_app_commands.values(): + if not isinstance(command, InvokableSlashCommand): + continue + chain_match = _match_subcommand_chain(command, chain) + if chain_match is None: + continue + if result is None: + result = chain_match + # we should check whether there's an ambiguity in command search + elif chain_match is not result: + raise ValueError( + "Argument guild_id must be provided if there're different slash commands " + "with the same name but different guilds or one of them is global." + ) + + return result + + def get_user_command( + self, name: str, guild_id: Optional[int] = MISSING + ) -> Optional[InvokableUserCommand]: """Gets an :class:`InvokableUserCommand` from the internal list of user commands. @@ -457,15 +620,46 @@ def get_user_command(self, name: str) -> Optional[InvokableUserCommand]: ---------- name: :class:`str` The name of the user command to get. + guild_id: Optional[:class:`int`] + The guild ID corresponding to the user command or ``None`` if it's a global command. + If this is not specified then the first match is returned instead. + + Raises + ------ + ValueError + Parameter ``guild_id`` was not provided in a case where different user commands + have the same name but different guild_ids. Returns ------- Optional[:class:`InvokableUserCommand`] The user command that was requested. If not found, returns ``None``. """ - return self.all_user_commands.get(name) + if guild_id is not MISSING: + cmd_index = AppCmdIndex(type=ApplicationCommandType.user, name=name, guild_id=guild_id) + command = self._all_app_commands.get(cmd_index) + if command is None: + return None + return command # type: ignore + # this is mostly for backwards compatibility, as previously guild_id arg didn't exist + result = None + for command in self._all_app_commands.values(): + if not isinstance(command, InvokableUserCommand) or command.name != name: + continue + if result is None: + result = command + # we should check whether there's an ambiguity in command search + elif command is not result: + raise ValueError( + "Argument guild_id must be provided if there're different user commands " + "with the same name but different guilds or one of them is global." + ) - def get_message_command(self, name: str) -> Optional[InvokableMessageCommand]: + return result + + def get_message_command( + self, name: str, guild_id: Optional[int] = MISSING + ) -> Optional[InvokableMessageCommand]: """Gets an :class:`InvokableMessageCommand` from the internal list of message commands. @@ -473,13 +667,44 @@ def get_message_command(self, name: str) -> Optional[InvokableMessageCommand]: ---------- name: :class:`str` The name of the message command to get. + guild_id: Optional[:class:`int`] + The guild ID corresponding to the message command or ``None`` if it's a global command. + If this is not specified then the first match is returned instead. + + Raises + ------ + ValueError + Parameter ``guild_id`` was not provided in a case where different message commands + have the same name but different guild_ids. Returns ------- Optional[:class:`InvokableMessageCommand`] The message command that was requested. If not found, returns ``None``. """ - return self.all_message_commands.get(name) + if guild_id is not MISSING: + cmd_index = AppCmdIndex( + type=ApplicationCommandType.message, name=name, guild_id=guild_id + ) + command = self._all_app_commands.get(cmd_index) + if command is None: + return None + return command # type: ignore + # this is mostly for backwards compatibility, as previously guild_id arg didn't exist + result = None + for command in self._all_app_commands.values(): + if not isinstance(command, InvokableMessageCommand) or command.name != name: + continue + if result is None: + result = command + # we should check whether there's an ambiguity in command search + elif command is not result: + raise ValueError( + "Argument guild_id must be provided if there're different message commands " + "with the same name but different guilds or one of them is global." + ) + + return result def slash_command( self, @@ -533,9 +758,11 @@ def slash_command( auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True`` - guild_ids: Sequence[:class:`int`] - If specified, the client will register the command in these guilds. - Otherwise, this command will be registered globally. + guild_ids: Optional[Sequence[:class:`int`]] + If specified, the client will register the command to these guilds. + Otherwise the command will be registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command will be registered to those guilds. connectors: Dict[:class:`str`, :class:`str`] Binds function names to option names. If the name of an option already matches the corresponding function param, @@ -570,7 +797,7 @@ def decorator(func: CommandCallback) -> InvokableSlashCommand: extras=extras, **kwargs, )(func) - self.add_slash_command(result) + self.add_app_command(result) return result return decorator @@ -617,9 +844,11 @@ def user_command( auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True``. - guild_ids: Sequence[:class:`int`] - If specified, the client will register the command in these guilds. - Otherwise, this command will be registered globally. + guild_ids: Optional[Sequence[:class:`int`]] + If specified, the client will register the command to these guilds. + Otherwise the command will be registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command will be registered to those guilds. extras: Dict[:class:`str`, Any] A dict of user provided extras to attach to the command. @@ -647,7 +876,7 @@ def decorator( extras=extras, **kwargs, )(func) - self.add_user_command(result) + self.add_app_command(result) return result return decorator @@ -694,9 +923,11 @@ def message_command( auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True`` - guild_ids: Sequence[:class:`int`] - If specified, the client will register the command in these guilds. - Otherwise, this command will be registered globally. + guild_ids: Optional[Sequence[:class:`int`]] + If specified, the client will register the command to these guilds. + Otherwise the command will be registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command will be registered to those guilds. extras: Dict[:class:`str`, Any] A dict of user provided extras to attach to the command. @@ -724,7 +955,7 @@ def decorator( extras=extras, **kwargs, )(func) - self.add_message_command(result) + self.add_app_command(result) return result return decorator @@ -732,26 +963,23 @@ def decorator( # command synchronisation def _ordered_unsynced_commands( - self, test_guilds: Optional[Sequence[int]] = None + self, ) -> Tuple[List[ApplicationCommand], Dict[int, List[ApplicationCommand]]]: - global_cmds = [] - guilds = {} + global_cmds: List[ApplicationCommand] = [] + guilds: Dict[int, List[ApplicationCommand]] = {} - for cmd in self.application_commands_iterator(): + for key, cmd in self._all_app_commands.items(): if not cmd.auto_sync: cmd.body._always_synced = True - guild_ids = cmd.guild_ids or test_guilds + guild_id = key.guild_id - if guild_ids is None: + if guild_id is None: global_cmds.append(cmd.body) - continue - - for guild_id in guild_ids: - if guild_id not in guilds: - guilds[guild_id] = [cmd.body] - else: - guilds[guild_id].append(cmd.body) + elif guild_id not in guilds: + guilds[guild_id] = [cmd.body] + else: + guilds[guild_id].append(cmd.body) return global_cmds, guilds @@ -759,7 +987,7 @@ async def _cache_application_commands(self) -> None: if not isinstance(self, disnake.Client): raise NotImplementedError("This method is only usable in disnake.Client subclasses") - _, guilds = self._ordered_unsynced_commands(self._test_guilds) + _, guilds = self._ordered_unsynced_commands() # Here we only cache global commands and commands from guilds that are specified in the code. # They're collected from the "test_guilds" kwarg of commands.InteractionBotBase @@ -794,8 +1022,8 @@ async def _sync_application_commands(self) -> None: return # We assume that all commands are already cached. - # Sort all invokable commands between guild IDs: - global_cmds, guild_cmds = self._ordered_unsynced_commands(self._test_guilds) + # Group all invokable commands by guild IDs: + global_cmds, guild_cmds = self._ordered_unsynced_commands() if self._command_sync_flags.sync_global_commands: # Update global commands first @@ -1280,11 +1508,19 @@ async def process_app_command_autocompletion( inter: :class:`disnake.ApplicationCommandInteraction` The interaction to process. """ - slash_command = self.all_slash_commands.get(inter.data.name) + # `inter.data.guild_id` is the guild ID the command is registered to, + # so this is correct even when a global command is called from a guild + cmd_index = AppCmdIndex( + type=inter.data.type, name=inter.data.name, guild_id=inter.data.guild_id + ) + # this happens to always be a slash command + slash_command = self._all_app_commands.get(cmd_index) if slash_command is None: return + slash_command = cast(InvokableSlashCommand, slash_command) + inter.application_command = slash_command if slash_command.guild_ids is None or inter.guild_id in slash_command.guild_ids: await slash_command._call_relevant_autocompleter(inter) @@ -1316,7 +1552,7 @@ async def process_application_commands( # and we're instructed to sync guild commands and self._command_sync_flags.sync_guild_commands # and the current command was registered to a guild - and interaction.data.get("guild_id") + and interaction.data.guild_id # and we don't know the command and not self.get_guild_command(interaction.guild_id, interaction.data.id) # type: ignore ): @@ -1351,20 +1587,19 @@ async def process_application_commands( return command_type = interaction.data.type - command_name = interaction.data.name - app_command = None event_name = None + # `inter.data.guild_id` is the guild ID the command is registered to, + # so this is correct even when a global command is called from a guild + cmd_index = AppCmdIndex( + type=command_type, name=interaction.data.name, guild_id=interaction.data.guild_id + ) + app_command = self._all_app_commands.get(cmd_index) if command_type is ApplicationCommandType.chat_input: - app_command = self.all_slash_commands.get(command_name) event_name = "slash_command" - elif command_type is ApplicationCommandType.user: - app_command = self.all_user_commands.get(command_name) event_name = "user_command" - elif command_type is ApplicationCommandType.message: - app_command = self.all_message_commands.get(command_name) event_name = "message_command" if event_name is None or app_command is None: diff --git a/disnake/ext/commands/slash_core.py b/disnake/ext/commands/slash_core.py index a23cf86bd3..685bc3cd34 100644 --- a/disnake/ext/commands/slash_core.py +++ b/disnake/ext/commands/slash_core.py @@ -796,9 +796,11 @@ def slash_command( .. versionadded:: 2.5 - guild_ids: List[:class:`int`] - If specified, the client will register the command in these guilds. - Otherwise, this command will be registered globally. + guild_ids: Optional[Sequence[:class:`int`]] + If specified, the client will register the command to these guilds. + Otherwise the command will be registered globally, unless + parameter ``test_guilds`` is specified in the bot constructor, in which case + this command will be registered to those guilds. connectors: Dict[:class:`str`, :class:`str`] Binds function names to option names. If the name of an option already matches the corresponding function param, diff --git a/disnake/interactions/application_command.py b/disnake/interactions/application_command.py index 13c96c02de..d5248176fd 100644 --- a/disnake/interactions/application_command.py +++ b/disnake/interactions/application_command.py @@ -174,16 +174,19 @@ class ApplicationCommandInteractionData(Dict[str, Any]): All resolved objects related to this interaction. options: List[:class:`ApplicationCommandInteractionDataOption`] A list of options from the API. + guild_id: Optional[:class:`int`] + ID of the guild the command is registered to. target_id: :class:`int` - ID of the user or message targetted by a user or message command + ID of the user or message targeted by a user or message command. target: Union[:class:`User`, :class:`Member`, :class:`Message`] - The user or message targetted by a user or message command + The user or message targeted by a user or message command. """ __slots__ = ( "id", "name", "type", + "guild_id", "target_id", "target", "resolved", @@ -195,7 +198,7 @@ def __init__( *, data: ApplicationCommandInteractionDataPayload, state: ConnectionState, - guild_id: Optional[int], + guild_id: Optional[int], # the ID of the guild where this command has been invoked ) -> None: super().__init__(data) self.id: int = int(data["id"]) @@ -205,6 +208,7 @@ def __init__( self.resolved = InteractionDataResolved( data=data.get("resolved", {}), state=state, guild_id=guild_id ) + self.guild_id: Optional[int] = utils._get_as_snowflake(data, "guild_id") self.target_id: Optional[int] = utils._get_as_snowflake(data, "target_id") target = self.resolved.get_by_id(self.target_id) self.target: Optional[Union[User, Member, Message]] = target # type: ignore diff --git a/docs/ext/commands/api/app_commands.rst b/docs/ext/commands/api/app_commands.rst index f4d2d6f290..67a78846f4 100644 --- a/docs/ext/commands/api/app_commands.rst +++ b/docs/ext/commands/api/app_commands.rst @@ -138,6 +138,13 @@ CommandSyncFlags .. autoclass:: CommandSyncFlags() :members: +AppCmdIndex +~~~~~~~~~~~ + +.. attributetable:: AppCmdIndex + +.. autoclass:: AppCmdIndex + Injection ~~~~~~~~~ diff --git a/docs/ext/commands/api/exceptions.rst b/docs/ext/commands/api/exceptions.rst index bde1d7d446..094ac5dbbc 100644 --- a/docs/ext/commands/api/exceptions.rst +++ b/docs/ext/commands/api/exceptions.rst @@ -186,6 +186,9 @@ Exceptions .. autoexception:: CommandRegistrationError :members: +.. autoexception:: ApplicationCommandRegistrationError + :members: + Exception Hierarchy ~~~~~~~~~~~~~~~~~~~ @@ -252,6 +255,7 @@ Exception Hierarchy - :exc:`ExtensionNotFound` - :exc:`~.ClientException` - :exc:`CommandRegistrationError` + - :exc:`ApplicationCommandRegistrationError` Warnings --------