diff --git a/disnake/app_commands.py b/disnake/app_commands.py index b5f6345b6c..a276f601da 100644 --- a/disnake/app_commands.py +++ b/disnake/app_commands.py @@ -468,12 +468,14 @@ def __init__( name: LocalizedRequired, dm_permission: Optional[bool] = None, default_member_permissions: Optional[Union[Permissions, int]] = None, + id: Optional[int] = None, ): self.type: ApplicationCommandType = enum_if_int(ApplicationCommandType, type) name_loc = Localized._cast(name, True) self.name: str = name_loc.string self.name_localizations: LocalizationValue = name_loc.localizations + self.id: Optional[int] = id self.dm_permission: bool = True if dm_permission is None else dm_permission @@ -535,6 +537,11 @@ def __eq__(self, other) -> bool: and self._default_permission == other._default_permission ) + def need_sync(self, other: ApplicationCommand) -> bool: + if self != other: + return True + return self.id is not None and self.id != other.id + def to_dict(self) -> EditApplicationCommandPayload: data: EditApplicationCommandPayload = { "type": try_enum_to_int(self.type), @@ -550,6 +557,9 @@ def to_dict(self) -> EditApplicationCommandPayload: if (loc := self.name_localizations.data) is not None: data["name_localizations"] = loc + if self.id is not None: + data["id"] = self.id + return data def localize(self, store: LocalizationProtocol) -> None: @@ -595,12 +605,14 @@ def __init__( name: LocalizedRequired, dm_permission: Optional[bool] = None, default_member_permissions: Optional[Union[Permissions, int]] = None, + id: Optional[int] = None, ): super().__init__( type=ApplicationCommandType.user, name=name, dm_permission=dm_permission, default_member_permissions=default_member_permissions, + id=id, ) @@ -636,6 +648,9 @@ class APIUserCommand(UserCommand, _APIApplicationCommandMixin): __repr_info__ = UserCommand.__repr_info__ + _APIApplicationCommandMixin.__repr_info__ + if TYPE_CHECKING: + id: int + @classmethod def from_dict(cls, data: ApplicationCommandPayload) -> Self: cmd_type = data.get("type", 0) @@ -678,12 +693,14 @@ def __init__( name: LocalizedRequired, dm_permission: Optional[bool] = None, default_member_permissions: Optional[Union[Permissions, int]] = None, + id: Optional[int] = None, ): super().__init__( type=ApplicationCommandType.message, name=name, dm_permission=dm_permission, default_member_permissions=default_member_permissions, + id=id, ) @@ -719,6 +736,9 @@ class APIMessageCommand(MessageCommand, _APIApplicationCommandMixin): __repr_info__ = MessageCommand.__repr_info__ + _APIApplicationCommandMixin.__repr_info__ + if TYPE_CHECKING: + id: int + @classmethod def from_dict(cls, data: ApplicationCommandPayload) -> Self: cmd_type = data.get("type", 0) @@ -779,12 +799,14 @@ def __init__( options: Optional[List[Option]] = None, dm_permission: Optional[bool] = None, default_member_permissions: Optional[Union[Permissions, int]] = None, + id: Optional[int] = None, ): super().__init__( type=ApplicationCommandType.chat_input, name=name, dm_permission=dm_permission, default_member_permissions=default_member_permissions, + id=id, ) _validate_name(self.name) @@ -898,6 +920,9 @@ class APISlashCommand(SlashCommand, _APIApplicationCommandMixin): __repr_info__ = SlashCommand.__repr_info__ + _APIApplicationCommandMixin.__repr_info__ + if TYPE_CHECKING: + id: int + @classmethod def from_dict(cls, data: ApplicationCommandPayload) -> Self: cmd_type = data.get("type", 0) diff --git a/disnake/client.py b/disnake/client.py index 296787939d..99903dabdb 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -2478,6 +2478,10 @@ async def bulk_overwrite_global_commands( .. versionadded:: 2.1 + .. versionchanged:: 2.6 + + Modifies the commands' ``id`` attribute to correspond to the version on the API. + Parameters ---------- application_commands: List[:class:`.ApplicationCommand`] @@ -2490,7 +2494,16 @@ async def bulk_overwrite_global_commands( """ for cmd in application_commands: cmd.localize(self.i18n) - return await self._connection.bulk_overwrite_global_commands(application_commands) + res = await self._connection.bulk_overwrite_global_commands(application_commands) + + for api_command in res: + cmd = utils.get(application_commands, name=api_command.name) + if not cmd: + # consider a warning + continue + cmd.id = api_command.id + + return res # Application commands (guild) @@ -2634,7 +2647,16 @@ async def bulk_overwrite_guild_commands( """ for cmd in application_commands: cmd.localize(self.i18n) - return await self._connection.bulk_overwrite_guild_commands(guild_id, application_commands) + res = await self._connection.bulk_overwrite_guild_commands(guild_id, application_commands) + + for api_command in res: + cmd = utils.get(application_commands, name=api_command.name) + if not cmd: + # consider a warning + continue + cmd.id = api_command.id + + return res # Application command permissions @@ -2647,6 +2669,10 @@ async def bulk_fetch_command_permissions( .. versionadded:: 2.1 + .. versionchanged:: 2.6 + + Modifies the commands' ``id`` attribute to correspond to the version on the API. + Parameters ---------- guild_id: :class:`int` diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index 48aa54eb4d..a6aabcbad4 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -134,7 +134,6 @@ def __init__(self, func: CommandCallback, *, name: Optional[str] = None, **kwarg self.__command_flag__ = None self._callback: CommandCallback = func self.name: str = name or func.__name__ - self.qualified_name: str = self.name # Annotation parser needs this attribute because body doesn't exist at this moment. # We will use this attribute later in order to set the dm_permission. self._guild_only: bool = kwargs.get("guild_only", False) @@ -236,11 +235,28 @@ def _update_copy(self: AppCommandT, kwargs: Dict[str, Any]) -> AppCommandT: else: return self.copy() + @property + def id(self) -> Optional[int]: + return self.body.id + + @id.setter + def id(self, id: int): + self.body.id = id + @property def dm_permission(self) -> bool: """:class:`bool`: Whether this command can be used in DMs.""" return self.body.dm_permission + @property + def qualified_name(self) -> str: + return self.name + + @property + def mention(self) -> str: + # todo: add docs and make ID non-nullable + return f"" + @property def default_member_permissions(self) -> Optional[Permissions]: """Optional[:class:`.Permissions`]: The default required member permissions for this command. diff --git a/disnake/ext/commands/ctx_menus_core.py b/disnake/ext/commands/ctx_menus_core.py index 6f2c965879..470e02cc18 100644 --- a/disnake/ext/commands/ctx_menus_core.py +++ b/disnake/ext/commands/ctx_menus_core.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, Sequence, Tuple, Union from disnake.app_commands import MessageCommand, UserCommand +from disnake.enums import ApplicationCommandType from disnake.i18n import Localized from disnake.permissions import Permissions @@ -16,7 +17,7 @@ if TYPE_CHECKING: from typing_extensions import ParamSpec - from disnake.i18n import LocalizedOptional + from disnake.i18n import LocalizationValue, LocalizedOptional from disnake.interactions import ( ApplicationCommandInteraction, MessageCommandInteraction, @@ -30,7 +31,7 @@ __all__ = ("InvokableUserCommand", "InvokableMessageCommand", "user_command", "message_command") -class InvokableUserCommand(InvokableApplicationCommand): +class InvokableUserCommand(InvokableApplicationCommand, UserCommand): """A class that implements the protocol for a bot user command (context menu). These are not created manually, instead they are created via the @@ -77,6 +78,7 @@ def __init__( default_member_permissions: Optional[Union[Permissions, int]] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, + id: Optional[int] = None, **kwargs, ): name_loc = Localized._cast(name, False) @@ -101,8 +103,23 @@ def __init__( name=name_loc._upgrade(self.name), dm_permission=dm_permission and not self._guild_only, default_member_permissions=default_member_permissions, + id=id, ) + self._name_localised = name_loc + + @property + def type(self) -> Literal[ApplicationCommandType.user]: + return ApplicationCommandType.user + + @property + def qualified_name(self) -> str: + return self.name + + @property + def name_localizations(self) -> LocalizationValue: + return self._name_localised.localizations + async def _call_external_error_handlers( self, inter: ApplicationCommandInteraction, error: CommandError ) -> None: @@ -130,7 +147,7 @@ async def __call__( await safe_call(self.callback, interaction, *args, **kwargs) -class InvokableMessageCommand(InvokableApplicationCommand): +class InvokableMessageCommand(InvokableApplicationCommand, MessageCommand): """A class that implements the protocol for a bot message command (context menu). These are not created manually, instead they are created via the @@ -177,6 +194,7 @@ def __init__( default_member_permissions: Optional[Union[Permissions, int]] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, + id: Optional[int] = None, **kwargs, ): name_loc = Localized._cast(name, False) @@ -195,7 +213,21 @@ def __init__( name=name_loc._upgrade(self.name), dm_permission=dm_permission and not self._guild_only, default_member_permissions=default_member_permissions, + id=id, ) + self._name_localised = name_loc + + @property + def type(self) -> Literal[ApplicationCommandType.message]: + return ApplicationCommandType.message + + @property + def qualified_name(self) -> str: + return self.name + + @property + def name_localizations(self) -> LocalizationValue: + return self._name_localised.localizations async def _call_external_error_handlers( self, inter: ApplicationCommandInteraction, error: CommandError diff --git a/disnake/ext/commands/errors.py b/disnake/ext/commands/errors.py index 47cc5762f7..9d783a4e4f 100644 --- a/disnake/ext/commands/errors.py +++ b/disnake/ext/commands/errors.py @@ -1016,11 +1016,16 @@ class CommandRegistrationError(ClientException): Whether the name that conflicts is an alias of the command we try to add. """ - def __init__(self, name: str, *, alias_conflict: bool = False) -> None: + def __init__( + self, name: str, *, alias_conflict: bool = False, guild_id: Optional[int] = None + ) -> None: self.name: str = name self.alias_conflict: bool = alias_conflict + self.guild_id: Optional[int] = guild_id type_ = "alias" if alias_conflict else "command" - super().__init__(f"The {type_} {name} is already an existing command or alias.") + msg = f"The {type_} {name} is already an existing command or alias" + msg += "." if not guild_id else f" in guild ID {guild_id}." + super().__init__(msg) class FlagError(BadArgument): diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index b3d9c3aa25..055cb296c7 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -7,7 +7,6 @@ import sys import traceback import warnings -from itertools import chain from typing import ( TYPE_CHECKING, Any, @@ -15,6 +14,8 @@ Dict, Iterable, List, + Literal, + NamedTuple, Optional, Sequence, Set, @@ -22,6 +23,7 @@ TypedDict, TypeVar, Union, + overload, ) import disnake @@ -71,11 +73,18 @@ _log = logging.getLogger(__name__) +class AppCommandMetadata(NamedTuple): + name: str + guild_id: Optional[int] + type: ApplicationCommandType + + class _Diff(TypedDict): no_changes: List[ApplicationCommand] upsert: List[ApplicationCommand] edit: List[ApplicationCommand] delete: List[ApplicationCommand] + delete_ignored: NotRequired[List[ApplicationCommand]] @@ -104,7 +113,7 @@ def _app_commands_diff( elif new_cmd._always_synced: diff["no_changes"].append(old_cmd) continue - elif new_cmd != old_cmd: + elif new_cmd.need_sync(old_cmd): diff["edit"].append(new_cmd) else: diff["no_changes"].append(new_cmd) @@ -213,9 +222,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[AppCommandMetadata, InvokableApplicationCommand] = {} self._schedule_app_command_preparation() @@ -229,11 +236,11 @@ 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(), - ) + yield from self._all_app_commands.values() + + @property + def all_app_commands(self) -> List[InvokableApplicationCommand]: + return list(self._all_app_commands.values()) @property def application_commands(self) -> Set[InvokableApplicationCommand]: @@ -243,17 +250,32 @@ def application_commands(self) -> Set[InvokableApplicationCommand]: @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 { # type: ignore # this will always be a slash command + command + for meta, command in self._all_app_commands.items() + if meta.type is ApplicationCommandType.chat_input + } @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 { # type: ignore # this will always be a user command + command + for meta, command in self._all_app_commands.items() + if meta.type is ApplicationCommandType.user + } @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 { # type: ignore # this will always be a message command + command + for meta, command in self._all_app_commands.items() + if meta.type is ApplicationCommandType.message + } + + def add_app_command(self, command: InvokableApplicationCommand) -> None: + ... def add_slash_command(self, slash_command: InvokableSlashCommand) -> None: """Adds an :class:`InvokableSlashCommand` into the internal list of slash commands. @@ -279,11 +301,30 @@ def add_slash_command(self, slash_command: InvokableSlashCommand) -> None: 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 + if slash_command.guild_ids: + for guild_id in slash_command.guild_ids: + if disnake.utils.get( + self._all_app_commands, + name=slash_command.name, + type=slash_command.type, + guild_id=guild_id, + ): + raise CommandRegistrationError(slash_command.name, guild_id=guild_id) + self._all_app_commands[ + AppCommandMetadata(slash_command.name, guild_id, slash_command.type) + ] = slash_command + else: + if disnake.utils.get( + self._all_app_commands, + name=slash_command.name, + type=slash_command.type, + guild_id=None, + ): + raise CommandRegistrationError(slash_command.name) + self._all_app_commands[ + AppCommandMetadata(slash_command.name, None, slash_command.type) + ] = slash_command def add_user_command(self, user_command: InvokableUserCommand) -> None: """Adds an :class:`InvokableUserCommand` into the internal list of user commands. @@ -309,11 +350,30 @@ def add_user_command(self, user_command: InvokableUserCommand) -> None: 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 + if user_command.guild_ids: + for guild_id in user_command.guild_ids: + if disnake.utils.get( + self._all_app_commands, + name=user_command.name, + type=user_command.type, + guild_id=guild_id, + ): + raise CommandRegistrationError(user_command.name, guild_id=guild_id) + self._all_app_commands[ + AppCommandMetadata(user_command.name, guild_id, user_command.type) + ] = user_command + else: + if disnake.utils.get( + self._all_app_commands, + name=user_command.name, + type=user_command.type, + guild_id=None, + ): + raise CommandRegistrationError(user_command.name) + self._all_app_commands[ + AppCommandMetadata(user_command.name, None, user_command.type) + ] = user_command def add_message_command(self, message_command: InvokableMessageCommand) -> None: """Adds an :class:`InvokableMessageCommand` into the internal list of message commands. @@ -341,13 +401,34 @@ def add_message_command(self, message_command: InvokableMessageCommand) -> None: "The message_command passed must be an instance of InvokableMessageCommand" ) - if message_command.name in self.all_message_commands: - raise CommandRegistrationError(message_command.name) - message_command.body.localize(self.i18n) - self.all_message_commands[message_command.name] = message_command - - def remove_slash_command(self, name: str) -> Optional[InvokableSlashCommand]: + if message_command.guild_ids: + for guild_id in message_command.guild_ids: + if disnake.utils.get( + self._all_app_commands, + name=message_command.name, + type=message_command.type, + guild_id=guild_id, + ): + raise CommandRegistrationError(message_command.name, guild_id=guild_id) + self._all_app_commands[ + AppCommandMetadata(message_command.name, guild_id, message_command.type) + ] = message_command + else: + if disnake.utils.get( + self._all_app_commands, + name=message_command.name, + type=message_command.type, + guild_id=None, + ): + raise CommandRegistrationError(message_command.name) + self._all_app_commands[ + AppCommandMetadata(message_command.name, None, message_command.type) + ] = message_command + + def remove_slash_command( + self, name: str, *, guild_id: Optional[int] = None + ) -> Optional[InvokableSlashCommand]: """Removes an :class:`InvokableSlashCommand` from the internal list of slash commands. @@ -361,12 +442,22 @@ 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 guild_id: + meta = AppCommandMetadata( + name=name, type=ApplicationCommandType.chat_input, guild_id=guild_id + ) + else: + meta = AppCommandMetadata( + name=name, guild_id=None, type=ApplicationCommandType.chat_input + ) + command: InvokableSlashCommand = self._all_app_commands.pop(meta, None) # type: ignore if command is None: return None return command - def remove_user_command(self, name: str) -> Optional[InvokableUserCommand]: + def remove_user_command( + self, name: str, *, guild_id: Optional[int] = None + ) -> Optional[InvokableUserCommand]: """Removes an :class:`InvokableUserCommand` from the internal list of user commands. @@ -380,12 +471,15 @@ 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) + meta = AppCommandMetadata(name=name, type=ApplicationCommandType.user, guild_id=guild_id) + command: InvokableUserCommand = self._all_app_commands.pop(meta, None) # type: ignore if command is None: return None return command - def remove_message_command(self, name: str) -> Optional[InvokableMessageCommand]: + def remove_message_command( + self, name: str, *, guild_id: Optional[int] = None + ) -> Optional[InvokableMessageCommand]: """Removes an :class:`InvokableMessageCommand` from the internal list of message commands. @@ -399,13 +493,55 @@ 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) + meta = AppCommandMetadata(name=name, guild_id=guild_id, type=ApplicationCommandType.message) + command: InvokableMessageCommand = self._all_app_commands.pop(meta, None) # type: ignore + if command is None: + return None + return command + + @overload + def get_app_command( + self, + name: str, + type: Literal[ApplicationCommandType.chat_input], + *, + guild_id: Optional[int] = None, + ) -> Optional[InvokableSlashCommand]: + ... + + @overload + def get_app_command( + self, + name: str, + type: Literal[ApplicationCommandType.message], + *, + guild_id: Optional[int] = None, + ) -> Optional[InvokableMessageCommand]: + ... + + @overload + def get_app_command( + self, + name: str, + type: Literal[ApplicationCommandType.user], + *, + guild_id: Optional[int] = None, + ) -> Optional[InvokableUserCommand]: + ... + + def get_app_command( + self, name: str, type: ApplicationCommandType, *, guild_id: Optional[int] = None + ) -> Optional[InvokableApplicationCommand]: + # this does not get commands by ID, use (some other method) to do that + if not isinstance(name, str): + raise TypeError(f"Expected name to be str, not {name.__class__}") + command = self._all_app_commands.get(AppCommandMetadata(name, type=type, guild_id=guild_id)) if command is None: return None return command def get_slash_command( - self, name: str + self, name: str, *, guild_id: Optional[int] = None ) -> Optional[Union[InvokableSlashCommand, SubCommandGroup, SubCommand]]: """Works like ``Bot.get_command``, but for slash commands. @@ -433,7 +569,7 @@ 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]) + slash = self.get_app_command(chain[0], ApplicationCommandType.chat_input, guild_id=guild_id) if slash is None: return None @@ -446,7 +582,9 @@ def get_slash_command( if isinstance(group, SubCommandGroup): return group.children.get(chain[2]) - def get_user_command(self, name: str) -> Optional[InvokableUserCommand]: + def get_user_command( + self, name: str, *, guild_id: Optional[int] = None + ) -> Optional[InvokableUserCommand]: """Gets an :class:`InvokableUserCommand` from the internal list of user commands. @@ -460,9 +598,11 @@ def get_user_command(self, name: str) -> Optional[InvokableUserCommand]: Optional[:class:`InvokableUserCommand`] The user command that was requested. If not found, returns ``None``. """ - return self.all_user_commands.get(name) + return self.get_app_command(name, ApplicationCommandType.user, guild_id=guild_id) - def get_message_command(self, name: str) -> Optional[InvokableMessageCommand]: + def get_message_command( + self, name: str, *, guild_id: Optional[int] = None + ) -> Optional[InvokableMessageCommand]: """Gets an :class:`InvokableMessageCommand` from the internal list of message commands. @@ -476,7 +616,7 @@ def get_message_command(self, name: str) -> Optional[InvokableMessageCommand]: Optional[:class:`InvokableMessageCommand`] The message command that was requested. If not found, returns ``None``. """ - return self.all_message_commands.get(name) + return self.get_app_command(name, ApplicationCommandType.message, guild_id=guild_id) def slash_command( self, @@ -744,11 +884,16 @@ async def _cache_application_commands(self) -> None: try: commands = await self.fetch_global_commands(with_localizations=True) - self._connection._global_application_commands = { + self._connection._global_application_commands = global_commands = { command.id: command for command in commands } except (disnake.HTTPException, TypeError): pass + else: + for command in self.application_commands_iterator(): + if command.body.id is not None and command.body.id not in global_commands: + command.body.id = None + for guild_id in guilds: try: commands = await self.fetch_guild_commands(guild_id, with_localizations=True) @@ -758,6 +903,9 @@ async def _cache_application_commands(self) -> None: } except (disnake.HTTPException, TypeError): pass + else: + # add ID info to guild slash commands as well. + ... async def _sync_application_commands(self) -> None: if not isinstance(self, disnake.Client): @@ -854,8 +1002,38 @@ async def _prepare_application_commands(self) -> None: await self.wait_until_first_connect() await self._cache_application_commands() await self._sync_application_commands() + self._fill_app_command_ids() self._sync_queued = False + def _fill_app_command_ids(self): + # this requires that _cache_application_commands was ran first + if not isinstance(self, disnake.Client): + raise NotImplementedError("This method is only usable in disnake.Client subclasses") + + all_commands = self._all_app_commands + for api_command in self._connection._global_application_commands.values(): + cmdmeta = disnake.utils.get(all_commands, name=api_command.name, guild_id=None) + if not cmdmeta: + # consider logging + continue + cmd = all_commands[cmdmeta] + # todo: think about body usage + if cmd.body.id is None: + cmd.body.id = api_command.id + # also fill guild commands + for guild_id, api_commands in ( + (x, y.values()) for x, y in self._connection._guild_application_commands.items() + ): + + for api_command in api_commands: + cmdmeta = disnake.utils.get(all_commands, name=api_command.name, guild_id=guild_id) + if not cmdmeta: + # consider logging + continue + cmd = all_commands[cmdmeta] + if cmd.body.id is None: + cmd.body.id = api_command.id + async def _delayed_command_sync(self) -> None: if not isinstance(self, disnake.Client): raise NotImplementedError("This method is only usable in disnake.Client subclasses") @@ -873,6 +1051,7 @@ async def _delayed_command_sync(self) -> None: self._sync_queued = True await asyncio.sleep(2) await self._sync_application_commands() + self._fill_app_command_ids() self._sync_queued = False def _schedule_app_command_preparation(self) -> None: @@ -1252,7 +1431,13 @@ async def process_app_command_autocompletion( inter: :class:`disnake.ApplicationCommandInteraction` The interaction to process. """ - slash_command = self.all_slash_commands.get(inter.data.name) + slash_command: InvokableSlashCommand = self._all_app_commands.get( # type: ignore :3 + AppCommandMetadata( + name=inter.data.name, + type=ApplicationCommandType.chat_input, + guild_id=inter.guild_id, + ) + ) if slash_command is None: return @@ -1324,24 +1509,29 @@ async def process_application_commands( return command_type = interaction.data.type - command_name = interaction.data.name app_command = None event_name = None + for command in self._all_app_commands.values(): + if command.body.id == interaction.data.id: + app_command = command + break + elif command.body.id is None: + ... + else: + app_command = None + 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: - # If we are here, the command being invoked is either unknown or has an unknonw type. + # If we are here, the command being invoked is either unknown or has an unknown type. # This usually happens if the auto sync is disabled, so let's just ignore this. return diff --git a/disnake/ext/commands/slash_core.py b/disnake/ext/commands/slash_core.py index 57c855bb7d..f284e6313a 100644 --- a/disnake/ext/commands/slash_core.py +++ b/disnake/ext/commands/slash_core.py @@ -10,6 +10,7 @@ Callable, Dict, List, + Literal, Optional, Sequence, Tuple, @@ -19,7 +20,7 @@ from disnake import utils from disnake.app_commands import Option, SlashCommand -from disnake.enums import OptionType +from disnake.enums import ApplicationCommandType, OptionType from disnake.i18n import Localized from disnake.interactions import ApplicationCommandInteraction from disnake.permissions import Permissions @@ -30,7 +31,7 @@ if TYPE_CHECKING: from disnake.app_commands import Choices - from disnake.i18n import LocalizedOptional + from disnake.i18n import LocalizationValue, LocalizedOptional from .base_core import CommandCallback @@ -146,7 +147,7 @@ def __init__( ): name_loc = Localized._cast(name, False) super().__init__(func, name=name_loc.string, **kwargs) - self.parent: InvokableSlashCommand = parent + self._parent: InvokableSlashCommand = parent self.children: Dict[str, SubCommand] = {} self.option = Option( name=name_loc._upgrade(self.name), @@ -154,7 +155,6 @@ def __init__( type=OptionType.sub_command_group, options=[], ) - self.qualified_name: str = f"{parent.qualified_name} {self.name}" if ( "dm_permission" in kwargs @@ -172,20 +172,37 @@ def root_parent(self) -> InvokableSlashCommand: .. versionadded:: 2.6 """ - return self.parent + return self._parent @property def parents(self) -> Tuple[InvokableSlashCommand]: - """Tuple[:class:`InvokableSlashCommand`]: Returns all parents of this group. + """Tuple[:class:`InvokableSlashCommand`]: Retrieves the parents of this command. + + If the command has no parents then it returns an empty :class:`tuple`. + + For example in commands ``/a b test``, the parents are ``(b, a)``. .. versionadded:: 2.6 """ - return (self.parent,) + return (self._parent,) @property def body(self) -> Option: return self.option + @property + def qualified_name(self) -> str: + return f"{self._parent.qualified_name} {self.name}" + + @property + def mention(self) -> str: + # todo: add docs and make ID non-nullable + return f"" + + @property + def parent(self) -> InvokableSlashCommand: + return self._parent + def sub_command( self, name: LocalizedOptional = None, @@ -216,6 +233,7 @@ def decorator(func: CommandCallback) -> SubCommand: extras=extras, **kwargs, ) + new_func._parent = self self.children[new_func.name] = new_func self.option.options.append(new_func.option) return new_func @@ -262,6 +280,9 @@ class SubCommand(InvokableApplicationCommand): This object may be copied by the library. .. versionadded:: 2.5 + parent: ... + + .. versionadded:: 2.6 """ def __init__( @@ -277,7 +298,7 @@ def __init__( ): name_loc = Localized._cast(name, False) super().__init__(func, name=name_loc.string, **kwargs) - self.parent: Union[InvokableSlashCommand, SubCommandGroup] = parent + self._parent: Union[InvokableSlashCommand, SubCommandGroup] = parent self.connectors: Dict[str, str] = connectors or {} self.autocompleters: Dict[str, Any] = kwargs.get("autocompleters", {}) @@ -295,7 +316,6 @@ def __init__( type=OptionType.sub_command, options=options, ) - self.qualified_name = f"{parent.qualified_name} {self.name}" if ( "dm_permission" in kwargs @@ -313,7 +333,7 @@ def root_parent(self) -> InvokableSlashCommand: .. versionadded:: 2.6 """ - return self.parent.parent if isinstance(self.parent, SubCommandGroup) else self.parent + return self._parent._parent if isinstance(self._parent, SubCommandGroup) else self._parent @property def parents( @@ -326,10 +346,10 @@ def parents( .. versionadded:: 2.6 """ - # here I'm not using 'self.parent.parents + (self.parent,)' because it causes typing issues - if isinstance(self.parent, SubCommandGroup): - return (self.parent, self.parent.parent) - return (self.parent,) + # here I'm not using 'self._parent._parents + (self._parent,)' because it causes typing issues + if isinstance(self._parent, SubCommandGroup): + return (self._parent, self._parent._parent) + return (self._parent,) @property def description(self) -> str: @@ -339,6 +359,21 @@ def description(self) -> str: def body(self) -> Option: return self.option + @property + def qualified_name(self) -> str: + if not self._parent: + return self.name + return f"{self._parent.qualified_name} {self.name}" + + @property + def mention(self) -> str: + # todo: add docs and make ID non-nullable + return f"" + + @property + def parent(self) -> Union[InvokableSlashCommand, SubCommandGroup]: + return self._parent + async def _call_autocompleter( self, param: str, inter: ApplicationCommandInteraction, user_input: str ) -> Optional[Choices]: @@ -379,7 +414,7 @@ def autocomplete(self, option_name: str) -> Callable[[Callable], Callable]: return _autocomplete(self, option_name) -class InvokableSlashCommand(InvokableApplicationCommand): +class InvokableSlashCommand(InvokableApplicationCommand, SlashCommand): """A class that implements the protocol for a bot slash command. These are not created manually, instead they are created via the @@ -437,11 +472,15 @@ def __init__( guild_ids: Optional[Sequence[int]] = None, connectors: Optional[Dict[str, str]] = None, auto_sync: Optional[bool] = None, + id: Optional[int] = None, **kwargs, ): name_loc = Localized._cast(name, False) - super().__init__(func, name=name_loc.string, **kwargs) - self.parent = None + super().__init__( + func, + name=name_loc.string, + **kwargs, + ) self.connectors: Dict[str, str] = connectors or {} self.children: Dict[str, Union[SubCommand, SubCommandGroup]] = {} self.auto_sync: bool = True if auto_sync is None else auto_sync @@ -453,6 +492,8 @@ def __init__( self.docstring = utils.parse_docstring(func) desc_loc = Localized._cast(description, False) + self._name_localised = name_loc + self._description_localised = desc_loc try: default_member_permissions = func.__default_member_permissions__ @@ -469,6 +510,7 @@ def __init__( options=options or [], dm_permission=dm_permission and not self._guild_only, default_member_permissions=default_member_permissions, + id=id, ) @property @@ -487,6 +529,13 @@ def parents(self) -> Tuple[()]: """ return () + @property + def parent(self) -> None: + """``None``: This is for consistency with :class:`SubCommand` and :class:`SubCommandGroup`. + + .. versionadded:: 2.6 + """ + def _ensure_assignment_on_copy(self, other: SlashCommandT) -> SlashCommandT: super()._ensure_assignment_on_copy(other) if self.connectors != other.connectors: @@ -497,7 +546,7 @@ def _ensure_assignment_on_copy(self, other: SlashCommandT) -> SlashCommandT: other.children = self.children.copy() # update parents... for child in other.children.values(): - child.parent = other + child._parent = other if self.description != other.description and "description" not in other.__original_kwargs__: # Allows overriding the default description cog-wide. other.body.description = self.description @@ -505,6 +554,10 @@ def _ensure_assignment_on_copy(self, other: SlashCommandT) -> SlashCommandT: other.body.options = self.options return other + @property + def type(self) -> Literal[ApplicationCommandType.chat_input]: + return ApplicationCommandType.chat_input + @property def description(self) -> str: return self.body.description @@ -513,6 +566,14 @@ def description(self) -> str: def options(self) -> List[Option]: return self.body.options + @property + def name_localizations(self) -> LocalizationValue: + return self._name_localised.localizations + + @property + def description_localizations(self) -> LocalizationValue: + return self._description_localised.localizations + def sub_command( self, name: LocalizedOptional = None, @@ -573,6 +634,7 @@ def decorator(func: CommandCallback) -> SubCommand: extras=extras, **kwargs, ) + new_func._parent = self self.children[new_func.name] = new_func self.body.options.append(new_func.option) return new_func @@ -619,6 +681,7 @@ def decorator(func: CommandCallback) -> SubCommandGroup: extras=extras, **kwargs, ) + new_func._parent = self self.children[new_func.name] = new_func self.body.options.append(new_func.option) return new_func diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 9a83737c79..b0a2761eb5 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -311,6 +311,10 @@ class InteractionMessageReference(TypedDict): class EditApplicationCommand(TypedDict): name: str + # TODO: properly seperate these payloads + id: NotRequired[ + Snowflake + ] # when this is provided we are able to change the name, this is also slightly wrong in this payload name_localizations: NotRequired[Optional[ApplicationCommandLocalizations]] description: NotRequired[str] description_localizations: NotRequired[Optional[ApplicationCommandLocalizations]]