Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commands): support bot-wide defaults for install_types/contexts #1261

Merged
merged 7 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading