Skip to content

Commit

Permalink
feat(commands): support bot-wide defaults for install_types/`contex…
Browse files Browse the repository at this point in the history
…ts` (#1261)
  • Loading branch information
shiftinv authored Dec 29, 2024
1 parent 14d5b78 commit 42ef060
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 8 deletions.
1 change: 1 addition & 0 deletions changelog/1173.feature.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
14 changes: 14 additions & 0 deletions changelog/1261.feature.rst
Original file line number Diff line number Diff line change
@@ -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`.
37 changes: 34 additions & 3 deletions disnake/app_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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).
Expand All @@ -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),
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions disnake/ext/commands/base_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
67 changes: 62 additions & 5 deletions disnake/ext/commands/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions disnake/ext/commands/interaction_bot_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/ext/commands/slash_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions tests/ext/commands/test_base_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# SPDX-License-Identifier: MIT

import warnings

import pytest

import disnake
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 42ef060

Please sign in to comment.