diff --git a/changelog/265.feature.rst b/changelog/265.feature.rst new file mode 100644 index 0000000000..eca3e2027b --- /dev/null +++ b/changelog/265.feature.rst @@ -0,0 +1 @@ +|commands| Add :class:`~disnake.ext.commands.CommandSyncFlags` to provide sync configuration to :class:`~disnake.ext.commands.Bot` and :class:`~disnake.ext.commands.InteractionBot` (and their autosharded variants) as ``command_sync_flags``. diff --git a/changelog/433.feature.rst b/changelog/433.feature.rst new file mode 100644 index 0000000000..eca3e2027b --- /dev/null +++ b/changelog/433.feature.rst @@ -0,0 +1 @@ +|commands| Add :class:`~disnake.ext.commands.CommandSyncFlags` to provide sync configuration to :class:`~disnake.ext.commands.Bot` and :class:`~disnake.ext.commands.InteractionBot` (and their autosharded variants) as ``command_sync_flags``. diff --git a/changelog/468.feature.rst b/changelog/468.feature.rst new file mode 100644 index 0000000000..eca3e2027b --- /dev/null +++ b/changelog/468.feature.rst @@ -0,0 +1 @@ +|commands| Add :class:`~disnake.ext.commands.CommandSyncFlags` to provide sync configuration to :class:`~disnake.ext.commands.Bot` and :class:`~disnake.ext.commands.InteractionBot` (and their autosharded variants) as ``command_sync_flags``. diff --git a/changelog/806.deprecate.rst b/changelog/806.deprecate.rst new file mode 100644 index 0000000000..32b45acaf6 --- /dev/null +++ b/changelog/806.deprecate.rst @@ -0,0 +1 @@ +|commands| Deprecate the ``sync_commands``, ``sync_commands_debug``, and ``sync_commands_on_cog_unload`` parameters of :class:`~disnake.ext.commands.Bot` and :class:`~disnake.ext.commands.InteractionBot`. These have been replaced with the ``command_sync_flags`` parameter which takes a :class:`~disnake.ext.commands.CommandSyncFlags` instance. diff --git a/changelog/806.feature.rst b/changelog/806.feature.rst new file mode 100644 index 0000000000..eca3e2027b --- /dev/null +++ b/changelog/806.feature.rst @@ -0,0 +1 @@ +|commands| Add :class:`~disnake.ext.commands.CommandSyncFlags` to provide sync configuration to :class:`~disnake.ext.commands.Bot` and :class:`~disnake.ext.commands.InteractionBot` (and their autosharded variants) as ``command_sync_flags``. diff --git a/disnake/ext/commands/__init__.py b/disnake/ext/commands/__init__.py index c97b3a0ebf..6a59dde1c9 100644 --- a/disnake/ext/commands/__init__.py +++ b/disnake/ext/commands/__init__.py @@ -21,6 +21,7 @@ from .custom_warnings import * from .errors import * from .flag_converter import * +from .flags import * from .help import * from .params import * from .slash_core import * diff --git a/disnake/ext/commands/bot.py b/disnake/ext/commands/bot.py index 3b0d82cb25..e9760d5f8d 100644 --- a/disnake/ext/commands/bot.py +++ b/disnake/ext/commands/bot.py @@ -26,6 +26,7 @@ from ._types import MaybeCoro from .bot_base import PrefixType + from .flags import CommandSyncFlags from .help import HelpCommand @@ -61,6 +62,13 @@ class Bot(BotBase, InteractionBotBase, disnake.Client): .. versionadded:: 2.1 + command_sync_flags: :class:`.ext.commands.CommandSyncFlags` + The command sync flags for the session. This is a way of + controlling when and how application commands will be synced with the Discord API. + If not given, defaults to :func:`CommandSyncFlags.default`. + + .. versionadded:: 2.7 + sync_commands: :class:`bool` Whether to enable automatic synchronization of application commands in your code. Defaults to ``True``, which means that commands in API are automatically synced @@ -68,11 +76,17 @@ class Bot(BotBase, InteractionBotBase, disnake.Client): .. versionadded:: 2.1 + .. deprecated:: 2.7 + Replaced with ``command_sync_flags``. + sync_commands_on_cog_unload: :class:`bool` Whether to sync the application commands on cog unload / reload. Defaults to ``True``. .. versionadded:: 2.1 + .. deprecated:: 2.7 + Replaced with ``command_sync_flags``. + sync_commands_debug: :class:`bool` Whether to always show sync debug logs (uses ``INFO`` log level if it's enabled, prints otherwise). If disabled, uses the default ``DEBUG`` log level which isn't shown unless the log level is changed manually. @@ -85,6 +99,9 @@ class Bot(BotBase, InteractionBotBase, disnake.Client): Changes the log level of corresponding messages from ``DEBUG`` to ``INFO`` or ``print``\\s them, instead of controlling whether they are enabled at all. + .. deprecated:: 2.7 + Replaced with ``command_sync_flags``. + localization_provider: :class:`.LocalizationProtocol` An implementation of :class:`.LocalizationProtocol` to use for localization of application commands. @@ -216,10 +233,11 @@ def __init__( owner_ids: Optional[Set[int]] = None, reload: bool = False, case_insensitive: bool = False, - sync_commands: bool = True, - sync_commands_debug: bool = False, - sync_commands_on_cog_unload: bool = True, + command_sync_flags: CommandSyncFlags = ..., test_guilds: Optional[Sequence[int]] = None, + sync_commands: bool = ..., + sync_commands_debug: bool = ..., + sync_commands_on_cog_unload: bool = ..., asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_id: Optional[int] = None, @@ -267,10 +285,11 @@ def __init__( owner_ids: Optional[Set[int]] = None, reload: bool = False, case_insensitive: bool = False, - sync_commands: bool = True, - sync_commands_debug: bool = False, - sync_commands_on_cog_unload: bool = True, + command_sync_flags: CommandSyncFlags = ..., test_guilds: Optional[Sequence[int]] = None, + sync_commands: bool = ..., + sync_commands_debug: bool = ..., + sync_commands_on_cog_unload: bool = ..., asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_ids: Optional[List[int]] = None, # instead of shard_id @@ -317,6 +336,13 @@ class InteractionBot(InteractionBotBase, disnake.Client): .. versionadded:: 2.1 + command_sync_flags: :class:`.ext.commands.CommandSyncFlags` + The command sync flags for the session. This is a way of + controlling when and how application commands will be synced with the Discord API. + If not given, defaults to :func:`CommandSyncFlags.default`. + + .. versionadded:: 2.7 + sync_commands: :class:`bool` Whether to enable automatic synchronization of application commands in your code. Defaults to ``True``, which means that commands in API are automatically synced @@ -324,11 +350,17 @@ class InteractionBot(InteractionBotBase, disnake.Client): .. versionadded:: 2.1 + .. deprecated:: 2.7 + Replaced with ``command_sync_flags``. + sync_commands_on_cog_unload: :class:`bool` Whether to sync the application commands on cog unload / reload. Defaults to ``True``. .. versionadded:: 2.1 + .. deprecated:: 2.7 + Replaced with ``command_sync_flags``. + sync_commands_debug: :class:`bool` Whether to always show sync debug logs (uses ``INFO`` log level if it's enabled, prints otherwise). If disabled, uses the default ``DEBUG`` log level which isn't shown unless the log level is changed manually. @@ -341,6 +373,9 @@ class InteractionBot(InteractionBotBase, disnake.Client): Changes the log level of corresponding messages from ``DEBUG`` to ``INFO`` or ``print``\\s them, instead of controlling whether they are enabled at all. + .. deprecated:: 2.7 + Replaced with ``command_sync_flags``. + localization_provider: :class:`.LocalizationProtocol` An implementation of :class:`.LocalizationProtocol` to use for localization of application commands. @@ -399,10 +434,11 @@ def __init__( owner_id: Optional[int] = None, owner_ids: Optional[Set[int]] = None, reload: bool = False, - sync_commands: bool = True, - sync_commands_debug: bool = False, - sync_commands_on_cog_unload: bool = True, + command_sync_flags: CommandSyncFlags = ..., test_guilds: Optional[Sequence[int]] = None, + sync_commands: bool = ..., + sync_commands_debug: bool = ..., + sync_commands_on_cog_unload: bool = ..., asyncio_debug: bool = False, loop: Optional[asyncio.AbstractEventLoop] = None, shard_id: Optional[int] = None, @@ -443,10 +479,11 @@ def __init__( owner_id: Optional[int] = None, owner_ids: Optional[Set[int]] = None, reload: bool = False, - sync_commands: bool = True, - sync_commands_debug: bool = False, - sync_commands_on_cog_unload: bool = True, + command_sync_flags: CommandSyncFlags = ..., test_guilds: Optional[Sequence[int]] = None, + sync_commands: bool = ..., + sync_commands_debug: bool = ..., + sync_commands_on_cog_unload: bool = ..., 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/cog.py b/disnake/ext/commands/cog.py index 54b33e9c55..8dc2ff626e 100644 --- a/disnake/ext/commands/cog.py +++ b/disnake/ext/commands/cog.py @@ -808,7 +808,7 @@ def _inject(self, bot: AnyBot) -> Self: bot.add_listener(getattr(self, method_name), name) try: - if bot._sync_commands_on_cog_unload: + if bot._command_sync_flags.sync_on_cog_actions: bot._schedule_delayed_command_sync() except NotImplementedError: pass @@ -874,7 +874,7 @@ def _eject(self, bot: AnyBot) -> None: finally: try: - if bot._sync_commands_on_cog_unload: + if bot._command_sync_flags.sync_on_cog_actions: bot._schedule_delayed_command_sync() except NotImplementedError: pass diff --git a/disnake/ext/commands/flags.py b/disnake/ext/commands/flags.py new file mode 100644 index 0000000000..823228e372 --- /dev/null +++ b/disnake/ext/commands/flags.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import TYPE_CHECKING, NoReturn, overload + +from disnake.flags import BaseFlags, alias_flag_value, all_flags_value, flag_value +from disnake.utils import _generated + +if TYPE_CHECKING: + from typing_extensions import Self + +__all__ = ("CommandSyncFlags",) + + +class CommandSyncFlags(BaseFlags): + """Controls the library's application command syncing policy. + + This allows for finer grained control over what commands are synced automatically and in what cases. + + To construct an object you can pass keyword arguments denoting the flags + to enable or disable. + + If command sync is disabled (see the docs of :attr:`sync_commands` for more info), other options will have no effect. + + .. versionadded:: 2.7 + + .. container:: operations + + .. describe:: x == y + + Checks if two CommandSyncFlags instances are equal. + .. describe:: x != y + + Checks if two CommandSyncFlags instances are not equal. + .. describe:: x <= y + + Checks if an CommandSyncFlags instance is a subset of another CommandSyncFlags instance. + .. describe:: x >= y + + Checks if an CommandSyncFlags instance is a superset of another CommandSyncFlags instance. + .. describe:: x < y + + Checks if an CommandSyncFlags instance is a strict subset of another CommandSyncFlags instance. + .. describe:: x > y + + Checks if an CommandSyncFlags instance is a strict superset of another CommandSyncFlags instance. + .. describe:: x | y, x |= y + + Returns a new CommandSyncFlags instance with all enabled flags from both x and y. + (Using ``|=`` will update in place). + .. describe:: x & y, x &= y + + Returns a new CommandSyncFlags instance with only flags enabled on both x and y. + (Using ``&=`` will update in place). + .. describe:: x ^ y, x ^= y + + Returns a new CommandSyncFlags instance with only flags enabled on one of x or y, but not both. + (Using ``^=`` will update in place). + .. describe:: ~x + + Returns a new CommandSyncFlags instance with all flags from x inverted. + .. describe:: hash(x) + + Return 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:: CommandSyncFlags.y | CommandSyncFlags.z, CommandSyncFlags(y=True) | CommandSyncFlags.z + + Returns a CommandSyncFlags instance with all provided flags enabled. + + .. describe:: ~CommandSyncFlags.y + + Returns a CommandSyncFlags instance with all flags except ``y`` inverted from their default value. + + Attributes + ---------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + @overload + @_generated + def __init__( + self, + *, + allow_command_deletion: bool = ..., + sync_commands: bool = ..., + sync_commands_debug: bool = ..., + sync_global_commands: bool = ..., + sync_guild_commands: bool = ..., + sync_on_cog_actions: bool = ..., + ): + ... + + @overload + @_generated + def __init__(self: NoReturn): + ... + + def __init__(self, **kwargs: bool): + self.value = all_flags_value(self.VALID_FLAGS) + for key, value in kwargs.items(): + if key not in self.VALID_FLAGS: + raise TypeError(f"{key!r} is not a valid flag name.") + setattr(self, key, value) + + @classmethod + def all(cls) -> Self: + """A factory method that creates a :class:`CommandSyncFlags` 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:`CommandSyncFlags` 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:`CommandSyncFlags` with the default settings. + + The default is all flags enabled except for :attr:`sync_commands_debug`. + """ + self = cls.all() + self.sync_commands_debug = False + return self + + @property + def _sync_enabled(self): + return self.sync_global_commands or self.sync_guild_commands + + @alias_flag_value + def sync_commands(self): + """:class:`bool`: Whether to sync global and guild app commands. + + This controls the :attr:`sync_global_commands` and :attr:`sync_guild_commands` attributes. + + Note that it is possible for sync to be enabled for guild *or* global commands yet this will return ``False``. + """ + return 1 << 3 | 1 << 4 + + @flag_value + def sync_commands_debug(self): + """:class:`bool`: Whether or not to show app command sync debug messages.""" + return 1 << 0 + + @flag_value + def sync_on_cog_actions(self): + """:class:`bool`: Whether or not to sync app commands on cog load, unload, or reload.""" + return 1 << 1 + + @flag_value + def allow_command_deletion(self): + """:class:`bool`: Whether to allow commands to be deleted by automatic command sync. + + Current implementation of commands sync of renamed commands means that a rename of a command *will* result + in the old one being deleted and a new command being created. + """ + return 1 << 2 + + @flag_value + def sync_global_commands(self): + """:class:`bool`: Whether to sync global commands.""" + return 1 << 3 + + @flag_value + def sync_guild_commands(self): + """:class:`bool`: Whether to sync per-guild commands.""" + return 1 << 4 diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index 2577260cc1..b3d9c3aa25 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -19,6 +19,7 @@ Sequence, Set, Tuple, + TypedDict, TypeVar, Union, ) @@ -27,6 +28,7 @@ from disnake.app_commands import ApplicationCommand, Option from disnake.custom_warnings import SyncWarning from disnake.enums import ApplicationCommandType +from disnake.utils import warn_deprecated from . import errors from .base_core import InvokableApplicationCommand @@ -38,10 +40,11 @@ user_command, ) from .errors import CommandRegistrationError +from .flags import CommandSyncFlags from .slash_core import InvokableSlashCommand, SubCommand, SubCommandGroup, slash_command if TYPE_CHECKING: - from typing_extensions import ParamSpec + from typing_extensions import NotRequired, ParamSpec from disnake.i18n import LocalizedOptional from disnake.interactions import ( @@ -68,14 +71,26 @@ _log = logging.getLogger(__name__) +class _Diff(TypedDict): + no_changes: List[ApplicationCommand] + upsert: List[ApplicationCommand] + edit: List[ApplicationCommand] + delete: List[ApplicationCommand] + delete_ignored: NotRequired[List[ApplicationCommand]] + + +def _get_to_send_from_diff(diff: _Diff): + return diff["no_changes"] + diff["upsert"] + diff["edit"] + diff.get("delete_ignored", []) + + def _app_commands_diff( new_commands: Iterable[ApplicationCommand], old_commands: Iterable[ApplicationCommand], -) -> Dict[str, List[ApplicationCommand]]: +) -> _Diff: new_cmds = {(cmd.name, cmd.type): cmd for cmd in new_commands} old_cmds = {(cmd.name, cmd.type): cmd for cmd in old_commands} - diff = { + diff: _Diff = { "no_changes": [], "upsert": [], "edit": [], @@ -106,12 +121,15 @@ def _app_commands_diff( "edit": "To edit:", "delete": "To delete:", "no_changes": "No changes:", + "delete_ignored": "Ignored due to delete flags:", } -def _format_diff(diff: Dict[str, List[ApplicationCommand]]) -> str: +def _format_diff(diff: _Diff) -> str: lines: List[str] = [] for key, label in _diff_map.items(): + if key not in diff: + continue lines.append(label) if changes := diff[key]: lines.extend(f" <{type(cmd).__name__} name={cmd.name!r}>" for cmd in changes) @@ -125,9 +143,10 @@ class InteractionBotBase(CommonBotBase): def __init__( self, *, - sync_commands: bool = True, - sync_commands_debug: bool = False, - sync_commands_on_cog_unload: bool = True, + command_sync_flags: Optional[CommandSyncFlags] = None, + sync_commands: bool = MISSING, + sync_commands_debug: bool = MISSING, + sync_commands_on_cog_unload: bool = MISSING, test_guilds: Optional[Sequence[int]] = None, **options: Any, ): @@ -138,9 +157,46 @@ def __init__( test_guilds = None if test_guilds is None else tuple(test_guilds) self._test_guilds: Optional[Tuple[int, ...]] = test_guilds - self._sync_commands: bool = sync_commands - self._sync_commands_debug: bool = sync_commands_debug - self._sync_commands_on_cog_unload = sync_commands_on_cog_unload + + if command_sync_flags is not None and ( + sync_commands is not MISSING + or sync_commands_debug is not MISSING + or sync_commands_on_cog_unload is not MISSING + ): + raise TypeError( + "cannot set 'command_sync_flags' and any of 'sync_commands', 'sync_commands_debug', 'sync_commands_on_cog_unload' at the same time." + ) + + if command_sync_flags is not None: + # this makes a copy so it cannot be changed after setting + command_sync_flags = CommandSyncFlags._from_value(command_sync_flags.value) + if command_sync_flags is None: + command_sync_flags = CommandSyncFlags.default() + + if sync_commands is not MISSING: + warn_deprecated( + "sync_commands is deprecated and will be removed in a future version. " + "Use `command_sync_flags` with an `CommandSyncFlags` instance as a replacement.", + stacklevel=3, + ) + command_sync_flags.sync_commands = sync_commands + if sync_commands_debug is not MISSING: + warn_deprecated( + "sync_commands_debug is deprecated and will be removed in a future version. " + "Use `command_sync_flags` with an `CommandSyncFlags` instance as a replacement.", + stacklevel=3, + ) + command_sync_flags.sync_commands_debug = sync_commands_debug + + if sync_commands_on_cog_unload is not MISSING: + warn_deprecated( + "sync_commands_on_cog_unload is deprecated and will be removed in a future version. " + "Use `command_sync_flags` with an `CommandSyncFlags` instance as a replacement.", + stacklevel=3, + ) + command_sync_flags.sync_on_cog_actions = sync_commands_on_cog_unload + + self._command_sync_flags = command_sync_flags self._sync_queued: bool = False self._slash_command_checks = [] @@ -163,6 +219,15 @@ def __init__( self._schedule_app_command_preparation() + @property + def command_sync_flags(self) -> CommandSyncFlags: + """:class:`~.ext.commands.CommandSyncFlags`: The command sync flags configured for this bot. + + .. versionadded:: 2.7 + """ + + return CommandSyncFlags._from_value(self._command_sync_flags.value) + def application_commands_iterator(self) -> Iterable[InvokableApplicationCommand]: return chain( self.all_slash_commands.values(), @@ -698,65 +763,78 @@ async def _sync_application_commands(self) -> None: if not isinstance(self, disnake.Client): raise NotImplementedError("This method is only usable in disnake.Client subclasses") - if not self._sync_commands or self._is_closed or self.loop.is_closed(): + if not self._command_sync_flags._sync_enabled or self._is_closed or self.loop.is_closed(): return # We assume that all commands are already cached. # Sort all invokable commands between guild IDs: global_cmds, guild_cmds = self._ordered_unsynced_commands(self._test_guilds) - if global_cmds is None: - return - # Update global commands first - diff = _app_commands_diff( - global_cmds, self._connection._global_application_commands.values() - ) - update_required = bool(diff["upsert"]) or bool(diff["edit"]) or bool(diff["delete"]) - - # Show the difference - self._log_sync_debug( - "Application command synchronization:\n" - "GLOBAL COMMANDS\n" - "===============\n" - f"| Update is required: {update_required}\n{_format_diff(diff)}" - ) - - if update_required: - # Notice that we don't do any API requests if there're no changes. - try: - to_send = diff["no_changes"] + diff["edit"] + diff["upsert"] - await self.bulk_overwrite_global_commands(to_send) - except Exception as e: - warnings.warn(f"Failed to overwrite global commands due to {e}", SyncWarning) - # Same process but for each specified guild individually. - # Notice that we're not doing this for every single guild for optimisation purposes. - # See the note in :meth:`_cache_application_commands` about guild app commands. - for guild_id, cmds in guild_cmds.items(): - current_guild_cmds = self._connection._guild_application_commands.get(guild_id, {}) - diff = _app_commands_diff(cmds, current_guild_cmds.values()) - update_required = bool(diff["upsert"]) or bool(diff["edit"]) or bool(diff["delete"]) - # Show diff + if self._command_sync_flags.sync_global_commands: + # Update global commands first + diff = _app_commands_diff( + global_cmds, self._connection._global_application_commands.values() + ) + if not self._command_sync_flags.allow_command_deletion: + # because allow_command_deletion is disabled, we want to never automatically delete a command + # so we move the delete commands to delete_ignored + diff["delete_ignored"] = diff["delete"] + diff["delete"] = [] + update_required = bool(diff["upsert"] or diff["edit"] or diff["delete"]) + + # Show the difference self._log_sync_debug( "Application command synchronization:\n" - f"COMMANDS IN {guild_id}\n" - "===============================\n" + "GLOBAL COMMANDS\n" + "===============\n" f"| Update is required: {update_required}\n{_format_diff(diff)}" ) - # Do API requests and cache + if update_required: + # Notice that we don't do any API requests if there're no changes. + to_send = _get_to_send_from_diff(diff) try: - to_send = diff["no_changes"] + diff["edit"] + diff["upsert"] - await self.bulk_overwrite_guild_commands(guild_id, to_send) + await self.bulk_overwrite_global_commands(to_send) except Exception as e: - warnings.warn( - f"Failed to overwrite commands in due to {e}", - SyncWarning, - ) + warnings.warn(f"Failed to overwrite global commands due to {e}", SyncWarning) + + # Same process but for each specified guild individually. + # Notice that we're not doing this for every single guild for optimisation purposes. + # See the note in :meth:`_cache_application_commands` about guild app commands. + if self._command_sync_flags.sync_guild_commands: + for guild_id, cmds in guild_cmds.items(): + current_guild_cmds = self._connection._guild_application_commands.get(guild_id, {}) + diff = _app_commands_diff(cmds, current_guild_cmds.values()) + if not self._command_sync_flags.allow_command_deletion: + # because allow_command_deletion is disabled, we want to never automatically delete a command + # so we move the delete commands to delete_ignored + diff["delete_ignored"] = diff["delete"] + diff["delete"] = [] + update_required = bool(diff["upsert"] or diff["edit"] or diff["delete"]) + + # Show diff + self._log_sync_debug( + "Application command synchronization:\n" + f"COMMANDS IN {guild_id}\n" + "===============================\n" + f"| Update is required: {update_required}\n{_format_diff(diff)}" + ) + + # Do API requests and cache + if update_required: + to_send = _get_to_send_from_diff(diff) + try: + await self.bulk_overwrite_guild_commands(guild_id, to_send) + except Exception as e: + warnings.warn( + f"Failed to overwrite commands in due to {e}", + SyncWarning, + ) # Last debug message self._log_sync_debug("Command synchronization task has finished") def _log_sync_debug(self, text: str) -> None: - if self._sync_commands_debug: + if self._command_sync_flags.sync_commands_debug: # if sync debugging is enabled, *always* output logs if _log.isEnabledFor(logging.INFO): # if the log level is `INFO` or higher, use that @@ -783,7 +861,7 @@ async def _delayed_command_sync(self) -> None: raise NotImplementedError("This method is only usable in disnake.Client subclasses") if ( - not self._sync_commands + not self._command_sync_flags._sync_enabled or self._sync_queued or not self.is_ready() or self._is_closed @@ -1201,31 +1279,49 @@ async def process_application_commands( interaction: :class:`disnake.ApplicationCommandInteraction` The interaction to process commands for. """ - if self._sync_commands and not self._sync_queued: - known_command = self.get_global_command(interaction.data.id) # type: ignore - - if known_command is None: - known_command = self.get_guild_command(interaction.guild_id, interaction.data.id) # type: ignore - if known_command is None: - # This usually comes from the blind spots of the sync algorithm. - # Since not all guild commands are cached, it is possible to experience such issues. - # In this case, the blind spot is the interaction guild, let's fix it: + # This usually comes from the blind spots of the sync algorithm. + # Since not all guild commands are cached, it is possible to experience such issues. + # In this case, the blind spot is the interaction guild, let's fix it: + if ( + # if we're not currently syncing, + not self._sync_queued + # and we're instructed to sync guild commands + and self._command_sync_flags.sync_guild_commands + # and the current command was registered to a guild + and interaction.data.get("guild_id") + # and we don't know the command + and not self.get_guild_command(interaction.guild_id, interaction.data.id) # type: ignore + ): + # don't do anything if we aren't allowed to disable them + if self._command_sync_flags.allow_command_deletion: try: await self.bulk_overwrite_guild_commands(interaction.guild_id, []) # type: ignore except disnake.HTTPException: - pass - try: - # This part is in a separate try-except because we still should respond to the interaction - await interaction.response.send_message( - "This command has just been synced. More information about this: " - "https://docs.disnake.dev/en/latest/ext/commands/additional_info.html" - "#app-command-sync.", - ephemeral=True, - ) - except disnake.HTTPException: - pass - return + # for some reason we were unable to sync the command + # either malformed API request, or some other error + # in theory this will never error: if a command exists the bot has authorisation + # in practice this is not the case, the API could change valid requests at any time + message = "This command could not be processed. Additionally, an error occured when trying to sync commands." + else: + message = "This command has just been synced." + else: + # this block is responsible for responding to guild commands that we don't delete + # this could be changed to not respond but that behavior is undecided + message = "This command could not be processed." + try: + # This part is in a separate try-except because we still should respond to the interaction + message += ( + " More information about this: " + "https://docs.disnake.dev/page/ext/commands/additional_info.html#unknown-commands." + ) + await interaction.response.send_message( + message, + ephemeral=True, + ) + except (disnake.HTTPException, disnake.InteractionTimedOut): + pass + return command_type = interaction.data.type command_name = interaction.data.name diff --git a/docs/ext/commands/additional_info.rst b/docs/ext/commands/additional_info.rst index 37db81dabe..8631e3fc05 100644 --- a/docs/ext/commands/additional_info.rst +++ b/docs/ext/commands/additional_info.rst @@ -15,24 +15,30 @@ App command sync ---------------- If you're using :ref:`discord_ext_commands` for application commands (slash commands, context menus) you should -understand how your commands show up in Discord. If ``sync_commands`` kwarg of :class:`Bot ` (or a similar class) is set to ``True`` (which is the default value) -the library registers / updates all commands automatically. Based on the application commands defined in your code it decides -which commands should be registered, edited or deleted but there're some edge cases you should keep in mind. +understand how your commands show up in Discord. By default, the library registers / updates all commands automatically. +Based on the application commands defined in your code the library automatically determines +which commands should be registered, edited or deleted, but there're some edge cases you should keep in mind. -Changing test guilds -++++++++++++++++++++ +Unknown Commands ++++++++++++++++++ -If you remove some IDs from the ``test_guilds`` kwarg of :class:`Bot ` (or a similar class) or from the ``guild_ids`` kwarg of -:func:`slash_command ` (:func:`user_command `, :func:`message_command `) -the commands in those guilds won't be deleted instantly. Instead, they'll be deleted as soon as one of the deprecated commands is invoked. Your bot will send a message -like "This command has just been synced ...". +Unlike global commands, per-guild application commands are synced in a lazy fashion. This is due to Discord ratelimits, +as checking all guilds for application commands is infeasible past two or three guilds. +This can lead to situations where a command no longer exists in the code but still exists in a server. -Hosting the bot on multiple machines +To rectify this, just run the command. It will automatically be deleted. + +.. _changing-test-guilds: + +This will also occur when IDs are removed from the ``test_guilds`` kwarg of :class:`Bot ` (or a similar class) or from the ``guild_ids`` kwarg of +:func:`slash_command `, :func:`user_command `, or :func:`message_command `. + +Command Sync with Multiple Clusters ++++++++++++++++++++++++++++++++++++ -If your bot requires shard distribution across several machines, you should set ``sync_commands`` kwarg to ``False`` everywhere except 1 machine. +If your bot requires shard distribution across several clusters, you should disable command sync on all clusters except one. This will prevent conflicts and race conditions. Discord API doesn't provide users with events related to application command updates, -so it's impossible to keep the cache of multiple machines synced. Having only 1 machine with ``sync_commands`` set to ``True`` is enough +so it's impossible to keep the cache of multiple machines synced. Having only 1 cluster with ``sync_commands`` set to ``True`` is enough because global registration of application commands doesn't depend on sharding. .. _why_params_and_injections_return_any: diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index 7d40793f13..49da475f08 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -153,6 +153,18 @@ AutoShardedInteractionBot .. autoclass:: disnake.ext.commands.AutoShardedInteractionBot :members: +Command Sync +------------- + +CommandSyncFlags +~~~~~~~~~~~~~~~~~ + +.. attributetable:: disnake.ext.commands.CommandSyncFlags + +.. autoclass:: disnake.ext.commands.CommandSyncFlags() + :members: + + Prefix Helpers ---------------- diff --git a/docs/ext/commands/slash_commands.rst b/docs/ext/commands/slash_commands.rst index 9a60a99486..d1b16e4469 100644 --- a/docs/ext/commands/slash_commands.rst +++ b/docs/ext/commands/slash_commands.rst @@ -40,30 +40,40 @@ This code sample shows how to set the registration to be local: For global registration, don't specify this parameter. -Another useful parameter is ``sync_commands_debug``. If set to ``True``, you receive debug messages related to the -app command registration by default, without having to change the log level of any loggers -(see the documentation on :class:`Bot ` for more info). +In order to configure specific properties about command sync, there's a configuration +class which may be passed to the Bot, :class:`~.ext.commands.CommandSyncFlags`. + +Setting :attr:`CommandSyncFlags.sync_commands_debug <.ext.commands.CommandSyncFlags.sync_commands_debug>` to ``True``, will print debug messages related to the +app command registration to the console (or logger if enabled). + This is useful if you want to figure out some registration details: .. code-block:: python3 from disnake.ext import commands + command_sync_flags = commands.CommandSyncFlags.default() + command_sync_flags.sync_commands_debug = True + bot = commands.Bot( command_prefix='!', test_guilds=[123456789], # Optional - sync_commands_debug=True + command_sync_flags=command_sync_flags, ) -If you want to disable the automatic registration, set ``sync_commands`` to ``False``: +If you want to disable the automatic registration, set :attr:`CommandSyncFlags.sync_commands <.ext.commands.CommandSyncFlags.sync_commands>` +to ``False``, or use :meth:`CommandSyncFlags.none() <.ext.commands.CommandSyncFlags.none>` .. code-block:: python3 from disnake.ext import commands + command_sync_flags = commands.CommandSyncFlags.none() + command_sync_flags.sync_commands = False + bot = commands.Bot( command_prefix='!', - sync_commands=False + command_sync_flags=command_sync_flags, ) Basic Slash Command diff --git a/scripts/codemods/typed_flags.py b/scripts/codemods/typed_flags.py index a1beaebdfe..823ff4a62c 100644 --- a/scripts/codemods/typed_flags.py +++ b/scripts/codemods/typed_flags.py @@ -14,7 +14,10 @@ BASE_FLAG_CLASSES = (flags.BaseFlags, flags.ListBaseFlags) -MODULES = ("disnake.flags",) +MODULES = ( + "disnake.flags", + "disnake.ext.commands.flags", +) class FlagTypings(codemod.VisitorBasedCodemodCommand): diff --git a/test_bot/__main__.py b/test_bot/__main__.py index 5e084fdcb3..687e682d7c 100644 --- a/test_bot/__main__.py +++ b/test_bot/__main__.py @@ -32,7 +32,7 @@ def __init__(self): command_prefix=Config.prefix, intents=disnake.Intents.all(), help_command=None, # type: ignore - sync_commands_debug=Config.sync_commands_debug, + command_sync_flags=commands.CommandSyncFlags.all(), strict_localization=Config.strict_localization, test_guilds=Config.test_guilds, reload=Config.auto_reload,