From a95ddafdb5c8edd58f3907ad6c839c5bc07687b0 Mon Sep 17 00:00:00 2001 From: vi <8530778+shiftinv@users.noreply.github.com> Date: Fri, 13 Dec 2024 22:03:10 +0100 Subject: [PATCH] feat: user apps (#1173) Co-authored-by: onerandomusername --- changelog/1173.deprecate.0.rst | 1 + changelog/1173.deprecate.1.rst | 1 + changelog/1173.feature.rst | 13 + disnake/app_commands.py | 322 ++++++++++++++----- disnake/appinfo.py | 87 ++++- disnake/ext/commands/base_core.py | 155 ++++++++- disnake/ext/commands/ctx_menus_core.py | 97 +++++- disnake/ext/commands/interaction_bot_base.py | 85 ++++- disnake/ext/commands/slash_core.py | 90 ++++-- disnake/flags.py | 253 +++++++++++++-- disnake/interactions/application_command.py | 40 ++- disnake/interactions/base.py | 72 +++-- disnake/interactions/message.py | 15 + disnake/interactions/modal.py | 15 + disnake/message.py | 151 ++++++++- disnake/types/appinfo.py | 11 +- disnake/types/interactions.py | 52 ++- disnake/types/message.py | 5 +- disnake/utils.py | 31 +- docs/api/app_commands.rst | 16 + docs/api/app_info.rst | 8 + docs/api/interactions.rst | 11 + docs/api/messages.rst | 12 +- docs/ext/commands/api/app_commands.rst | 9 + docs/ext/commands/api/checks.rst | 3 - docs/ext/commands/slash_commands.rst | 109 +++++-- tests/ext/commands/test_base_core.py | 24 +- tests/test_utils.py | 57 ++-- 28 files changed, 1500 insertions(+), 245 deletions(-) create mode 100644 changelog/1173.deprecate.0.rst create mode 100644 changelog/1173.deprecate.1.rst create mode 100644 changelog/1173.feature.rst diff --git a/changelog/1173.deprecate.0.rst b/changelog/1173.deprecate.0.rst new file mode 100644 index 0000000000..16b4e2ca5a --- /dev/null +++ b/changelog/1173.deprecate.0.rst @@ -0,0 +1 @@ +Deprecate :attr:`ApplicationCommand.dm_permission` and related fields/parameters of application command objects. Use ``contexts`` instead. diff --git a/changelog/1173.deprecate.1.rst b/changelog/1173.deprecate.1.rst new file mode 100644 index 0000000000..7248d4f297 --- /dev/null +++ b/changelog/1173.deprecate.1.rst @@ -0,0 +1 @@ +Deprecate :attr:`Message.interaction` attribute and :class:`InteractionReference`. Use :attr:`Message.interaction_metadata` instead. diff --git a/changelog/1173.feature.rst b/changelog/1173.feature.rst new file mode 100644 index 0000000000..02268dbb8b --- /dev/null +++ b/changelog/1173.feature.rst @@ -0,0 +1,13 @@ +Add support for user-installed commands. See :ref:`app_command_contexts` for further details. +- Add :attr:`ApplicationCommand.install_types` and :attr:`ApplicationCommand.contexts` fields, + with respective :class:`ApplicationInstallTypes` and :class:`InteractionContextTypes` flag types. +- :class:`Interaction` changes: + - Add :attr:`Interaction.context` field, reflecting the context in which the interaction occurred. + - Add :attr:`Interaction.authorizing_integration_owners` field and :class:`AuthorizingIntegrationOwners` class, containing details about the application installation. + - :attr:`Interaction.app_permissions` is now always provided by Discord. +- Add :attr:`Message.interaction_metadata` and :class:`InteractionMetadata` type, containing metadata for the interaction associated with a message. +- Add ``integration_type`` parameter to :func:`utils.oauth_url`. +- Add :attr:`AppInfo.guild_install_type_config` and :attr:`AppInfo.user_install_type_config` fields. +- |commands| Add ``install_types`` and ``contexts`` parameters to application command decorators. +- |commands| Add :func:`~ext.commands.install_types` and :func:`~ext.commands.contexts` decorators. +- |commands| Using the :class:`GuildCommandInteraction` annotation now sets :attr:`~ApplicationCommand.install_types` and :attr:`~ApplicationCommand.contexts`, instead of :attr:`~ApplicationCommand.dm_permission`. diff --git a/disnake/app_commands.py b/disnake/app_commands.py index 1da2b8576f..cd6eb76ece 100644 --- a/disnake/app_commands.py +++ b/disnake/app_commands.py @@ -17,9 +17,10 @@ try_enum, try_enum_to_int, ) +from .flags import ApplicationInstallTypes, InteractionContextTypes from .i18n import Localized from .permissions import Permissions -from .utils import MISSING, _get_as_snowflake, _maybe_cast +from .utils import MISSING, _get_as_snowflake, _maybe_cast, deprecated, warn_deprecated if TYPE_CHECKING: from typing_extensions import Self @@ -485,34 +486,44 @@ class ApplicationCommand(ABC): # noqa: B024 # this will get refactored eventua .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - Defaults to ``True``. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. Defaults to ``False``. .. versionadded:: 2.8 + + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 """ __repr_info__: ClassVar[Tuple[str, ...]] = ( "type", "name", - "dm_permission", - "default_member_permisions", + "default_member_permissions", "nsfw", + "install_types", + "contexts", ) def __init__( self, type: ApplicationCommandType, name: LocalizedRequired, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, ) -> None: self.type: ApplicationCommandType = enum_if_int(ApplicationCommandType, type) @@ -521,8 +532,6 @@ def __init__( self.name_localizations: LocalizationValue = name_loc.localizations self.nsfw: bool = False if nsfw is None else nsfw - self.dm_permission: bool = True if dm_permission is None else dm_permission - self._default_member_permissions: Optional[int] if default_member_permissions is None: # allow everyone to use the command if its not supplied @@ -534,11 +543,31 @@ def __init__( else: self._default_member_permissions = default_member_permissions.value + # note: this defaults to `[0]` for syncing purposes only + self.install_types: Optional[ApplicationInstallTypes] = install_types + self.contexts: Optional[InteractionContextTypes] = contexts + self._always_synced: bool = False # reset `default_permission` if set before self._default_permission: bool = True + self._dm_permission: Optional[bool] = dm_permission + if self._dm_permission is not None: + warn_deprecated( + "dm_permission is deprecated, use contexts instead.", + stacklevel=2, + # the call stack can have different depths, depending on how the + # user created the command, so we can't reliably set a fixed stacklevel + skip_internal_frames=True, + ) + + # if both are provided, raise an exception + # (n.b. these can be assigned to later, in which case no exception will be raised. + # assume the user knows what they're doing, in that case) + if self.contexts is not None: + raise ValueError("Cannot use both `dm_permission` and `contexts` at the same time") + @property def default_member_permissions(self) -> Optional[Permissions]: """Optional[:class:`Permissions`]: The default required member permissions for this command. @@ -557,6 +586,26 @@ def default_member_permissions(self) -> Optional[Permissions]: return None return Permissions(self._default_member_permissions) + @property + @deprecated("contexts") + def dm_permission(self) -> bool: + """ + Whether this command can be used in DMs with the bot. + + .. versionadded:: 2.5 + + .. deprecated:: 2.10 + Use :attr:`contexts` instead. + This is equivalent to the :attr:`InteractionContextTypes.bot_dm` flag. + """ + # a `None` value is equivalent to `True` here + return self._dm_permission is not False + + @dm_permission.setter + @deprecated("contexts") + def dm_permission(self, value: bool) -> None: + self._dm_permission = value + def __repr__(self) -> str: attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_info__) return f"<{type(self).__name__} {attrs}>" @@ -565,36 +614,77 @@ def __str__(self) -> str: return self.name def __eq__(self, other) -> bool: - return ( + if not ( self.type == other.type and self.name == other.name and self.name_localizations == other.name_localizations and self.nsfw == other.nsfw and self._default_member_permissions == other._default_member_permissions - # ignore `dm_permission` if comparing guild commands - and ( - any( - (isinstance(obj, _APIApplicationCommandMixin) and obj.guild_id) - for obj in (self, other) - ) - or self.dm_permission == other.dm_permission - ) and self._default_permission == other._default_permission - ) + ): + return False + + # ignore global-only fields if comparing guild commands + if not any( + (isinstance(obj, _APIApplicationCommandMixin) and obj.guild_id) for obj in (self, other) + ): + if self._install_types_with_default != other._install_types_with_default: + return False + + # `contexts` takes priority over `dm_permission`; + # ignore `dm_permission` if `contexts` is set, + # since the API returns both even when only `contexts` was provided + if self.contexts is not None or other.contexts is not None: + if self.contexts != other.contexts: + return False + else: + # this is a bit awkward; `None` is equivalent to `True` in this case + if (self._dm_permission is not False) != (other._dm_permission is not False): + return False + + return True + + @property + def _install_types_with_default(self) -> Optional[ApplicationInstallTypes]: + # if this is an api-provided command object, keep things as-is + if self.install_types is None and not isinstance(self, _APIApplicationCommandMixin): + # The purpose of this default is to avoid re-syncing after the updating to the new version, + # at least as long as the user hasn't enabled user installs in the dev portal + # (i.e. if they haven't, the api defaults to this value as well). + # Additionally, this provides consistency independent of the dev portal configuration, + # even if it might not be ideal. + # In an ideal world, we would make use of `application_info().install_types_config`. + return ApplicationInstallTypes(guild=True) + + return self.install_types def to_dict(self) -> EditApplicationCommandPayload: data: EditApplicationCommandPayload = { "type": try_enum_to_int(self.type), "name": self.name, - "dm_permission": self.dm_permission, + "default_member_permissions": ( + str(self._default_member_permissions) + if self._default_member_permissions is not None + else None + ), "default_permission": True, "nsfw": self.nsfw, } - if self._default_member_permissions is None: - data["default_member_permissions"] = None - else: - data["default_member_permissions"] = str(self._default_member_permissions) + install_types = ( + self._install_types_with_default.values + if self._install_types_with_default is not None + else None + ) + data["integration_types"] = install_types + + contexts = self.contexts.values if self.contexts is not None else None + data["contexts"] = contexts + + # don't set `dm_permission` if `contexts` is set + if contexts is None: + data["dm_permission"] = self._dm_permission is not False + if (loc := self.name_localizations.data) is not None: data["name_localizations"] = loc @@ -608,13 +698,20 @@ class _APIApplicationCommandMixin: __repr_info__ = ("id",) def _update_common(self, data: ApplicationCommandPayload) -> None: + if not isinstance(self, ApplicationCommand): + raise TypeError("_APIApplicationCommandMixin must be used with ApplicationCommand") + self.id: int = int(data["id"]) self.application_id: int = int(data["application_id"]) self.guild_id: Optional[int] = _get_as_snowflake(data, "guild_id") self.version: int = int(data["version"]) + # deprecated, but kept until API stops returning this field self._default_permission = data.get("default_permission") is not False + # same deal, also deprecated. + self._dm_permission = data.get("dm_permission") + class UserCommand(ApplicationCommand): """A user context menu command. @@ -628,27 +725,36 @@ class UserCommand(ApplicationCommand): .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - Defaults to ``True``. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. Defaults to ``False``. .. versionadded:: 2.8 + + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 """ - __repr_info__ = ("name", "dm_permission", "default_member_permissions") + __repr_info__ = tuple(n for n in ApplicationCommand.__repr_info__ if n != "type") def __init__( self, name: LocalizedRequired, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, ) -> None: super().__init__( type=ApplicationCommandType.user, @@ -656,6 +762,8 @@ def __init__( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, ) @@ -673,16 +781,24 @@ class APIUserCommand(UserCommand, _APIApplicationCommandMixin): .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. .. versionadded:: 2.8 + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 + id: :class:`int` The user command's ID. application_id: :class:`int` @@ -703,9 +819,18 @@ def from_dict(cls, data: ApplicationCommandPayload) -> Self: self = cls( name=Localized(data["name"], data=data.get("name_localizations")), - dm_permission=data.get("dm_permission") is not False, default_member_permissions=_get_as_snowflake(data, "default_member_permissions"), nsfw=data.get("nsfw"), + install_types=( + ApplicationInstallTypes._from_values(install_types) + if (install_types := data.get("integration_types")) is not None + else None + ), + contexts=( + InteractionContextTypes._from_values(contexts) + if (contexts := data.get("contexts")) is not None + else None + ), ) self._update_common(data) return self @@ -723,27 +848,36 @@ class MessageCommand(ApplicationCommand): .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - Defaults to ``True``. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. Defaults to ``False``. .. versionadded:: 2.8 + + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 """ - __repr_info__ = ("name", "dm_permission", "default_member_permissions") + __repr_info__ = tuple(n for n in ApplicationCommand.__repr_info__ if n != "type") def __init__( self, name: LocalizedRequired, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, ) -> None: super().__init__( type=ApplicationCommandType.message, @@ -751,6 +885,8 @@ def __init__( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, ) @@ -768,16 +904,24 @@ class APIMessageCommand(MessageCommand, _APIApplicationCommandMixin): .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. .. versionadded:: 2.8 + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 + id: :class:`int` The message command's ID. application_id: :class:`int` @@ -798,9 +942,18 @@ def from_dict(cls, data: ApplicationCommandPayload) -> Self: self = cls( name=Localized(data["name"], data=data.get("name_localizations")), - dm_permission=data.get("dm_permission") is not False, default_member_permissions=_get_as_snowflake(data, "default_member_permissions"), nsfw=data.get("nsfw"), + install_types=( + ApplicationInstallTypes._from_values(install_types) + if (install_types := data.get("integration_types")) is not None + else None + ), + contexts=( + InteractionContextTypes._from_values(contexts) + if (contexts := data.get("contexts")) is not None + else None + ), ) self._update_common(data) return self @@ -825,28 +978,32 @@ class SlashCommand(ApplicationCommand): .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - Defaults to ``True``. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. Defaults to ``False``. .. versionadded:: 2.8 + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 + options: List[:class:`Option`] The list of options the slash command has. """ - __repr_info__ = ( - "name", + __repr_info__ = tuple(n for n in ApplicationCommand.__repr_info__ if n != "type") + ( "description", "options", - "dm_permission", - "default_member_permissions", ) def __init__( @@ -854,9 +1011,11 @@ def __init__( name: LocalizedRequired, description: LocalizedRequired, options: Optional[List[Option]] = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, ) -> None: super().__init__( type=ApplicationCommandType.chat_input, @@ -864,6 +1023,8 @@ def __init__( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, ) _validate_name(self.name) @@ -957,16 +1118,24 @@ class APISlashCommand(SlashCommand, _APIApplicationCommandMixin): .. versionadded:: 2.5 - dm_permission: :class:`bool` - Whether this command can be used in DMs. - - .. versionadded:: 2.5 - nsfw: :class:`bool` Whether this command is :ddocs:`age-restricted `. .. versionadded:: 2.8 + install_types: Optional[:class:`ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`ApplicationInstallTypes.guild` only. + Only available for global commands. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + .. versionadded:: 2.10 + id: :class:`int` The slash command's ID. options: List[:class:`Option`] @@ -993,9 +1162,18 @@ def from_dict(cls, data: ApplicationCommandPayload) -> Self: options=_maybe_cast( data.get("options", MISSING), lambda x: list(map(Option.from_dict, x)) ), - dm_permission=data.get("dm_permission") is not False, default_member_permissions=_get_as_snowflake(data, "default_member_permissions"), nsfw=data.get("nsfw"), + install_types=( + ApplicationInstallTypes._from_values(install_types) + if (install_types := data.get("integration_types")) is not None + else None + ), + contexts=( + InteractionContextTypes._from_values(contexts) + if (contexts := data.get("contexts")) is not None + else None + ), ) self._update_common(data) return self diff --git a/disnake/appinfo.py b/disnake/appinfo.py index 15468f1eb5..0264c3df51 100644 --- a/disnake/appinfo.py +++ b/disnake/appinfo.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, cast from . import utils from .asset import Asset @@ -14,6 +14,8 @@ from .state import ConnectionState from .types.appinfo import ( AppInfo as AppInfoPayload, + ApplicationIntegrationType as ApplicationIntegrationTypeLiteral, + ApplicationIntegrationTypeConfiguration as ApplicationIntegrationTypeConfigurationPayload, InstallParams as InstallParamsPayload, PartialAppInfo as PartialAppInfoPayload, Team as TeamPayload, @@ -24,6 +26,7 @@ "AppInfo", "PartialAppInfo", "InstallParams", + "InstallTypeConfiguration", ) @@ -42,12 +45,20 @@ class InstallParams: __slots__ = ( "_app_id", + "_install_type", "scopes", "permissions", ) - def __init__(self, data: InstallParamsPayload, parent: AppInfo) -> None: + def __init__( + self, + data: InstallParamsPayload, + parent: AppInfo, + *, + install_type: Optional[ApplicationIntegrationTypeLiteral] = None, + ) -> None: self._app_id = parent.id + self._install_type: Optional[ApplicationIntegrationTypeLiteral] = install_type self.scopes = data["scopes"] self.permissions = Permissions(int(data["permissions"])) @@ -55,14 +66,48 @@ def __repr__(self) -> str: return f"" def to_url(self) -> str: - """Return a string that can be used to add this application to a server. + """Returns a string that can be used to install this application. Returns ------- :class:`str` The invite url. """ - return utils.oauth_url(self._app_id, scopes=self.scopes, permissions=self.permissions) + return utils.oauth_url( + self._app_id, + scopes=self.scopes, + permissions=self.permissions, + integration_type=( + self._install_type if self._install_type is not None else utils.MISSING + ), + ) + + +class InstallTypeConfiguration: + """Represents the configuration for a particular application installation type. + + .. versionadded:: 2.10 + + Attributes + ---------- + install_params: Optional[:class:`InstallParams`] + The parameters for this installation type. + """ + + __slots__ = ("install_params",) + + def __init__( + self, + data: ApplicationIntegrationTypeConfigurationPayload, + *, + parent: AppInfo, + install_type: ApplicationIntegrationTypeLiteral, + ) -> None: + self.install_params: Optional[InstallParams] = ( + InstallParams(install_params, parent=parent, install_type=install_type) + if (install_params := data.get("oauth2_install_params")) + else None + ) class AppInfo: @@ -138,6 +183,9 @@ class AppInfo: install_params: Optional[:class:`InstallParams`] The installation parameters for this application. + See also :attr:`guild_install_type_config`/:attr:`user_install_type_config` + for installation type-specific configuration. + .. versionadded:: 2.5 custom_install_url: Optional[:class:`str`] @@ -187,6 +235,7 @@ class AppInfo: "role_connections_verification_url", "approximate_guild_count", "approximate_user_install_count", + "_install_types_config", ) def __init__(self, state: ConnectionState, data: AppInfoPayload) -> None: @@ -231,6 +280,18 @@ def __init__(self, state: ConnectionState, data: AppInfoPayload) -> None: self.approximate_guild_count: int = data.get("approximate_guild_count", 0) self.approximate_user_install_count: int = data.get("approximate_user_install_count", 0) + # this is a bit of a mess, but there's no better way to expose this data for now + self._install_types_config: Dict[ + ApplicationIntegrationTypeLiteral, InstallTypeConfiguration + ] = {} + for type_str, config in (data.get("integration_types_config") or {}).items(): + install_type = cast("ApplicationIntegrationTypeLiteral", int(type_str)) + self._install_types_config[install_type] = InstallTypeConfiguration( + config or {}, + parent=self, + install_type=install_type, + ) + def __repr__(self) -> str: return ( f"<{self.__class__.__name__} id={self.id} name={self.name!r} " @@ -280,6 +341,24 @@ def summary(self) -> str: ) return self._summary + @property + def guild_install_type_config(self) -> Optional[InstallTypeConfiguration]: + """Optional[:class:`InstallTypeConfiguration`]: The guild installation parameters for + this application. If this application cannot be installed to guilds, returns ``None``. + + .. versionadded:: 2.10 + """ + return self._install_types_config.get(0) + + @property + def user_install_type_config(self) -> Optional[InstallTypeConfiguration]: + """Optional[:class:`InstallTypeConfiguration`]: The user installation parameters for + this application. If this application cannot be installed to users, returns ``None``. + + .. versionadded:: 2.10 + """ + return self._install_types_config.get(1) + class PartialAppInfo: """Represents a partial AppInfo given by :func:`~disnake.abc.GuildChannel.create_invite`. diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index 804d3717f6..ff9b8afd76 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -22,6 +22,7 @@ from disnake.app_commands import ApplicationCommand from disnake.enums import ApplicationCommandType +from disnake.flags import ApplicationInstallTypes, InteractionContextTypes from disnake.permissions import Permissions from disnake.utils import _generated, _overload_with_permissions, async_all, maybe_coroutine @@ -49,7 +50,12 @@ ] -__all__ = ("InvokableApplicationCommand", "default_member_permissions") +__all__ = ( + "InvokableApplicationCommand", + "default_member_permissions", + "install_types", + "contexts", +) T = TypeVar("T") @@ -136,7 +142,7 @@ def __init__(self, func: CommandCallback, *, name: Optional[str] = None, **kwarg 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. + # We will use this attribute later in order to set the allowed contexts. self._guild_only: bool = kwargs.get("guild_only", False) self.extras: Dict[str, Any] = kwargs.get("extras") or {} @@ -146,9 +152,16 @@ def __init__(self, func: CommandCallback, *, name: Optional[str] = None, **kwarg if "default_permission" in kwargs: raise TypeError( "`default_permission` is deprecated and will always be set to `True`. " - "See `default_member_permissions` and `dm_permission` instead." + "See `default_member_permissions` and `contexts` instead." ) + # XXX: remove in next major/minor version + # the parameter was called `integration_types` in earlier stages of the user apps PR. + # since unknown kwargs unfortunately get silently ignored, at least try to warn users + # in this specific case + if "integration_types" in kwargs: + raise TypeError("`integration_types` has been renamed to `install_types`.") + try: checks = func.__commands_checks__ checks.reverse() @@ -184,6 +197,7 @@ def __init__(self, func: CommandCallback, *, name: Optional[str] = None, **kwarg self._before_invoke: Optional[Hook] = None self._after_invoke: Optional[Hook] = None + # this should copy all attributes that can be changed after instantiation via decorators def _ensure_assignment_on_copy(self, other: AppCommandT) -> AppCommandT: other._before_invoke = self._before_invoke other._after_invoke = self._after_invoke @@ -195,12 +209,29 @@ def _ensure_assignment_on_copy(self, other: AppCommandT) -> AppCommandT: # _max_concurrency won't be None at this point other._max_concurrency = cast(MaxConcurrency, self._max_concurrency).copy() - if self.body._default_member_permissions != other.body._default_member_permissions and ( - "default_member_permissions" not in other.__original_kwargs__ - or self.body._default_member_permissions is not None + if ( + # see https://github.com/DisnakeDev/disnake/pull/678#discussion_r938113624: + # if these are not equal, then either `self` had a decorator, or `other` got a + # value from `*_command_attrs`; we only want to copy in the former case + self.body._default_member_permissions != other.body._default_member_permissions + and self.body._default_member_permissions is not None ): other.body._default_member_permissions = self.body._default_member_permissions + if ( + self.body.install_types != other.body.install_types + and self.body.install_types is not None # see above + ): + other.body.install_types = ApplicationInstallTypes._from_value( + self.body.install_types.value + ) + + if ( + self.body.contexts != other.body.contexts + and self.body.contexts is not None # see above + ): + other.body.contexts = InteractionContextTypes._from_value(self.body.contexts.value) + try: other.on_error = self.on_error except AttributeError: @@ -228,6 +259,15 @@ def _update_copy(self: AppCommandT, kwargs: Dict[str, Any]) -> AppCommandT: else: return self.copy() + def _apply_guild_only(self) -> None: + # If we have a `GuildCommandInteraction` annotation, set `contexts` and `install_types` accordingly. + # This matches the old pre-user-apps behavior. + if self._guild_only: + # n.b. this overwrites any user-specified parameter + # FIXME(3.0): this should raise if these were set elsewhere (except `*_command_attrs`) already + self.body.contexts = InteractionContextTypes(guild=True) + self.body.install_types = ApplicationInstallTypes(guild=True) + @property def dm_permission(self) -> bool: """:class:`bool`: Whether this command can be used in DMs.""" @@ -249,6 +289,24 @@ def default_member_permissions(self) -> Optional[Permissions]: """ return self.body.default_member_permissions + @property + def install_types(self) -> Optional[ApplicationInstallTypes]: + """Optional[:class:`.ApplicationInstallTypes`]: The installation types + where the command is available. Only available for global commands. + + .. versionadded:: 2.10 + """ + return self.body.install_types + + @property + def contexts(self) -> Optional[InteractionContextTypes]: + """Optional[:class:`.InteractionContextTypes`]: The interaction contexts + where the command can be used. Only available for global commands. + + .. versionadded:: 2.10 + """ + return self.body.contexts + @property def callback(self) -> CommandCallback: return self._callback @@ -704,7 +762,7 @@ def default_member_permissions( @_overload_with_permissions def default_member_permissions(value: int = 0, **permissions: bool) -> Callable[[T], T]: - """A decorator that sets default required member permissions for the command. + """A decorator that sets default required member permissions for the application command. Unlike :func:`~.has_permissions`, this decorator does not add any checks. Instead, it prevents the command from being run by members without *all* required permissions, if not overridden by moderators on a guild-specific basis. @@ -714,6 +772,8 @@ def default_member_permissions(value: int = 0, **permissions: bool) -> Callable[ .. note:: This does not work with slash subcommands/groups. + .. versionadded:: 2.5 + Example ------- @@ -761,3 +821,84 @@ def decorator(func: T) -> T: return func return decorator + + +def install_types(*, guild: bool = False, user: bool = False) -> Callable[[T], T]: + """A decorator that sets the installation types where the + application command is available. + + See also the ``install_types`` parameter for application command decorators. + + .. note:: + This does not work with slash subcommands/groups. + + .. versionadded:: 2.10 + + Parameters + ---------- + **params: bool + The installation types; see :class:`.ApplicationInstallTypes`. + Setting a parameter to ``False`` does not affect the result. + """ + + def decorator(func: T) -> T: + from .slash_core import SubCommand, SubCommandGroup + + install_types = ApplicationInstallTypes(guild=guild, user=user) + if isinstance(func, InvokableApplicationCommand): + if isinstance(func, (SubCommand, SubCommandGroup)): + raise TypeError("Cannot set `install_types` on subcommands or subcommand groups") + # special case - don't overwrite if `_guild_only` was set, since that takes priority + if not func._guild_only: + if func.body.install_types is not None: + raise ValueError("Cannot set `install_types` in both parameter and decorator") + func.body.install_types = install_types + else: + func.__install_types__ = install_types # type: ignore + return func + + return decorator + + +def contexts( + *, guild: bool = False, bot_dm: bool = False, private_channel: bool = False +) -> Callable[[T], T]: + """A decorator that sets the interaction contexts where the application command can be used. + + See also the ``contexts`` parameter for application command decorators. + + .. note:: + This does not work with slash subcommands/groups. + + .. versionadded:: 2.10 + + Parameters + ---------- + **params: bool + The interaction contexts; see :class:`.InteractionContextTypes`. + Setting a parameter to ``False`` does not affect the result. + """ + + def decorator(func: T) -> T: + from .slash_core import SubCommand, SubCommandGroup + + contexts = InteractionContextTypes( + guild=guild, bot_dm=bot_dm, private_channel=private_channel + ) + if isinstance(func, InvokableApplicationCommand): + if isinstance(func, (SubCommand, SubCommandGroup)): + raise TypeError("Cannot set `contexts` on subcommands or subcommand groups") + # special case - don't overwrite if `_guild_only` was set, since that takes priority + if not func._guild_only: + if func.body._dm_permission is not None: + raise ValueError( + "Cannot use both `dm_permission` and `contexts` at the same time" + ) + if func.body.contexts is not None: + raise ValueError("Cannot set `contexts` in both parameter and decorator") + func.body.contexts = contexts + else: + func.__contexts__ = contexts # type: ignore + return func + + return decorator diff --git a/disnake/ext/commands/ctx_menus_core.py b/disnake/ext/commands/ctx_menus_core.py index 9f59f03fc5..b7ad876424 100644 --- a/disnake/ext/commands/ctx_menus_core.py +++ b/disnake/ext/commands/ctx_menus_core.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence, Tuple, Union from disnake.app_commands import MessageCommand, UserCommand +from disnake.flags import ApplicationInstallTypes, InteractionContextTypes from disnake.i18n import Localized from disnake.permissions import Permissions @@ -73,9 +74,11 @@ def __init__( func: InteractionCommandCallback[CogT, UserCommandInteraction, P], *, name: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, **kwargs: Any, @@ -89,16 +92,26 @@ def __init__( default_member_permissions = func.__default_member_permissions__ except AttributeError: pass - - dm_permission = True if dm_permission is None else dm_permission + try: + install_types = func.__install_types__ + except AttributeError: + pass + try: + contexts = func.__contexts__ + except AttributeError: + pass self.body = UserCommand( name=name_loc._upgrade(self.name), - dm_permission=dm_permission and not self._guild_only, + dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, ) + self._apply_guild_only() + async def _call_external_error_handlers( self, inter: ApplicationCommandInteraction, error: CommandError ) -> None: @@ -173,9 +186,11 @@ def __init__( func: InteractionCommandCallback[CogT, MessageCommandInteraction, P], *, name: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, **kwargs: Any, @@ -189,16 +204,26 @@ def __init__( default_member_permissions = func.__default_member_permissions__ except AttributeError: pass - - dm_permission = True if dm_permission is None else dm_permission + try: + install_types = func.__install_types__ + except AttributeError: + pass + try: + contexts = func.__contexts__ + except AttributeError: + pass self.body = MessageCommand( name=name_loc._upgrade(self.name), - dm_permission=dm_permission and not self._guild_only, + dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, ) + self._apply_guild_only() + async def _call_external_error_handlers( self, inter: ApplicationCommandInteraction, error: CommandError ) -> None: @@ -233,9 +258,11 @@ async def __call__( def user_command( *, name: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, extras: Optional[Dict[str, Any]] = None, @@ -254,6 +281,11 @@ def user_command( dm_permission: :class:`bool` Whether this command can be used in DMs. Defaults to ``True``. + + .. deprecated:: 2.10 + Use ``contexts`` instead. + This is equivalent to the :attr:`.InteractionContextTypes.bot_dm` flag. + default_member_permissions: Optional[Union[:class:`.Permissions`, :class:`int`]] The default required permissions for this command. See :attr:`.ApplicationCommand.default_member_permissions` for details. @@ -266,6 +298,23 @@ def user_command( .. versionadded:: 2.8 + install_types: Optional[:class:`.ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`.ApplicationInstallTypes.guild` only. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`.InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True``. guild_ids: Sequence[:class:`int`] @@ -300,6 +349,8 @@ def decorator( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, guild_ids=guild_ids, auto_sync=auto_sync, extras=extras, @@ -312,9 +363,11 @@ def decorator( def message_command( *, name: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, extras: Optional[Dict[str, Any]] = None, @@ -336,6 +389,11 @@ def message_command( dm_permission: :class:`bool` Whether this command can be used in DMs. Defaults to ``True``. + + .. deprecated:: 2.10 + Use ``contexts`` instead. + This is equivalent to the :attr:`.InteractionContextTypes.bot_dm` flag. + default_member_permissions: Optional[Union[:class:`.Permissions`, :class:`int`]] The default required permissions for this command. See :attr:`.ApplicationCommand.default_member_permissions` for details. @@ -348,6 +406,23 @@ def message_command( .. versionadded:: 2.8 + install_types: Optional[:class:`.ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`.ApplicationInstallTypes.guild` only. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`.InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True``. guild_ids: Sequence[:class:`int`] @@ -382,6 +457,8 @@ def decorator( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, guild_ids=guild_ids, auto_sync=auto_sync, extras=extras, diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index 110066b679..4e9f8698ea 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -28,6 +28,7 @@ from disnake.app_commands import ApplicationCommand, Option from disnake.custom_warnings import SyncWarning from disnake.enums import ApplicationCommandType +from disnake.flags import ApplicationInstallTypes, InteractionContextTypes from disnake.utils import warn_deprecated from . import errors @@ -486,9 +487,11 @@ def slash_command( *, name: LocalizedOptional = None, description: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, options: Optional[List[Option]] = None, guild_ids: Optional[Sequence[int]] = None, connectors: Optional[Dict[str, str]] = None, @@ -519,6 +522,11 @@ def slash_command( dm_permission: :class:`bool` Whether this command can be used in DMs. Defaults to ``True``. + + .. deprecated:: 2.10 + Use ``contexts`` instead. + This is equivalent to the :attr:`.InteractionContextTypes.bot_dm` flag. + default_member_permissions: Optional[Union[:class:`.Permissions`, :class:`int`]] The default required permissions for this command. See :attr:`.ApplicationCommand.default_member_permissions` for details. @@ -531,6 +539,23 @@ def slash_command( .. versionadded:: 2.8 + install_types: Optional[:class:`.ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`.ApplicationInstallTypes.guild` only. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`.InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True`` guild_ids: Sequence[:class:`int`] @@ -564,6 +589,8 @@ def decorator(func: CommandCallback) -> InvokableSlashCommand: dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, guild_ids=guild_ids, connectors=connectors, auto_sync=auto_sync, @@ -579,9 +606,11 @@ def user_command( self, *, name: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, extras: Optional[Dict[str, Any]] = None, @@ -603,6 +632,11 @@ def user_command( dm_permission: :class:`bool` Whether this command can be used in DMs. Defaults to ``True``. + + .. deprecated:: 2.10 + Use ``contexts`` instead. + This is equivalent to the :attr:`.InteractionContextTypes.bot_dm` flag. + default_member_permissions: Optional[Union[:class:`.Permissions`, :class:`int`]] The default required permissions for this command. See :attr:`.ApplicationCommand.default_member_permissions` for details. @@ -615,6 +649,23 @@ def user_command( .. versionadded:: 2.8 + install_types: Optional[:class:`.ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`.ApplicationInstallTypes.guild` only. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`.InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True``. guild_ids: Sequence[:class:`int`] @@ -642,6 +693,8 @@ def decorator( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, guild_ids=guild_ids, auto_sync=auto_sync, extras=extras, @@ -656,9 +709,11 @@ def message_command( self, *, name: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, auto_sync: Optional[bool] = None, extras: Optional[Dict[str, Any]] = None, @@ -680,6 +735,11 @@ def message_command( dm_permission: :class:`bool` Whether this command can be used in DMs. Defaults to ``True``. + + .. deprecated:: 2.10 + Use ``contexts`` instead. + This is equivalent to the :attr:`.InteractionContextTypes.bot_dm` flag. + default_member_permissions: Optional[Union[:class:`.Permissions`, :class:`int`]] The default required permissions for this command. See :attr:`.ApplicationCommand.default_member_permissions` for details. @@ -692,6 +752,23 @@ def message_command( .. versionadded:: 2.8 + install_types: Optional[:class:`.ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`.ApplicationInstallTypes.guild` only. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`.InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + auto_sync: :class:`bool` Whether to automatically register the command. Defaults to ``True`` guild_ids: Sequence[:class:`int`] @@ -719,6 +796,8 @@ def decorator( dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, guild_ids=guild_ids, auto_sync=auto_sync, extras=extras, diff --git a/disnake/ext/commands/slash_core.py b/disnake/ext/commands/slash_core.py index 5c8dac4617..a42e881391 100644 --- a/disnake/ext/commands/slash_core.py +++ b/disnake/ext/commands/slash_core.py @@ -20,6 +20,7 @@ from disnake import utils from disnake.app_commands import Option, SlashCommand from disnake.enums import OptionType +from disnake.flags import ApplicationInstallTypes, InteractionContextTypes from disnake.i18n import Localized from disnake.interactions import ApplicationCommandInteraction from disnake.permissions import Permissions @@ -97,6 +98,29 @@ async def _call_autocompleter( return choices +_INVALID_SUB_KWARGS = frozenset( + {"dm_permission", "default_member_permissions", "install_types", "contexts"} +) + + +# this is just a helpful message for users trying to set specific +# top-level-only fields on subcommands or groups +def _check_invalid_sub_kwargs(func: CommandCallback, kwargs: Dict[str, Any]) -> None: + invalid_keys = kwargs.keys() & _INVALID_SUB_KWARGS + + for decorator_key in [ + "__default_member_permissions__", + "__install_types__", + "__contexts__", + ]: + if hasattr(func, decorator_key): + invalid_keys.add(decorator_key.strip("_")) + + if invalid_keys: + msg = f"Cannot set {utils.humanize_list(list(invalid_keys), 'or')} on subcommands or subcommand groups" + raise TypeError(msg) + + class SubCommandGroup(InvokableApplicationCommand): """A class that implements the protocol for a bot slash command group. @@ -156,14 +180,7 @@ def __init__( ) self.qualified_name: str = f"{parent.qualified_name} {self.name}" - if ( - "dm_permission" in kwargs - or "default_member_permissions" in kwargs - or hasattr(func, "__default_member_permissions__") - ): - raise TypeError( - "Cannot set `default_member_permissions` or `dm_permission` on subcommand groups" - ) + _check_invalid_sub_kwargs(func, kwargs) @property def root_parent(self) -> InvokableSlashCommand: @@ -296,14 +313,7 @@ def __init__( ) self.qualified_name = f"{parent.qualified_name} {self.name}" - if ( - "dm_permission" in kwargs - or "default_member_permissions" in kwargs - or hasattr(func, "__default_member_permissions__") - ): - raise TypeError( - "Cannot set `default_member_permissions` or `dm_permission` on subcommands" - ) + _check_invalid_sub_kwargs(func, kwargs) @property def root_parent(self) -> InvokableSlashCommand: @@ -433,9 +443,11 @@ def __init__( name: LocalizedOptional = None, description: LocalizedOptional = None, options: Optional[List[Option]] = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, guild_ids: Optional[Sequence[int]] = None, connectors: Optional[Dict[str, str]] = None, auto_sync: Optional[bool] = None, @@ -460,8 +472,14 @@ def __init__( default_member_permissions = func.__default_member_permissions__ except AttributeError: pass - - dm_permission = True if dm_permission is None else dm_permission + try: + install_types = func.__install_types__ + except AttributeError: + pass + try: + contexts = func.__contexts__ + except AttributeError: + pass self.body: SlashCommand = SlashCommand( name=name_loc._upgrade(self.name, key=self.docstring["localization_key_name"]), @@ -469,11 +487,15 @@ def __init__( self.docstring["description"] or "-", key=self.docstring["localization_key_desc"] ), options=options or [], - dm_permission=dm_permission and not self._guild_only, + dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, ) + self._apply_guild_only() + @property def root_parent(self) -> None: """``None``: This is for consistency with :class:`SubCommand` and :class:`SubCommandGroup`. @@ -750,9 +772,11 @@ def slash_command( *, name: LocalizedOptional = None, description: LocalizedOptional = None, - dm_permission: Optional[bool] = None, + dm_permission: Optional[bool] = None, # deprecated default_member_permissions: Optional[Union[Permissions, int]] = None, nsfw: Optional[bool] = None, + install_types: Optional[ApplicationInstallTypes] = None, + contexts: Optional[InteractionContextTypes] = None, options: Optional[List[Option]] = None, guild_ids: Optional[Sequence[int]] = None, connectors: Optional[Dict[str, str]] = None, @@ -784,12 +808,34 @@ def slash_command( .. versionadded:: 2.8 + install_types: Optional[:class:`.ApplicationInstallTypes`] + The installation types where the command is available. + Defaults to :attr:`.ApplicationInstallTypes.guild` only. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + + contexts: Optional[:class:`.InteractionContextTypes`] + The interaction contexts where the command can be used. + Only available for global commands. + + See :ref:`app_command_contexts` for details. + + .. versionadded:: 2.10 + options: List[:class:`.Option`] The list of slash command options. The options will be visible in Discord. This is the old way of specifying options. Consider using :ref:`param_syntax` instead. dm_permission: :class:`bool` Whether this command can be used in DMs. Defaults to ``True``. + + .. deprecated:: 2.10 + Use ``contexts`` instead. + This is equivalent to the :attr:`.InteractionContextTypes.bot_dm` flag. + default_member_permissions: Optional[Union[:class:`.Permissions`, :class:`int`]] The default required permissions for this command. See :attr:`.ApplicationCommand.default_member_permissions` for details. @@ -834,6 +880,8 @@ def decorator(func: CommandCallback) -> InvokableSlashCommand: dm_permission=dm_permission, default_member_permissions=default_member_permissions, nsfw=nsfw, + install_types=install_types, + contexts=contexts, guild_ids=guild_ids, connectors=connectors, auto_sync=auto_sync, diff --git a/disnake/flags.py b/disnake/flags.py index 41c319cfc9..5117f7b80b 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -43,6 +43,8 @@ "RoleFlags", "AttachmentFlags", "SKUFlags", + "ApplicationInstallTypes", + "InteractionContextTypes", ) BF = TypeVar("BF", bound="BaseFlags") @@ -1020,12 +1022,12 @@ class Intents(BaseFlags): .. describe:: Intents.y | Intents.z, Intents(y=True) | Intents.z - Returns a Intents instance with all provided flags enabled. + Returns an Intents instance with all provided flags enabled. .. versionadded:: 2.6 .. describe:: ~Intents.y - Returns a Intents instance with all flags except ``y`` inverted from their default value. + Returns an Intents instance with all flags except ``y`` inverted from their default value. .. versionadded:: 2.6 @@ -1102,21 +1104,21 @@ def __init__(self, value: Optional[int] = None, **kwargs: bool) -> None: @classmethod def all(cls) -> Self: - """A factory method that creates a :class:`Intents` with everything enabled.""" + """A factory method that creates an :class:`Intents` instance with everything enabled.""" self = cls.__new__(cls) self.value = all_flags_value(cls.VALID_FLAGS) return self @classmethod def none(cls) -> Self: - """A factory method that creates a :class:`Intents` with everything disabled.""" + """A factory method that creates an :class:`Intents` instance with everything disabled.""" self = cls.__new__(cls) self.value = self.DEFAULT_VALUE return self @classmethod def default(cls) -> Self: - """A factory method that creates a :class:`Intents` with everything enabled + """A factory method that creates an :class:`Intents` instance with everything enabled except :attr:`presences`, :attr:`members`, and :attr:`message_content`. """ self = cls.all() @@ -1805,14 +1807,14 @@ def __init__(self, **kwargs: bool) -> None: @classmethod def all(cls) -> Self: - """A factory method that creates a :class:`MemberCacheFlags` with everything enabled.""" + """A factory method that creates a :class:`MemberCacheFlags` instance with everything enabled.""" self = cls.__new__(cls) self.value = all_flags_value(cls.VALID_FLAGS) return self @classmethod def none(cls) -> Self: - """A factory method that creates a :class:`MemberCacheFlags` with everything disabled.""" + """A factory method that creates a :class:`MemberCacheFlags` instance with everything disabled.""" self = cls.__new__(cls) self.value = self.DEFAULT_VALUE return self @@ -1844,7 +1846,7 @@ def joined(self) -> int: @classmethod def from_intents(cls, intents: Intents) -> Self: - """A factory method that creates a :class:`MemberCacheFlags` based on + """A factory method that creates a :class:`MemberCacheFlags` instance based on the currently selected :class:`Intents`. Parameters @@ -1945,12 +1947,12 @@ class ApplicationFlags(BaseFlags): .. describe:: ApplicationFlags.y | ApplicationFlags.z, ApplicationFlags(y=True) | ApplicationFlags.z - Returns a ApplicationFlags instance with all provided flags enabled. + Returns an ApplicationFlags instance with all provided flags enabled. .. versionadded:: 2.6 .. describe:: ~ApplicationFlags.y - Returns a ApplicationFlags instance with all flags except ``y`` inverted from their default value. + Returns an ApplicationFlags instance with all flags except ``y`` inverted from their default value. .. versionadded:: 2.6 @@ -2233,11 +2235,11 @@ class AutoModKeywordPresets(ListBaseFlags): .. describe:: AutoModKeywordPresets.y | AutoModKeywordPresets.z, AutoModKeywordPresets(y=True) | AutoModKeywordPresets.z - Returns a AutoModKeywordPresets instance with all provided flags enabled. + Returns an AutoModKeywordPresets instance with all provided flags enabled. .. describe:: ~AutoModKeywordPresets.y - Returns a AutoModKeywordPresets instance with all flags except ``y`` inverted from their default value. + Returns an AutoModKeywordPresets instance with all flags except ``y`` inverted from their default value. .. versionadded:: 2.6 @@ -2259,15 +2261,15 @@ def __init__( ... @classmethod - def all(cls: Type[AutoModKeywordPresets]) -> AutoModKeywordPresets: - """A factory method that creates a :class:`AutoModKeywordPresets` with everything enabled.""" + def all(cls) -> Self: + """A factory method that creates an :class:`AutoModKeywordPresets` instance with everything enabled.""" self = cls.__new__(cls) self.value = all_flags_value(cls.VALID_FLAGS) return self @classmethod - def none(cls: Type[AutoModKeywordPresets]) -> AutoModKeywordPresets: - """A factory method that creates a :class:`AutoModKeywordPresets` with everything disabled.""" + def none(cls) -> Self: + """A factory method that creates an :class:`AutoModKeywordPresets` instance with everything disabled.""" self = cls.__new__(cls) self.value = self.DEFAULT_VALUE return self @@ -2307,16 +2309,16 @@ class MemberFlags(BaseFlags): Checks if two MemberFlags instances are not equal. .. describe:: x <= y - Checks if an MemberFlags instance is a subset of another MemberFlags instance. + Checks if a MemberFlags instance is a subset of another MemberFlags instance. .. describe:: x >= y - Checks if an MemberFlags instance is a superset of another MemberFlags instance. + Checks if a MemberFlags instance is a superset of another MemberFlags instance. .. describe:: x < y - Checks if an MemberFlags instance is a strict subset of another MemberFlags instance. + Checks if a MemberFlags instance is a strict subset of another MemberFlags instance. .. describe:: x > y - Checks if an MemberFlags instance is a strict superset of another MemberFlags instance. + Checks if a MemberFlags instance is a strict superset of another MemberFlags instance. .. describe:: x | y, x |= y Returns a new MemberFlags instance with all enabled flags from both x and y. @@ -2454,16 +2456,16 @@ class RoleFlags(BaseFlags): Checks if two RoleFlags instances are not equal. .. describe:: x <= y - Checks if an RoleFlags instance is a subset of another RoleFlags instance. + Checks if a RoleFlags instance is a subset of another RoleFlags instance. .. describe:: x >= y - Checks if an RoleFlags instance is a superset of another RoleFlags instance. + Checks if a RoleFlags instance is a superset of another RoleFlags instance. .. describe:: x < y - Checks if an RoleFlags instance is a strict subset of another RoleFlags instance. + Checks if a RoleFlags instance is a strict subset of another RoleFlags instance. .. describe:: x > y - Checks if an RoleFlags instance is a strict superset of another RoleFlags instance. + Checks if a RoleFlags instance is a strict superset of another RoleFlags instance. .. describe:: x | y, x |= y Returns a new RoleFlags instance with all enabled flags from both x and y. @@ -2572,11 +2574,11 @@ class AttachmentFlags(BaseFlags): .. describe:: AttachmentFlags.y | AttachmentFlags.z, AttachmentFlags(y=True) | AttachmentFlags.z - Returns a AttachmentFlags instance with all provided flags enabled. + Returns an AttachmentFlags instance with all provided flags enabled. .. describe:: ~AttachmentFlags.y - Returns a AttachmentFlags instance with all flags except ``y`` inverted from their default value. + Returns an AttachmentFlags instance with all flags except ``y`` inverted from their default value. .. versionadded:: 2.10 @@ -2652,11 +2654,11 @@ class SKUFlags(BaseFlags): .. describe:: SKUFlags.y | SKUFlags.z, SKUFlags(y=True) | SKUFlags.z - Returns a SKUFlags instance with all provided flags enabled. + Returns an SKUFlags instance with all provided flags enabled. .. describe:: ~SKUFlags.y - Returns a SKUFlags instance with all flags except ``y`` inverted from their default value. + Returns an SKUFlags instance with all flags except ``y`` inverted from their default value. .. versionadded:: 2.10 @@ -2695,3 +2697,198 @@ def guild_subscription(self): def user_subscription(self): """:class:`bool`: Returns ``True`` if the SKU is an application subscription applied to a user.""" return 1 << 8 + + +class ApplicationInstallTypes(ListBaseFlags): + """Represents the location(s) in which an application or application command can be installed. + + See the :ddocs:`official documentation ` for more info. + + .. collapse:: operations + + .. describe:: x == y + + Checks if two ApplicationInstallTypes instances are equal. + .. describe:: x != y + + Checks if two ApplicationInstallTypes instances are not equal. + .. describe:: x <= y + + Checks if an ApplicationInstallTypes instance is a subset of another ApplicationInstallTypes instance. + .. describe:: x >= y + + Checks if an ApplicationInstallTypes instance is a superset of another ApplicationInstallTypes instance. + .. describe:: x < y + + Checks if an ApplicationInstallTypes instance is a strict subset of another ApplicationInstallTypes instance. + .. describe:: x > y + + Checks if an ApplicationInstallTypes instance is a strict superset of another ApplicationInstallTypes instance. + .. describe:: x | y, x |= y + + Returns a new ApplicationInstallTypes instance with all enabled flags from both x and y. + (Using ``|=`` will update in place). + .. describe:: x & y, x &= y + + Returns a new ApplicationInstallTypes instance with only flags enabled on both x and y. + (Using ``&=`` will update in place). + .. describe:: x ^ y, x ^= y + + Returns a new ApplicationInstallTypes instance with only flags enabled on one of x or y, but not both. + (Using ``^=`` will update in place). + .. describe:: ~x + + Returns a new ApplicationInstallTypes instance with all flags from x inverted. + .. describe:: hash(x) + + Returns the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + Additionally supported are a few operations on class attributes. + + .. describe:: ApplicationInstallTypes.y | ApplicationInstallTypes.z, ApplicationInstallTypes(y=True) | ApplicationInstallTypes.z + + Returns an ApplicationInstallTypes instance with all provided flags enabled. + + .. describe:: ~ApplicationInstallTypes.y + + Returns an ApplicationInstallTypes instance with all flags except ``y`` inverted from their default value. + + .. versionadded:: 2.10 + + Attributes + ---------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + if TYPE_CHECKING: + + @_generated + def __init__(self, *, guild: bool = ..., user: bool = ...) -> None: + ... + + @classmethod + def all(cls) -> Self: + """A factory method that creates an :class:`ApplicationInstallTypes` instance with everything enabled.""" + self = cls.__new__(cls) + self.value = all_flags_value(cls.VALID_FLAGS) + return self + + @flag_value + def guild(self): + """:class:`bool`: Returns ``True`` if installable to guilds.""" + return 1 << 0 + + @flag_value + def user(self): + """:class:`bool`: Returns ``True`` if installable to users.""" + return 1 << 1 + + +class InteractionContextTypes(ListBaseFlags): + """Represents the context(s) in which an application command can be used. + + See the :ddocs:`official documentation ` for more info. + + .. collapse:: operations + + .. describe:: x == y + + Checks if two InteractionContextTypes instances are equal. + .. describe:: x != y + + Checks if two InteractionContextTypes instances are not equal. + .. describe:: x <= y + + Checks if an InteractionContextTypes instance is a subset of another InteractionContextTypes instance. + .. describe:: x >= y + + Checks if an InteractionContextTypes instance is a superset of another InteractionContextTypes instance. + .. describe:: x < y + + Checks if an InteractionContextTypes instance is a strict subset of another InteractionContextTypes instance. + .. describe:: x > y + + Checks if an InteractionContextTypes instance is a strict superset of another InteractionContextTypes instance. + .. describe:: x | y, x |= y + + Returns a new InteractionContextTypes instance with all enabled flags from both x and y. + (Using ``|=`` will update in place). + .. describe:: x & y, x &= y + + Returns a new InteractionContextTypes instance with only flags enabled on both x and y. + (Using ``&=`` will update in place). + .. describe:: x ^ y, x ^= y + + Returns a new InteractionContextTypes instance with only flags enabled on one of x or y, but not both. + (Using ``^=`` will update in place). + .. describe:: ~x + + Returns a new InteractionContextTypes instance with all flags from x inverted. + .. describe:: hash(x) + + Returns the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + Additionally supported are a few operations on class attributes. + + .. describe:: InteractionContextTypes.y | InteractionContextTypes.z, InteractionContextTypes(y=True) | InteractionContextTypes.z + + Returns an InteractionContextTypes instance with all provided flags enabled. + + .. describe:: ~InteractionContextTypes.y + + Returns an InteractionContextTypes instance with all flags except ``y`` inverted from their default value. + + .. versionadded:: 2.10 + + Attributes + ---------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + if TYPE_CHECKING: + + @_generated + def __init__( + self, *, bot_dm: bool = ..., guild: bool = ..., private_channel: bool = ... + ) -> None: + ... + + @classmethod + def all(cls) -> Self: + """A factory method that creates an :class:`InteractionContextTypes` instance with everything enabled.""" + self = cls.__new__(cls) + self.value = all_flags_value(cls.VALID_FLAGS) + return self + + @flag_value + def guild(self): + """:class:`bool`: Returns ``True`` if the command is usable in guilds.""" + return 1 << 0 + + @flag_value + def bot_dm(self): + """:class:`bool`: Returns ``True`` if the command is usable in DMs with the bot.""" + return 1 << 1 + + @flag_value + def private_channel(self): + """:class:`bool`: Returns ``True`` if the command is usable in DMs and group DMs with other users.""" + return 1 << 2 diff --git a/disnake/interactions/application_command.py b/disnake/interactions/application_command.py index bd85325a36..2148d7df9c 100644 --- a/disnake/interactions/application_command.py +++ b/disnake/interactions/application_command.py @@ -105,6 +105,21 @@ class ApplicationCommandInteraction(Interaction[ClientT]): .. versionadded:: 2.10 + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + Details about the authorizing user/guild for the application installation + related to the interaction. + + .. versionadded:: 2.10 + + context: :class:`InteractionContextTypes` + The context where the interaction was triggered from. + + This is a flag object, with exactly one of the flags set to ``True``. + To check whether an interaction originated from e.g. a :attr:`~InteractionContextTypes.guild` + context, you can use ``if interaction.context.guild:``. + + .. versionadded:: 2.10 + data: :class:`ApplicationCommandInteractionData` The wrapped interaction data. application_command: :class:`.InvokableApplicationCommand` @@ -140,15 +155,18 @@ def filled_options(self) -> Dict[str, Any]: return kwargs +# TODO(3.0): consider making these classes @type_check_only and not affect runtime behavior, or even remove entirely class GuildCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass, primarily meant for annotations. - This prevents the command from being invoked in DMs by automatically setting - :attr:`ApplicationCommand.dm_permission` to ``False`` for user/message commands and top-level slash commands. - + This restricts the command to only be usable in guilds and only as a guild-installed command, + by automatically setting :attr:`ApplicationCommand.contexts` to :attr:`~InteractionContextTypes.guild` only + and :attr:`ApplicationCommand.install_types` to :attr:`~ApplicationInstallTypes.guild` only. Note that this does not apply to slash subcommands, subcommand groups, or autocomplete callbacks. - Additionally, annotations of some attributes are modified to match the expected types in guilds. + Additionally, the type annotations of :attr:`~Interaction.author`, :attr:`~Interaction.guild`, + :attr:`~Interaction.guild_id`, :attr:`~Interaction.guild_locale`, and :attr:`~Interaction.me` + are modified to match the expected types in guilds. """ author: Member @@ -159,20 +177,22 @@ class GuildCommandInteraction(ApplicationCommandInteraction[ClientT]): class UserCommandInteraction(ApplicationCommandInteraction[ClientT]): - """An :class:`ApplicationCommandInteraction` subclass meant for annotations. + """An :class:`ApplicationCommandInteraction` subclass meant for annotations + in user context menu commands. - No runtime behavior is changed but annotations are modified - to seem like the interaction is specifically a user command. + No runtime behavior is changed, but the type annotations of :attr:`~ApplicationCommandInteraction.target` + are modified to match the expected type with user commands. """ target: Union[User, Member] class MessageCommandInteraction(ApplicationCommandInteraction[ClientT]): - """An :class:`ApplicationCommandInteraction` subclass meant for annotations. + """An :class:`ApplicationCommandInteraction` subclass meant for annotations + in message context menu commands. - No runtime behavior is changed but annotations are modified - to seem like the interaction is specifically a message command. + No runtime behavior is changed, but the type annotations of :attr:`~ApplicationCommandInteraction.target` + are modified to match the expected type with message commands. """ target: Message diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index 09795285d4..92b69a2cac 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -41,11 +41,11 @@ ModalChainNotSupported, NotFound, ) -from ..flags import MessageFlags +from ..flags import InteractionContextTypes, MessageFlags from ..guild import Guild from ..i18n import Localized from ..member import Member -from ..message import Attachment, Message +from ..message import Attachment, AuthorizingIntegrationOwners, Message from ..object import Object from ..permissions import Permissions from ..role import Role @@ -161,6 +161,21 @@ class Interaction(Generic[ClientT]): The entitlements for the invoking user and guild, representing access to an application subscription. + .. versionadded:: 2.10 + + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + Details about the authorizing user/guild for the application installation + related to the interaction. + + .. versionadded:: 2.10 + + context: :class:`InteractionContextTypes` + The context where the interaction was triggered from. + + This is a flag object, with exactly one of the flags set to ``True``. + To check whether an interaction originated from e.g. a :attr:`~InteractionContextTypes.guild` + context, you can use ``if interaction.context.guild:``. + .. versionadded:: 2.10 """ @@ -178,6 +193,8 @@ class Interaction(Generic[ClientT]): "guild_locale", "client", "entitlements", + "authorizing_integration_owners", + "context", "_app_permissions", "_permissions", "_state", @@ -243,6 +260,15 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState) -> None: else [] ) + self.authorizing_integration_owners: AuthorizingIntegrationOwners = ( + AuthorizingIntegrationOwners(data.get("authorizing_integration_owners") or {}) + ) + + # this *should* always exist, but fall back to an empty flag object if it somehow doesn't + self.context: InteractionContextTypes = InteractionContextTypes._from_values( + [context] if (context := data.get("context")) is not None else [] + ) + @property def bot(self) -> ClientT: """:class:`~disnake.ext.commands.Bot`: An alias for :attr:`.client`.""" @@ -262,17 +288,27 @@ def user(self) -> Union[User, Member]: @property def guild(self) -> Optional[Guild]: - """Optional[:class:`Guild`]: The guild the interaction was sent from.""" + """Optional[:class:`Guild`]: The guild the interaction was sent from. + + .. note:: + In some scenarios, e.g. for user-installed applications, this will usually be + ``None``, despite the interaction originating from a guild. + This will only return a full :class:`Guild` for cached guilds, + i.e. those the bot is already a member of. + + To check whether an interaction was sent from a guild, consider using + :attr:`guild_id` or :attr:`context` instead. + """ return self._state._get_guild(self.guild_id) @utils.cached_slot_property("_cs_me") def me(self) -> Union[Member, ClientUser]: - """Union[:class:`.Member`, :class:`.ClientUser`]: - Similar to :attr:`.Guild.me` except it may return the :class:`.ClientUser` in private message contexts. + """Union[:class:`.Member`, :class:`.ClientUser`]: Similar to :attr:`.Guild.me`, + except it may return the :class:`.ClientUser` in private message contexts or + when the bot is not a member of the guild (e.g. in the case of user-installed applications). """ - if self.guild is None: - return None if self.bot is None else self.bot.user # type: ignore - return self.guild.me + # NOTE: guild.me will return None if we start using the partial guild from the interaction + return self.guild.me if self.guild is not None else self.client.user @property def channel_id(self) -> int: @@ -298,15 +334,12 @@ def permissions(self) -> Permissions: def app_permissions(self) -> Permissions: """:class:`Permissions`: The resolved permissions of the bot in the channel, including overwrites. - In a guild context, this is provided directly by Discord. - - In a non-guild context this will be an instance of :meth:`Permissions.private_channel`. - .. versionadded:: 2.6 + + .. versionchanged:: 2.10 + This is now always provided by Discord. """ - if self.guild_id: - return Permissions(self._app_permissions) - return Permissions.private_channel() + return Permissions(self._app_permissions) @utils.cached_slot_property("_cs_response") def response(self) -> InteractionResponse: @@ -620,7 +653,7 @@ async def delete(delay: float) -> None: raise # legacy namings - # these MAY begin a deprecation warning in 2.7 but SHOULD have a deprecation version in 2.8 + # TODO: these should have a deprecation warning before 3.0 original_message = original_response edit_original_message = edit_original_response delete_original_message = delete_original_response @@ -1539,11 +1572,10 @@ class InteractionMessage(Message): Could be a :class:`DMChannel` or :class:`GroupChannel` if it's a private message. reference: Optional[:class:`~disnake.MessageReference`] The message that this message references. This is only applicable to message replies. - interaction: Optional[:class:`~disnake.InteractionReference`] - The interaction that this message references. - This exists only when the message is a response to an interaction without an existing message. + interaction_metadata: Optional[:class:`InteractionMetadata`] + The metadata about the interaction that caused this message, if any. - .. versionadded:: 2.1 + .. versionadded:: 2.10 mention_everyone: :class:`bool` Specifies if the message mentions everyone. diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 3341e29b11..ee1f401f8c 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -92,6 +92,21 @@ class MessageInteraction(Interaction[ClientT]): .. versionadded:: 2.10 + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + Details about the authorizing user/guild for the application installation + related to the interaction. + + .. versionadded:: 2.10 + + context: :class:`InteractionContextTypes` + The context where the interaction was triggered from. + + This is a flag object, with exactly one of the flags set to ``True``. + To check whether an interaction originated from e.g. a :attr:`~InteractionContextTypes.guild` + context, you can use ``if interaction.context.guild:``. + + .. versionadded:: 2.10 + data: :class:`MessageInteractionData` The wrapped interaction data. message: Optional[:class:`Message`] diff --git a/disnake/interactions/modal.py b/disnake/interactions/modal.py index 98c2358bcc..bc9141e09e 100644 --- a/disnake/interactions/modal.py +++ b/disnake/interactions/modal.py @@ -80,6 +80,21 @@ class ModalInteraction(Interaction[ClientT]): .. versionadded:: 2.10 + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + Details about the authorizing user/guild for the application installation + related to the interaction. + + .. versionadded:: 2.10 + + context: :class:`InteractionContextTypes` + The context where the interaction was triggered from. + + This is a flag object, with exactly one of the flags set to ``True``. + To check whether an interaction originated from e.g. a :attr:`~InteractionContextTypes.guild` + context, you can use ``if interaction.context.guild:``. + + .. versionadded:: 2.10 + data: :class:`ModalInteractionData` The wrapped interaction data. message: Optional[:class:`Message`] diff --git a/disnake/message.py b/disnake/message.py index d7442e84db..efe965ae13 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -48,7 +48,7 @@ from .threads import Thread from .ui.action_row import components_to_dict from .user import User -from .utils import MISSING, assert_never, escape_mentions +from .utils import MISSING, _get_as_snowflake, assert_never, deprecated, escape_mentions if TYPE_CHECKING: from typing_extensions import Self @@ -68,7 +68,9 @@ MessageUpdateEvent, ) from .types.interactions import ( + AuthorizingIntegrationOwners as AuthorizingIntegrationOwnersPayload, InteractionMessageReference as InteractionMessageReferencePayload, + InteractionMetadata as InteractionMetadataPayload, ) from .types.member import Member as MemberPayload, UserWithMember as UserWithMemberPayload from .types.message import ( @@ -92,9 +94,11 @@ "Attachment", "Message", "PartialMessage", + "DeletedReferencedMessage", "MessageReference", "InteractionReference", - "DeletedReferencedMessage", + "InteractionMetadata", + "AuthorizingIntegrationOwners", "RoleSubscriptionData", "ForwardedMessage", ) @@ -732,6 +736,9 @@ class InteractionReference: .. versionadded:: 2.1 + .. deprecated:: 2.10 + Use :attr:`Message.interaction_metadata` instead. + Attributes ---------- id: :class:`int` @@ -791,6 +798,114 @@ def author(self) -> Union[User, Member]: return self.user +class InteractionMetadata: + """Represents metadata about the interaction that caused a particular message. + + .. versionadded:: 2.10 + + Attributes + ---------- + id: :class:`int` + The ID of the interaction. + type: :class:`InteractionType` + The type of the interaction. + user: :class:`User` + The user that triggered the interaction. + authorizing_integration_owners: :class:`AuthorizingIntegrationOwners` + Details about the authorizing user/guild for the application installation + related to the interaction. + original_response_message_id: Optional[:class:`int`] + The ID of the original response message. + Only present on :attr:`~Interaction.followup` messages. + + target_user: Optional[:class:`User`] + The ID of the message the command was run on. + Only present on interactions of :attr:`ApplicationCommandType.message` commands. + target_message_id: Optional[:class:`int`] + The user the command was run on. + Only present on interactions of :attr:`ApplicationCommandType.user` commands. + + interacted_message_id: Optional[:class:`int`] + The ID of the message containing the component. + Only present on :attr:`InteractionType.component` interactions. + + triggering_interaction_metadata: Optional[:class:`InteractionMetadata`] + The metadata of the original interaction that triggered the modal. + Only present on :attr:`InteractionType.modal_submit` interactions. + """ + + __slots__ = ( + "id", + "type", + "user", + "authorizing_integration_owners", + "original_response_message_id", + "target_user", + "target_message_id", + "interacted_message_id", + "triggering_interaction_metadata", + ) + + def __init__(self, *, state: ConnectionState, data: InteractionMetadataPayload) -> None: + self.id: int = int(data["id"]) + self.type: InteractionType = try_enum(InteractionType, int(data["type"])) + self.user: User = state.create_user(data["user"]) + self.authorizing_integration_owners: AuthorizingIntegrationOwners = ( + AuthorizingIntegrationOwners(data.get("authorizing_integration_owners") or {}) + ) + + # followup only + self.original_response_message_id: Optional[int] = _get_as_snowflake( + data, "original_response_message_id" + ) + + # application command/type 2 only + self.target_user: Optional[User] = ( + state.create_user(target_user) if (target_user := data.get("target_user")) else None + ) + self.target_message_id: Optional[int] = _get_as_snowflake(data, "target_message_id") + + # component/type 3 only + self.interacted_message_id: Optional[int] = _get_as_snowflake(data, "interacted_message_id") + + # modal_submit/type 5 only + self.triggering_interaction_metadata: Optional[InteractionMetadata] = ( + InteractionMetadata(state=state, data=metadata) + if (metadata := data.get("triggering_interaction_metadata")) + else None + ) + + +class AuthorizingIntegrationOwners: + """Represents details about the authorizing guild/user for the application installation + related to an interaction. + + See the :ddocs:`official docs ` + for more information. + + .. versionadded:: 2.10 + + Attributes + ---------- + guild_id: Optional[:class:`int`] + The ID of the authorizing guild, if the application (and command, if applicable) + was installed to the guild. In DMs with the bot, this will be ``0``. + user_id: Optional[:class:`int`] + The ID of the authorizing user, if the application (and command, if applicable) + was installed to the user. + """ + + __slots__ = ("guild_id", "user_id") + + def __init__(self, data: AuthorizingIntegrationOwnersPayload) -> None: + # keys are stringified ApplicationInstallTypes + self.guild_id: Optional[int] = _get_as_snowflake(data, "0") + self.user_id: Optional[int] = _get_as_snowflake(data, "1") + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} guild_id={self.guild_id!r} user_id={self.user_id!r}>" + + class RoleSubscriptionData: """Represents metadata of the role subscription purchase/renewal in a message of type :attr:`MessageType.role_subscription_purchase`. @@ -890,15 +1005,14 @@ class Message(Hashable): reference: Optional[:class:`~disnake.MessageReference`] The message that this message references. This is only applicable to messages of type :attr:`MessageType.pins_add`, crossposted messages created by a - followed channel integration, or message replies. + followed channel integration, message replies, or application command responses. .. versionadded:: 1.5 - interaction: Optional[:class:`~disnake.InteractionReference`] - The interaction that this message references. - This exists only when the message is a response to an interaction without an existing message. + interaction_metadata: Optional[:class:`InteractionMetadata`] + The metadata about the interaction that caused this message, if any. - .. versionadded:: 2.1 + .. versionadded:: 2.10 mention_everyone: :class:`bool` Specifies if the message mentions everyone. @@ -1012,7 +1126,8 @@ class Message(Hashable): "flags", "reactions", "reference", - "interaction", + "_interaction", + "interaction_metadata", "message_snapshots", "application", "activity", @@ -1085,11 +1200,16 @@ def __init__( except AttributeError: self.guild = state._get_guild(utils._get_as_snowflake(data, "guild_id")) - self.interaction: Optional[InteractionReference] = ( + self._interaction: Optional[InteractionReference] = ( InteractionReference(state=state, guild=self.guild, data=interaction) if (interaction := data.get("interaction")) else None ) + self.interaction_metadata: Optional[InteractionMetadata] = ( + InteractionMetadata(state=state, data=interaction) + if (interaction := data.get("interaction_metadata")) is not None + else None + ) if thread_data := data.get("thread"): if not self.thread and isinstance(self.guild, Guild): @@ -1681,6 +1801,19 @@ def system_content(self) -> Optional[str]: # in the event of an unknown or unsupported message type, we return nothing return None + @property + @deprecated("interaction_metadata") + def interaction(self) -> Optional[InteractionReference]: + """Optional[:class:`~disnake.InteractionReference`]: The interaction that this message references. + This exists only when the message is a response to an interaction without an existing message. + + .. versionadded:: 2.1 + + .. deprecated:: 2.10 + Use :attr:`interaction_metadata` instead. + """ + return self._interaction + async def delete(self, *, delay: Optional[float] = None) -> None: """|coro| diff --git a/disnake/types/appinfo.py b/disnake/types/appinfo.py index 9df725a043..9ec0d926cc 100644 --- a/disnake/types/appinfo.py +++ b/disnake/types/appinfo.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Optional, TypedDict +from typing import Dict, List, Literal, Optional, TypedDict from typing_extensions import NotRequired @@ -10,6 +10,9 @@ from .team import Team from .user import User +# (also called "installation context", which seems more accurate) +ApplicationIntegrationType = Literal[0, 1] # GUILD_INSTALL, USER_INSTALL + class BaseAppInfo(TypedDict): id: Snowflake @@ -29,6 +32,10 @@ class InstallParams(TypedDict): permissions: str +class ApplicationIntegrationTypeConfiguration(TypedDict, total=False): + oauth2_install_params: InstallParams + + class AppInfo(BaseAppInfo): rpc_origins: List[str] bot_public: bool @@ -44,6 +51,8 @@ class AppInfo(BaseAppInfo): role_connections_verification_url: NotRequired[str] approximate_guild_count: NotRequired[int] approximate_user_install_count: NotRequired[int] + # values in this dict generally shouldn't be null, but they can be empty dicts + integration_types_config: NotRequired[Dict[str, ApplicationIntegrationTypeConfiguration]] class PartialAppInfo(BaseAppInfo, total=False): diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 5aca5f3cf3..12833beffd 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -6,6 +6,7 @@ from typing_extensions import NotRequired +from .appinfo import ApplicationIntegrationType from .channel import ChannelType from .components import Component, Modal from .embed import Embed @@ -23,6 +24,8 @@ ApplicationCommandType = Literal[1, 2, 3] +InteractionContextType = Literal[1, 2, 3] # GUILD, BOT_DM, PRIVATE_CHANNEL + class ApplicationCommand(TypedDict): id: Snowflake @@ -35,9 +38,11 @@ class ApplicationCommand(TypedDict): description_localizations: NotRequired[Optional[LocalizationDict]] options: NotRequired[List[ApplicationCommandOption]] default_member_permissions: NotRequired[Optional[str]] - dm_permission: NotRequired[Optional[bool]] + dm_permission: NotRequired[Optional[bool]] # deprecated default_permission: NotRequired[bool] # deprecated nsfw: NotRequired[bool] + integration_types: NotRequired[List[ApplicationIntegrationType]] + contexts: NotRequired[Optional[List[InteractionContextType]]] version: Snowflake @@ -250,12 +255,17 @@ class ModalInteractionData(TypedDict): ## Interactions +# keys are stringified ApplicationInstallType's +AuthorizingIntegrationOwners = Dict[str, Snowflake] + + # base type for *all* interactions class _BaseInteraction(TypedDict): id: Snowflake application_id: Snowflake token: str version: Literal[1] + app_permissions: str # common properties in non-ping interactions @@ -265,10 +275,11 @@ class _BaseUserInteraction(_BaseInteraction): channel_id: Snowflake channel: InteractionChannel locale: str - app_permissions: NotRequired[str] guild_id: NotRequired[Snowflake] guild_locale: NotRequired[str] entitlements: NotRequired[List[Entitlement]] + authorizing_integration_owners: NotRequired[AuthorizingIntegrationOwners] + context: NotRequired[InteractionContextType] # one of these two will always exist, according to docs member: NotRequired[MemberWithUser] user: NotRequired[User] @@ -340,6 +351,37 @@ class InteractionMessageReference(TypedDict): member: NotRequired[Member] +class _BaseInteractionMetadata(TypedDict): + id: Snowflake + type: InteractionType + user: User + authorizing_integration_owners: AuthorizingIntegrationOwners + original_response_message_id: NotRequired[Snowflake] # only on followups + + +class ApplicationCommandInteractionMetadata(_BaseInteractionMetadata): + target_user: NotRequired[User] # only on user command interactions + target_message_id: NotRequired[Snowflake] # only on message command interactions + + +class MessageComponentInteractionMetadata(_BaseInteractionMetadata): + interacted_message_id: Snowflake + + +class ModalInteractionMetadata(_BaseInteractionMetadata): + triggering_interaction_metadata: Union[ + ApplicationCommandInteractionMetadata, + MessageComponentInteractionMetadata, + ] + + +InteractionMetadata = Union[ + ApplicationCommandInteractionMetadata, + MessageComponentInteractionMetadata, + ModalInteractionMetadata, +] + + class EditApplicationCommand(TypedDict): name: str name_localizations: NotRequired[Optional[LocalizationDict]] @@ -347,8 +389,10 @@ class EditApplicationCommand(TypedDict): description_localizations: NotRequired[Optional[LocalizationDict]] options: NotRequired[Optional[List[ApplicationCommandOption]]] default_member_permissions: NotRequired[Optional[str]] - dm_permission: NotRequired[bool] + dm_permission: NotRequired[bool] # deprecated default_permission: NotRequired[bool] # deprecated nsfw: NotRequired[bool] - # TODO: remove, this cannot be changed + integration_types: NotRequired[Optional[List[ApplicationIntegrationType]]] + contexts: NotRequired[Optional[List[InteractionContextType]]] + # n.b. this cannot be changed type: NotRequired[ApplicationCommandType] diff --git a/disnake/types/message.py b/disnake/types/message.py index a6c3f63cb8..c3f8e4d1e9 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -10,7 +10,7 @@ from .components import Component from .embed import Embed from .emoji import PartialEmoji -from .interactions import InteractionDataResolved, InteractionMessageReference +from .interactions import InteractionDataResolved, InteractionMessageReference, InteractionMetadata from .member import Member, UserWithMember from .poll import Poll from .snowflake import Snowflake, SnowflakeList @@ -135,7 +135,8 @@ class Message(TypedDict): message_snapshots: NotRequired[List[MessageSnapshot]] flags: NotRequired[int] referenced_message: NotRequired[Optional[Message]] - interaction: NotRequired[InteractionMessageReference] + interaction: NotRequired[InteractionMessageReference] # deprecated + interaction_metadata: NotRequired[InteractionMetadata] thread: NotRequired[Thread] components: NotRequired[List[Component]] sticker_items: NotRequired[List[StickerItem]] diff --git a/disnake/utils.py b/disnake/utils.py index 07754afafc..1cbd012344 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -120,6 +120,7 @@ def __get__(self, instance, owner): from .invite import Invite from .permissions import Permissions from .template import Template + from .types.appinfo import ApplicationIntegrationType as ApplicationIntegrationTypeLiteral class _RequestLike(Protocol): headers: Mapping[str, Any] @@ -255,7 +256,9 @@ def decorator(overriden: T) -> T: return decorator -def deprecated(instead: Optional[str] = None) -> Callable[[Callable[P, T]], Callable[P, T]]: +def deprecated( + instead: Optional[str] = None, *, skip_internal_frames: bool = False +) -> Callable[[Callable[P, T]], Callable[P, T]]: def actual_decorator(func: Callable[P, T]) -> Callable[P, T]: @functools.wraps(func) def decorated(*args: P.args, **kwargs: P.kwargs) -> T: @@ -264,7 +267,7 @@ def decorated(*args: P.args, **kwargs: P.kwargs) -> T: else: msg = f"{func.__name__} is deprecated." - warn_deprecated(msg, stacklevel=2) + warn_deprecated(msg, stacklevel=2, skip_internal_frames=skip_internal_frames) return func(*args, **kwargs) return decorated @@ -272,7 +275,18 @@ def decorated(*args: P.args, **kwargs: P.kwargs) -> T: return actual_decorator -def warn_deprecated(*args: Any, stacklevel: int = 1, **kwargs: Any) -> None: +_root_module_path = os.path.join(os.path.dirname(__file__), "") # add trailing slash + + +def warn_deprecated( + *args: Any, stacklevel: int = 1, skip_internal_frames: bool = False, **kwargs: Any +) -> None: + # NOTE: skip_file_prefixes was added in 3.12; in older versions, + # we'll just have to live with the warning location possibly being wrong + if sys.version_info >= (3, 12) and skip_internal_frames: + kwargs["skip_file_prefixes"] = (_root_module_path,) + stacklevel = 1 # reset stacklevel, assume we just want the first frame outside library code + old_filters = warnings.filters[:] try: warnings.simplefilter("always", DeprecationWarning) @@ -289,9 +303,9 @@ def oauth_url( redirect_uri: str = MISSING, scopes: Iterable[str] = MISSING, disable_guild_select: bool = False, + integration_type: ApplicationIntegrationTypeLiteral = MISSING, ) -> str: - """A helper function that returns the OAuth2 URL for inviting the bot - into guilds. + """A helper function that returns the OAuth2 URL for authorizing the application. Parameters ---------- @@ -314,6 +328,11 @@ def oauth_url( .. versionadded:: 2.0 + integration_type: :class:`int` + An optional integration type/installation type to install the application with. + + .. versionadded:: 2.10 + Returns ------- :class:`str` @@ -329,6 +348,8 @@ def oauth_url( url += "&response_type=code&" + urlencode({"redirect_uri": redirect_uri}) if disable_guild_select: url += "&disable_guild_select=true" + if integration_type is not MISSING: + url += f"&integration_type={integration_type}" return url diff --git a/docs/api/app_commands.rst b/docs/api/app_commands.rst index a55e3670ab..e49bc47ebf 100644 --- a/docs/api/app_commands.rst +++ b/docs/api/app_commands.rst @@ -108,6 +108,22 @@ OptionChoice .. autoclass:: OptionChoice() :members: +ApplicationInstallTypes +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: ApplicationInstallTypes + +.. autoclass:: ApplicationInstallTypes() + :members: + +InteractionContextTypes +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: InteractionContextTypes + +.. autoclass:: InteractionContextTypes() + :members: + Enumerations ------------ diff --git a/docs/api/app_info.rst b/docs/api/app_info.rst index 7aa6c4148f..3b10b6c553 100644 --- a/docs/api/app_info.rst +++ b/docs/api/app_info.rst @@ -34,6 +34,14 @@ InstallParams .. autoclass:: InstallParams() :members: +InstallTypeConfiguration +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: InstallTypeConfiguration + +.. autoclass:: InstallTypeConfiguration() + :members: + Team ~~~~ diff --git a/docs/api/interactions.rst b/docs/api/interactions.rst index 1ff84165bc..67bf5a16ab 100644 --- a/docs/api/interactions.rst +++ b/docs/api/interactions.rst @@ -172,6 +172,17 @@ ModalInteractionData .. autoclass:: ModalInteractionData() :members: +Data Classes +------------ + +AuthorizingIntegrationOwners +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: AuthorizingIntegrationOwners + +.. autoclass:: AuthorizingIntegrationOwners() + :members: + Enumerations ------------ diff --git a/docs/api/messages.rst b/docs/api/messages.rst index ba6c5bb332..2e616af310 100644 --- a/docs/api/messages.rst +++ b/docs/api/messages.rst @@ -51,7 +51,15 @@ InteractionReference .. attributetable:: InteractionReference -.. autoclass:: InteractionReference +.. autoclass:: InteractionReference() + :members: + +InteractionMetadata +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: InteractionMetadata + +.. autoclass:: InteractionMetadata() :members: RoleSubscriptionData @@ -59,7 +67,7 @@ RoleSubscriptionData .. attributetable:: RoleSubscriptionData -.. autoclass:: RoleSubscriptionData +.. autoclass:: RoleSubscriptionData() :members: RawTypingEvent diff --git a/docs/ext/commands/api/app_commands.rst b/docs/ext/commands/api/app_commands.rst index f4d2d6f290..5db8fbe3a7 100644 --- a/docs/ext/commands/api/app_commands.rst +++ b/docs/ext/commands/api/app_commands.rst @@ -199,6 +199,15 @@ Functions .. autofunction:: option_enum +.. autofunction:: default_member_permissions + :decorator: + +.. autofunction:: install_types + :decorator: + +.. autofunction:: contexts + :decorator: + Events ------ diff --git a/docs/ext/commands/api/checks.rst b/docs/ext/commands/api/checks.rst index 2ced115197..3e8579cbae 100644 --- a/docs/ext/commands/api/checks.rst +++ b/docs/ext/commands/api/checks.rst @@ -119,6 +119,3 @@ Functions .. autofunction:: is_nsfw(,) :decorator: - -.. autofunction:: default_member_permissions(value=0, **permissions) - :decorator: diff --git a/docs/ext/commands/slash_commands.rst b/docs/ext/commands/slash_commands.rst index 603696b637..ad3e681e46 100644 --- a/docs/ext/commands/slash_commands.rst +++ b/docs/ext/commands/slash_commands.rst @@ -21,7 +21,7 @@ This is because each slash command is registered in Discord before people can se but you can still manage it. By default, the registration is global. This means that your slash commands will be visible everywhere, including bot DMs, -though you can disable them in DMs by setting the appropriate :ref:`permissions `. +though you can adjust this by setting specific :ref:`contexts `. You can also change the registration to be local, so your slash commands will only be visible in several guilds. This code sample shows how to set the registration to be local: @@ -671,13 +671,96 @@ Yet again, with a file like ``locale/de.json`` containing localizations like thi "AUTOCOMP_JAPANESE": "Japanisch" } -.. _app_command_permissions: -Permissions ------------ +.. _app_command_contexts: + +Installation/Interaction Contexts +--------------------------------- + +The :attr:`~ApplicationCommand.install_types` and :attr:`~ApplicationCommand.contexts` command +attributes allow you to control how and where your command can be run. + +.. note:: + These fields cannot be configured for a slash subcommand or + subcommand group, only in top-level slash commands and user/message commands. + +Install Types ++++++++++++++ + +The :attr:`~ApplicationCommand.install_types` field determines whether your command can be used +when the bot is installed to a guild, a user, or both. + +Bots installed to a **guild** are visible to *all members*, which used to be the only entry point for users +to run commands. Alternatively, bots can now also support being installed to a **user**, which makes +the commands available everywhere to the *authorizing user* only. + +For instance, to make a command only available in a user-installed context, you can +use the :func:`~.ext.commands.install_types` decorator: + +.. code-block:: python3 + + @bot.slash_command() + @commands.install_types(user=True) + async def command(inter: disnake.ApplicationCommandInteraction): + ... + +Alternatively, you may pass e.g. ``install_types=disnake.ApplicationInstallTypes(user=True)`` +as an argument directly to the command decorator. To allow all (guild + user) installation types, +a :meth:`ApplicationInstallTypes.all` shorthand is also available. + +By default, commands are set to only be usable in guild-installed contexts. + +.. note:: + To enable installing the bot in user contexts (or disallow guild contexts), you will need to + adjust the settings in the **Developer Portal** on the application's **"Installation"** page. + +Contexts +++++++++ + +While ``install_types`` determines where the bot must be *installed* to run a command, +:attr:`~ApplicationCommand.contexts` dictates where *in Discord* a command can be used. + +Possible surfaces are **guilds**, **DMs with the bot**, and **DMs (and group DMs) between other users**, +by setting :attr:`~InteractionContextTypes.guild`, :attr:`~InteractionContextTypes.bot_dm`, +or :attr:`~InteractionContextTypes.private_channel` respectively to ``True``. +The :attr:`~InteractionContextTypes.private_channel` context is only meaningful for user-installed +commands. + +Similarly to ``install_types``, this can be accomplished using the :func:`~.ext.commands.contexts` +decorator, to e.g. disallow a command in guilds: + +.. code-block:: python3 + + @bot.slash_command() + @commands.contexts(bot_dm=True, private_channel=True) + async def command(inter: disnake.ApplicationCommandInteraction): + ... + +In the same way, you can use the ``contexts=`` parameter and :class:`InteractionContextTypes` in the command decorator directly. + +The default context for commands is :attr:`~InteractionContextTypes.guild` + :attr:`~InteractionContextTypes.bot_dm`. + +This attribute supersedes the old ``dm_permission`` field, which can now be considered +equivalent to the :attr:`~InteractionContextTypes.bot_dm` flag. +Therefore, to prevent a command from being run in DMs, use ``InteractionContextTypes(guild=True)``. + +Interaction Data +++++++++++++++++ + +For a given :class:`ApplicationCommandInteraction`, you can determine the context where the interaction +was triggered from using the :attr:`~ApplicationCommandInteraction.context` field. + +To see how the command was installed, use the :attr:`~ApplicationCommandInteraction.authorizing_integration_owners` +field, which includes installation details relevant to that specific interaction. + + +.. _app_command_permissions: Default Member Permissions -++++++++++++++++++++++++++ +-------------------------- + +Using the :attr:`~ApplicationCommand.default_member_permissions` command attribute, +you can restrict by whom a command can be run in a guild by default. These commands will not be enabled/visible for members who do not have all the required guild permissions. In this example both the ``manage_guild`` and the ``moderate_members`` permissions would be required: @@ -701,19 +784,5 @@ This can be overridden by moderators on a per-guild basis; ``default_member_perm ignored entirely once a permission override — application-wide or for this command in particular — is configured in the guild settings, and will be restored if the permissions are re-synced in the settings. -Note that ``default_member_permissions`` and ``dm_permission`` cannot be configured for a slash subcommand or +Like the previous fields, this cannot be configured for a slash subcommand or subcommand group, only in top-level slash commands and user/message commands. - - -DM Permissions -++++++++++++++ - -Using this, you can specify if you want a certain slash command to work in DMs or not: - -.. code-block:: python3 - - @bot.slash_command(dm_permission=False) - async def config(inter: disnake.ApplicationCommandInteraction): - ... - -This will make the ``config`` slash command invisible in DMs, while it will remain visible in guilds. diff --git a/tests/ext/commands/test_base_core.py b/tests/ext/commands/test_base_core.py index bdedeb0fe0..c8335dd70a 100644 --- a/tests/ext/commands/test_base_core.py +++ b/tests/ext/commands/test_base_core.py @@ -17,11 +17,12 @@ def __init__(self, type: str) -> None: self.attr_key = f"{type}_command_attrs" -class TestDefaultPermissions: - @pytest.fixture(params=["slash", "user", "message"]) - def meta(self, request): - return DecoratorMeta(request.param) +@pytest.fixture(params=["slash", "user", "message"]) +def meta(request): + return DecoratorMeta(request.param) + +class TestDefaultPermissions: def test_decorator(self, meta: DecoratorMeta) -> None: class Cog(commands.Cog): @meta.decorator(default_member_permissions=64) @@ -102,6 +103,21 @@ async def overwrite_decorator_below(self, _) -> None: assert Cog().overwrite_decorator_below.default_member_permissions == Permissions(64) +def test_contexts_guildcommandinteraction(meta: DecoratorMeta) -> None: + class Cog(commands.Cog): + # this shouldn't raise, it should be silently ignored + @commands.contexts(bot_dm=True) + @commands.install_types(user=True) + # this is a legacy parameter, essentially the same as using `GuildCommandInteraction` + @meta.decorator(guild_only=True) + async def cmd(self, _) -> None: + ... + + for c in (Cog, Cog()): + assert c.cmd.contexts == disnake.InteractionContextTypes(guild=True) + assert c.cmd.install_types == disnake.ApplicationInstallTypes(guild=True) + + def test_localization_copy() -> None: class Cog(commands.Cog): @commands.slash_command() diff --git a/tests/test_utils.py b/tests/test_utils.py index 46237c2019..75e3944151 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -94,18 +94,46 @@ def stuff(num: int) -> int: mock_warn.assert_called_once_with(msg, stacklevel=3, category=DeprecationWarning) +@mock.patch.object(utils, "_root_module_path", os.path.dirname(__file__)) +@pytest.mark.xfail( + sys.version_info < (3, 12), + raises=AssertionError, + strict=True, + reason="requires 3.12 functionality", +) +def test_deprecated_skip() -> None: + def func(n: int) -> None: + if n == 0: + utils.warn_deprecated("test", skip_internal_frames=True) + else: + func(n - 1) + + with warnings.catch_warnings(record=True) as result: + # show a warning a couple frames deep + func(10) + + # if we successfully skipped all frames in the current module, + # we should end up in the mock decorator's frame + assert len(result) == 1 + assert result[0].filename == mock.__file__ + + @pytest.mark.parametrize( - ("expected", "perms", "guild", "redirect", "scopes", "disable_select"), + ("params", "expected"), [ ( + {}, {"scope": "bot"}, - utils.MISSING, - utils.MISSING, - utils.MISSING, - utils.MISSING, - False, ), ( + { + "permissions": disnake.Permissions(42), + "guild": disnake.Object(9999), + "redirect_uri": "http://endless.horse", + "scopes": ["bot", "applications.commands"], + "disable_guild_select": True, + "integration_type": 1, + }, { "scope": "bot applications.commands", "permissions": "42", @@ -113,24 +141,13 @@ def stuff(num: int) -> int: "response_type": "code", "redirect_uri": "http://endless.horse", "disable_guild_select": "true", + "integration_type": "1", }, - disnake.Permissions(42), - disnake.Object(9999), - "http://endless.horse", - ["bot", "applications.commands"], - True, ), ], ) -def test_oauth_url(expected, perms, guild, redirect, scopes, disable_select) -> None: - url = utils.oauth_url( - 1234, - permissions=perms, - guild=guild, - redirect_uri=redirect, - scopes=scopes, - disable_guild_select=disable_select, - ) +def test_oauth_url(params, expected) -> None: + url = utils.oauth_url(1234, **params) assert dict(yarl.URL(url).query) == {"client_id": "1234", **expected}