diff --git a/changelog/1173.feature.rst b/changelog/1173.feature.rst index 02268dbb8b..77c75f5c8b 100644 --- a/changelog/1173.feature.rst +++ b/changelog/1173.feature.rst @@ -11,3 +11,4 @@ Add support for user-installed commands. See :ref:`app_command_contexts` for fur - |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`. +- |commands| Add ``default_install_types`` and ``default_contexts`` parameters to :class:`~ext.commands.Bot`. diff --git a/changelog/1261.feature.rst b/changelog/1261.feature.rst new file mode 100644 index 0000000000..77c75f5c8b --- /dev/null +++ b/changelog/1261.feature.rst @@ -0,0 +1,14 @@ +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`. +- |commands| Add ``default_install_types`` and ``default_contexts`` parameters to :class:`~ext.commands.Bot`. diff --git a/disnake/app_commands.py b/disnake/app_commands.py index cd6eb76ece..38d83a0ca7 100644 --- a/disnake/app_commands.py +++ b/disnake/app_commands.py @@ -547,6 +547,13 @@ def __init__( self.install_types: Optional[ApplicationInstallTypes] = install_types self.contexts: Optional[InteractionContextTypes] = contexts + # TODO(3.0): refactor + # These are for ext.commands defaults. It's quite ugly to do it this way, + # but since __eq__ and to_dict functionality is encapsulated here and can't be moved trivially, + # it'll do until the presumably soon-ish refactor of the entire commands framework. + self._default_install_types: Optional[ApplicationInstallTypes] = None + self._default_contexts: Optional[InteractionContextTypes] = None + self._always_synced: bool = False # reset `default_permission` if set before @@ -614,6 +621,9 @@ def __str__(self) -> str: return self.name def __eq__(self, other) -> bool: + if not isinstance(other, ApplicationCommand): + return False + if not ( self.type == other.type and self.name == other.name @@ -634,8 +644,10 @@ def __eq__(self, other) -> bool: # `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: + self_contexts = self._contexts_with_default + other_contexts = other._contexts_with_default + 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 @@ -648,6 +660,9 @@ def __eq__(self, other) -> bool: 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): + if self._default_install_types is not None: + return self._default_install_types + # 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). @@ -658,6 +673,20 @@ def _install_types_with_default(self) -> Optional[ApplicationInstallTypes]: return self.install_types + @property + def _contexts_with_default(self) -> Optional[InteractionContextTypes]: + # (basically the same logic as `_install_types_with_default`, but without a fallback) + if ( + self.contexts is None + and not isinstance(self, _APIApplicationCommandMixin) + and self._default_contexts is not None + # only use default if legacy `dm_permission` wasn't set + and self._dm_permission is None + ): + return self._default_contexts + + return self.contexts + def to_dict(self) -> EditApplicationCommandPayload: data: EditApplicationCommandPayload = { "type": try_enum_to_int(self.type), @@ -678,7 +707,9 @@ def to_dict(self) -> EditApplicationCommandPayload: ) data["integration_types"] = install_types - contexts = self.contexts.values if self.contexts is not None else None + contexts = ( + self._contexts_with_default.values if self._contexts_with_default is not None else None + ) data["contexts"] = contexts # don't set `dm_permission` if `contexts` is set diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index ff9b8afd76..c513c977db 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -36,6 +36,7 @@ from ._types import AppCheck, Coro, Error, Hook from .cog import Cog + from .interaction_bot_base import InteractionBotBase ApplicationCommandInteractionT = TypeVar( "ApplicationCommandInteractionT", bound=ApplicationCommandInteraction, covariant=True @@ -268,6 +269,10 @@ def _apply_guild_only(self) -> None: self.body.contexts = InteractionContextTypes(guild=True) self.body.install_types = ApplicationInstallTypes(guild=True) + def _apply_defaults(self, bot: InteractionBotBase) -> None: + self.body._default_install_types = bot._default_install_types + self.body._default_contexts = bot._default_contexts + @property def dm_permission(self) -> bool: """:class:`bool`: Whether this command can be used in DMs.""" diff --git a/disnake/ext/commands/bot.py b/disnake/ext/commands/bot.py index 825f96e6ae..1e3e2d864a 100644 --- a/disnake/ext/commands/bot.py +++ b/disnake/ext/commands/bot.py @@ -18,7 +18,12 @@ from disnake.activity import BaseActivity from disnake.client import GatewayParams from disnake.enums import Status - from disnake.flags import Intents, MemberCacheFlags + from disnake.flags import ( + ApplicationInstallTypes, + Intents, + InteractionContextTypes, + MemberCacheFlags, + ) from disnake.i18n import LocalizationProtocol from disnake.mentions import AllowedMentions from disnake.message import Message @@ -117,6 +122,28 @@ class Bot(BotBase, InteractionBotBase, disnake.Client): .. versionadded:: 2.5 + default_install_types: Optional[:class:`.ApplicationInstallTypes`] + The default installation types where application commands will be available. + This applies to all commands added either through the respective decorators + or directly using :meth:`.add_slash_command` (etc.). + + Any value set directly on the command, e.g. using the :func:`.install_types` decorator, + the ``install_types`` parameter, ``slash_command_attrs`` (etc.) at the cog-level, or from + the :class:`.GuildCommandInteraction` annotation, takes precedence over this default. + + .. versionadded:: 2.10 + + default_contexts: Optional[:class:`.InteractionContextTypes`] + The default contexts where application commands will be usable. + This applies to all commands added either through the respective decorators + or directly using :meth:`.add_slash_command` (etc.). + + Any value set directly on the command, e.g. using the :func:`.contexts` decorator, + the ``contexts`` parameter, ``slash_command_attrs`` (etc.) at the cog-level, or from + the :class:`.GuildCommandInteraction` annotation, takes precedence over this default. + + .. versionadded:: 2.10 + Attributes ---------- command_prefix @@ -233,10 +260,12 @@ def __init__( reload: bool = False, case_insensitive: bool = False, command_sync_flags: CommandSyncFlags = ..., - test_guilds: Optional[Sequence[int]] = None, sync_commands: bool = ..., sync_commands_debug: bool = ..., sync_commands_on_cog_unload: bool = ..., + test_guilds: Optional[Sequence[int]] = None, + default_install_types: Optional[ApplicationInstallTypes] = None, + default_contexts: Optional[InteractionContextTypes] = None, asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_id: Optional[int] = None, @@ -285,10 +314,12 @@ def __init__( reload: bool = False, case_insensitive: bool = False, command_sync_flags: CommandSyncFlags = ..., - test_guilds: Optional[Sequence[int]] = None, sync_commands: bool = ..., sync_commands_debug: bool = ..., sync_commands_on_cog_unload: bool = ..., + test_guilds: Optional[Sequence[int]] = None, + default_install_types: Optional[ApplicationInstallTypes] = None, + default_contexts: Optional[InteractionContextTypes] = None, asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_ids: Optional[List[int]] = None, # instead of shard_id @@ -391,6 +422,28 @@ class InteractionBot(InteractionBotBase, disnake.Client): .. versionadded:: 2.5 + default_install_types: Optional[:class:`.ApplicationInstallTypes`] + The default installation types where application commands will be available. + This applies to all commands added either through the respective decorators + or directly using :meth:`.add_slash_command` (etc.). + + Any value set directly on the command, e.g. using the :func:`.install_types` decorator, + the ``install_types`` parameter, ``slash_command_attrs`` (etc.) at the cog-level, or from + the :class:`.GuildCommandInteraction` annotation, takes precedence over this default. + + .. versionadded:: 2.10 + + default_contexts: Optional[:class:`.InteractionContextTypes`] + The default contexts where application commands will be usable. + This applies to all commands added either through the respective decorators + or directly using :meth:`.add_slash_command` (etc.). + + Any value set directly on the command, e.g. using the :func:`.contexts` decorator, + the ``contexts`` parameter, ``slash_command_attrs`` (etc.) at the cog-level, or from + the :class:`.GuildCommandInteraction` annotation, takes precedence over this default. + + .. versionadded:: 2.10 + Attributes ---------- owner_id: Optional[:class:`int`] @@ -434,10 +487,12 @@ def __init__( owner_ids: Optional[Set[int]] = None, reload: bool = False, command_sync_flags: CommandSyncFlags = ..., - test_guilds: Optional[Sequence[int]] = None, sync_commands: bool = ..., sync_commands_debug: bool = ..., sync_commands_on_cog_unload: bool = ..., + test_guilds: Optional[Sequence[int]] = None, + default_install_types: Optional[ApplicationInstallTypes] = None, + default_contexts: Optional[InteractionContextTypes] = None, asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_id: Optional[int] = None, @@ -479,10 +534,12 @@ def __init__( owner_ids: Optional[Set[int]] = None, reload: bool = False, command_sync_flags: CommandSyncFlags = ..., - test_guilds: Optional[Sequence[int]] = None, sync_commands: bool = ..., sync_commands_debug: bool = ..., sync_commands_on_cog_unload: bool = ..., + test_guilds: Optional[Sequence[int]] = None, + default_install_types: Optional[ApplicationInstallTypes] = None, + default_contexts: Optional[InteractionContextTypes] = None, asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_ids: Optional[List[int]] = None, # instead of shard_id diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index 4e9f8698ea..902c2c1880 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -149,6 +149,8 @@ def __init__( sync_commands_debug: bool = MISSING, sync_commands_on_cog_unload: bool = MISSING, test_guilds: Optional[Sequence[int]] = None, + default_install_types: Optional[ApplicationInstallTypes] = None, + default_contexts: Optional[InteractionContextTypes] = None, **options: Any, ) -> None: if test_guilds and not all(isinstance(guild_id, int) for guild_id in test_guilds): @@ -200,6 +202,9 @@ def __init__( self._command_sync_flags = command_sync_flags self._sync_queued: asyncio.Lock = asyncio.Lock() + self._default_install_types = default_install_types + self._default_contexts = default_contexts + self._slash_command_checks = [] self._slash_command_check_once = [] self._user_command_checks = [] @@ -286,6 +291,7 @@ def add_slash_command(self, slash_command: InvokableSlashCommand) -> None: if slash_command.name in self.all_slash_commands: raise CommandRegistrationError(slash_command.name) + slash_command._apply_defaults(self) slash_command.body.localize(self.i18n) self.all_slash_commands[slash_command.name] = slash_command @@ -316,6 +322,7 @@ def add_user_command(self, user_command: InvokableUserCommand) -> None: if user_command.name in self.all_user_commands: raise CommandRegistrationError(user_command.name) + user_command._apply_defaults(self) user_command.body.localize(self.i18n) self.all_user_commands[user_command.name] = user_command @@ -348,6 +355,7 @@ def add_message_command(self, message_command: InvokableMessageCommand) -> None: if message_command.name in self.all_message_commands: raise CommandRegistrationError(message_command.name) + message_command._apply_defaults(self) message_command.body.localize(self.i18n) self.all_message_commands[message_command.name] = message_command diff --git a/docs/ext/commands/slash_commands.rst b/docs/ext/commands/slash_commands.rst index ad3e681e46..0b878aa30d 100644 --- a/docs/ext/commands/slash_commands.rst +++ b/docs/ext/commands/slash_commands.rst @@ -709,6 +709,14 @@ as an argument directly to the command decorator. To allow all (guild + user) in a :meth:`ApplicationInstallTypes.all` shorthand is also available. By default, commands are set to only be usable in guild-installed contexts. +You can set bot-wide defaults using the ``default_install_types`` parameter on +the :class:`~ext.commands.Bot` constructor: + +.. code-block:: python3 + + bot = commands.Bot( + default_install_types=disnake.ApplicationInstallTypes(user=True), + ) .. note:: To enable installing the bot in user contexts (or disallow guild contexts), you will need to @@ -739,6 +747,7 @@ decorator, to e.g. disallow a command in guilds: 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 can also be adjusted using the ``default_contexts`` parameter on the :class:`~ext.commands.Bot` constructor. This attribute supersedes the old ``dm_permission`` field, which can now be considered equivalent to the :attr:`~InteractionContextTypes.bot_dm` flag. diff --git a/tests/ext/commands/test_base_core.py b/tests/ext/commands/test_base_core.py index c8335dd70a..ac6b5ee93b 100644 --- a/tests/ext/commands/test_base_core.py +++ b/tests/ext/commands/test_base_core.py @@ -1,5 +1,7 @@ # SPDX-License-Identifier: MIT +import warnings + import pytest import disnake @@ -118,6 +120,48 @@ async def cmd(self, _) -> None: assert c.cmd.install_types == disnake.ApplicationInstallTypes(guild=True) +class TestDefaultContexts: + @pytest.fixture + def bot(self) -> commands.InteractionBot: + return commands.InteractionBot( + default_contexts=disnake.InteractionContextTypes(bot_dm=True) + ) + + def test_default(self, bot: commands.InteractionBot) -> None: + @bot.slash_command() + async def c(inter) -> None: + ... + + assert c.body.to_dict().get("contexts") == [1] + assert "dm_permission" not in c.body.to_dict() + + def test_decorator_override(self, bot: commands.InteractionBot) -> None: + @commands.contexts(private_channel=True) + @bot.slash_command() + async def c(inter) -> None: + ... + + assert c.body.to_dict().get("contexts") == [2] + + def test_annotation_override(self, bot: commands.InteractionBot) -> None: + @bot.slash_command() + async def c(inter: disnake.GuildCommandInteraction) -> None: + ... + + assert c.body.to_dict().get("contexts") == [0] + + def test_dm_permission(self, bot: commands.InteractionBot) -> None: + with warnings.catch_warnings(record=True): + + @bot.slash_command(dm_permission=False) + async def c(inter) -> None: + ... + + # if dm_permission was set, the `contexts` default shouldn't apply + assert c.body.to_dict().get("contexts") is None + assert c.body.to_dict().get("dm_permission") is False + + def test_localization_copy() -> None: class Cog(commands.Cog): @commands.slash_command()