diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed8917cbd..842c666572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#3105](https://github.com/Pycord-Development/pycord/pull/3105)) - Fixed the update of a user's `avatar_decoration` to now cause an `on_user_update` event to fire. ([#3103](https://github.com/Pycord-Development/pycord/pull/3103)) +- Fixed backend logic for `sync_commands` to only sync when needed. + ([#2990](https://github.com/Pycord-Development/pycord/pull/2990)) ### Deprecated diff --git a/discord/bot.py b/discord/bot.py index 5ba5f9b5df..ab891142ce 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -42,9 +42,12 @@ Generator, Literal, Mapping, + TypeAlias, TypeVar, ) +from typing_extensions import override + from .client import Client from .cog import CogMixin from .commands import ( @@ -81,6 +84,175 @@ _log = logging.getLogger(__name__) +class DefaultComparison: + """ + Comparison rule for when there are multiple default values that should be considered equivalent when comparing 2 objects. + Allows for a custom check to be passed for further control over equality. + + Attributes + ---------- + defaults: :class:`tuple` + The values that should be considered equivalent to each other + callback: Callable[[Any, Any], bool] + A callable that will do additional comparison on the objects if neither are a default value. + Defaults to a `==` comparison. + It should accept the 2 objects as arguments and return True if they should be considered equivalent + and False otherwise. + """ + + def __init__( + self, + defaults: tuple[Any, ...], + callback: Callable[[Any, Any], bool] = lambda x, y: x == y, + ): + self.defaults = defaults + self.callback = callback + + def _check_defaults(self, local: Any, remote: Any) -> bool | None: + defaults = (local in self.defaults) + (remote in self.defaults) + if defaults == 2: + # Both are COMMAND_DEFAULTS, so they can be counted as the same + return True + elif defaults == 0: + # Neither are COMMAND_DEFAULTS so the callback has to be used + return None + else: + # Only one is a default, so the command must be out of sync + return False + + def check(self, local: Any, remote: Any) -> bool: + """ + Compares the local and remote objects. + + Returns + ------- + bool + True if local and remote are deemed to be equivalent. False otherwise. + """ + if (rtn := self._check_defaults(local, remote)) is not None: + return rtn + else: + return self.callback(local, remote) + + +class DefaultSetComparison(DefaultComparison): + @override + def check(self, local: Any, remote: Any) -> bool: + try: + local = set(local) + except TypeError: + pass + try: + remote = set(remote) + except TypeError: + pass + return super().check(local, remote) + + +NestedComparison: TypeAlias = dict[str, "NestedComparison | DefaultComparison"] + + +def _compare_defaults( + obj: Mapping[str, Any] | Any, + match: Mapping[str, Any] | Any, + schema: NestedComparison, +) -> bool: + if not isinstance(match, Mapping) or not isinstance(obj, Mapping): + return obj == match + for field, comparison in schema.items(): + remote = match.get(field, MISSING) + local = obj.get(field, MISSING) + if isinstance(comparison, dict): + if not _compare_defaults(local, remote, comparison): + return False + elif isinstance(comparison, DefaultComparison): + if not comparison.check(local, remote): + return False + return True + + +OPTION_DEFAULT_VALUES = ([], MISSING) + + +def _option_comparison_check(local: Any, remote: Any) -> bool: + matching = (local in OPTION_DEFAULT_VALUES) + (remote in OPTION_DEFAULT_VALUES) + if matching == 2: + return True + elif matching == 1: + return False + else: + return len(local) == len(remote) and all( + [ + _compare_defaults(local[x], remote[x], COMMAND_OPTION_DEFAULTS) + for x in range(len(local)) + ] + ) + + +CHOICES_DEFAULT_VALUES = ([], MISSING) + + +def _choices_comparison_check(local: Any, remote: Any) -> bool: + matching = (local in CHOICES_DEFAULT_VALUES) + (remote in CHOICES_DEFAULT_VALUES) + if matching == 2: + return True + elif matching == 1: + return False + else: + return len(local) == len(remote) and all( + [ + _compare_defaults(local[x], remote[x], OPTIONS_CHOICES_DEFAULTS) + for x in range(len(local)) + ] + ) + + +COMMAND_DEFAULTS: NestedComparison = { + "type": DefaultComparison((1, MISSING)), + "name": DefaultComparison(()), + "description": DefaultComparison((MISSING,)), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "options": DefaultComparison(OPTION_DEFAULT_VALUES, _option_comparison_check), + "default_member_permissions": DefaultComparison((None, MISSING)), + "nsfw": DefaultComparison((False, MISSING)), + # TODO: Change the below default if needed to use the correct default integration types + # Discord States That This Defaults To "your app's configured contexts" + "integration_types": DefaultSetComparison( + (MISSING, {0, 1}), lambda x, y: set(x) == set(y) + ), + "contexts": DefaultSetComparison((None, MISSING), lambda x, y: set(x) == set(y)), +} +SUBCOMMAND_DEFAULTS: NestedComparison = { + "type": DefaultComparison(()), + "name": DefaultComparison(()), + "description": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "options": DefaultComparison(OPTION_DEFAULT_VALUES, _option_comparison_check), +} +COMMAND_OPTION_DEFAULTS: NestedComparison = { + "type": DefaultComparison(()), + "name": DefaultComparison(()), + "description": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "description_localizations": DefaultComparison((None, {}, MISSING)), + "required": DefaultComparison((False, MISSING)), + "choices": DefaultComparison(CHOICES_DEFAULT_VALUES, _choices_comparison_check), + "channel_types": DefaultComparison(([], MISSING)), + "min_value": DefaultComparison((MISSING,)), + "max_value": DefaultComparison((MISSING,)), + "min_length": DefaultComparison((MISSING,)), + "max_length": DefaultComparison((MISSING,)), + "autocomplete": DefaultComparison((MISSING, False)), +} +OPTIONS_CHOICES_DEFAULTS: NestedComparison = { + "name": DefaultComparison(()), + "name_localizations": DefaultComparison((None, {}, MISSING)), + "value": DefaultComparison(()), +} + + class ApplicationCommandMixin(ABC): """A mixin that implements common functionality for classes that need application command compatibility. @@ -239,6 +411,16 @@ def get_application_command( return return command + async def _get_command_defaults(self): + app_info = await self._bot.application_info() + integration_contexts = app_info.integration_types_config._to_payload().keys() + + command_defaults = COMMAND_DEFAULTS.copy() + command_defaults["integration_types"] = DefaultSetComparison( + (MISSING, integration_contexts), lambda x, y: set(x) == set(y) + ) + return command_defaults + async def get_desynced_commands( self, guild_id: int | None = None, @@ -270,82 +452,27 @@ async def get_desynced_commands( the action, including ``id``. """ - # We can suggest the user to upsert, edit, delete, or bulk upsert the commands + updated_command_defaults = await self._get_command_defaults() + # We can suggest the user to upsert, edit, delete, or bulk upsert the commands def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: + """Returns True If Commands Are Equivalent""" if isinstance(cmd, SlashCommandGroup): if len(cmd.subcommands) != len(match.get("options", [])): - return True + return False for i, subcommand in enumerate(cmd.subcommands): - match_ = next( - ( - data - for data in match["options"] - if data["name"] == subcommand.name - ), - MISSING, + match_ = find( + lambda x: x["name"] == subcommand.name, match["options"] ) - if match_ is not MISSING and _check_command(subcommand, match_): - return True + if match_ is not None: + return _check_command(subcommand, match_) else: - as_dict = cmd.to_dict() - to_check = { - "nsfw": None, - "default_member_permissions": None, - "name": None, - "description": None, - "name_localizations": None, - "description_localizations": None, - "options": [ - "type", - "name", - "description", - "autocomplete", - "choices", - "name_localizations", - "description_localizations", - ], - "contexts": None, - "integration_types": None, - } - for check, value in to_check.items(): - if type(to_check[check]) == list: - # We need to do some falsy conversion here - # The API considers False (autocomplete) and [] (choices) to be falsy values - falsy_vals = (False, []) - for opt in value: - cmd_vals = ( - [val.get(opt, MISSING) for val in as_dict[check]] - if check in as_dict - else [] - ) - for i, val in enumerate(cmd_vals): - if val in falsy_vals: - cmd_vals[i] = MISSING - if match.get( - check, MISSING - ) is not MISSING and cmd_vals != [ - val.get(opt, MISSING) for val in match[check] - ]: - # We have a difference - return True - elif (attr := getattr(cmd, check, None)) != ( - found := match.get(check) - ): - # We might have a difference - if "localizations" in check and bool(attr) == bool(found): - # unlike other attrs, localizations are MISSING by default - continue - elif ( - check == "default_permission" - and attr is True - and found is None - ): - # This is a special case - # TODO: Remove for perms v2 - continue - return True - return False + if cmd.parent is None: + return _compare_defaults( + cmd.to_dict(), match, updated_command_defaults + ) + else: + return _compare_defaults(cmd.to_dict(), match, SUBCOMMAND_DEFAULTS) return_value = [] cmds = self.pending_application_commands.copy() @@ -376,10 +503,11 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: # First let's check if the commands we have locally are the same as the ones on discord for cmd in pending: match = registered_commands_dict.get(cmd.name) + # We don't have this command registered if match is None: - # We don't have this command registered return_value.append({"command": cmd, "action": "upsert"}) - elif _check_command(cmd, match): + # We have a different version of the command then Discord + elif not _check_command(cmd, match): return_value.append( { "command": cmd, @@ -387,8 +515,8 @@ def _check_command(cmd: ApplicationCommand, match: Mapping[str, Any]) -> bool: "id": int(registered_commands_dict[cmd.name]["id"]), } ) + # We have this command registered and it's the same else: - # We have this command registered but it's the same return_value.append( {"command": cmd, "action": None, "id": int(match["id"])} ) diff --git a/tests/test_command_syncing.py b/tests/test_command_syncing.py new file mode 100644 index 0000000000..caec296aef --- /dev/null +++ b/tests/test_command_syncing.py @@ -0,0 +1,1093 @@ +import copy +from typing import Any, override + +import pytest + +import discord +from discord import MISSING, Bot, SlashCommandGroup +from discord.bot import COMMAND_DEFAULTS, DefaultSetComparison +from discord.types.interactions import ApplicationCommand, ApplicationCommandOption + +pytestmark = pytest.mark.asyncio + + +class SlashCommand(discord.SlashCommand): + def __init__(self, **kwargs): + if (r := kwargs.pop("func", None)) is not None: + callback = r + else: + + async def dummy_callback(ctx): + pass + + callback = dummy_callback + if (desc := kwargs.get("description")) is not None: + kwargs.pop("description") + else: + desc = "desc" + if (name := kwargs.get("name")) is not None: + kwargs.pop("name") + else: + name = "testing" + super().__init__(callback, name=name, description=desc, **kwargs) + if self.integration_types is None: + self.integration_types = {discord.IntegrationType.guild_install} + if self.contexts is None: + self.contexts = { + discord.InteractionContextType.private_channel, + discord.InteractionContextType.bot_dm, + discord.InteractionContextType.guild, + } + + +remote_dummy_base: dict = { + "id": "1", + "application_id": "1", + "version": "1", + "default_member_permissions": None, + "type": 1, + "name": "testing", + "name_localizations": None, + "description": "desc", + "description_localizations": None, + "dm_permission": True, + "contexts": [0, 1, 2], + "integration_types": [0], + "nsfw": False, +} + + +class DummyBot(Bot): + @override + async def _get_command_defaults(self): + command_defaults = COMMAND_DEFAULTS.copy() + command_defaults["integration_types"] = DefaultSetComparison( + (MISSING, {0}), lambda x, y: set(x) == set(y) + ) + return command_defaults + + +async def edit_needed( + local: SlashCommand | SlashCommandGroup, remote: ApplicationCommand +): + b = DummyBot() + b.add_application_command(local) + r = await b.get_desynced_commands(prefetched=[remote]) + return r[0]["action"] == "edit" + + +class TestCommandSyncing: + @staticmethod + def dict_factory(**kwargs) -> ApplicationCommand: + remote_dummy = copy.deepcopy(remote_dummy_base) + for key, value in kwargs.items(): + if value == MISSING: + del remote_dummy[key] + else: + remote_dummy.update({key: value}) + return remote_dummy + + async def test_default(self): + assert not await edit_needed(SlashCommand(), TestCommandSyncing.dict_factory()) + + async def test_default_member_permissions_defaults(self): + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(default_member_permissions=None), + ) + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(default_member_permissions=MISSING), + ) + + async def test_default_member_permissions(self): + assert await edit_needed( + SlashCommand(default_member_permissions=discord.Permissions(8)), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(default_member_permissions="8"), + ) + + async def test_type_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(type=1) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(type=MISSING) + ) + + async def test_name_localizations_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(name_localizations={}) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(name_localizations=None) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(name_localizations=MISSING) + ) + + async def test_name_localizations(self): + assert await edit_needed( + SlashCommand(name_localizations={"no": "testing_no"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(name_localizations={"no": "testing_no", "ja": "testing_ja"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(name_localizations={"ja": "testing_ja", "no": "testing_no"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(name_localizations={"no": "testing_no"}), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + name_localizations={"no": "testing_no", "ja": "testing_ja"} + ), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + name_localizations={"ja": "testing_ja", "no": "testing_no"} + ), + ) + + async def test_description_localizations_defaults(self): + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(description_localizations={}), + ) + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(description_localizations=None), + ) + assert not await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory(description_localizations=MISSING), + ) + + async def test_description_localizations(self): + assert await edit_needed( + SlashCommand(description_localizations={"no": "testing_desc_es"}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand( + description_localizations={ + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + ), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand( + description_localizations={ + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + ), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + description_localizations={"no": "testing_desc_es"} + ), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + description_localizations={ + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + ), + ) + assert await edit_needed( + SlashCommand(), + TestCommandSyncing.dict_factory( + description_localizations={ + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + ), + ) + + async def test_description(self): + assert await edit_needed( + SlashCommand(description="different"), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(description="different") + ) + + async def test_contexts_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(contexts=[2, 1, 0]) + ) + + async def test_contexts(self): + assert await edit_needed( + SlashCommand(contexts=set()), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(contexts={discord.InteractionContextType.guild}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(contexts=[]) + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(contexts=[1]) + ) + + async def test_integration_types_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(integration_types=MISSING) + ) + + async def test_integration_types(self): + assert await edit_needed( + SlashCommand(integration_types=set()), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(integration_types={discord.IntegrationType.user_install}), + TestCommandSyncing.dict_factory(), + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(integration_types=[]) + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(integration_types=[1]) + ) + + async def test_nsfw_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(nsfw=False) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(nsfw=MISSING) + ) + + async def test_nsfw(self): + assert await edit_needed( + SlashCommand(nsfw=True), TestCommandSyncing.dict_factory() + ) + assert await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(nsfw=True) + ) + + async def test_options_defaults(self): + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(options=[]) + ) + assert not await edit_needed( + SlashCommand(), TestCommandSyncing.dict_factory(options=MISSING) + ) + + +class TestCommandSyncingWithOption: + @staticmethod + def dict_factory(**kwargs) -> dict[str, Any]: + remote_dummy = copy.deepcopy(remote_dummy_base) + remote_dummy["options"] = [ + { + "type": 3, + "name": "user", + "name_localizations": None, + "description": "name", + "description_localizations": None, + "required": True, + } + ] + for key, value in kwargs.items(): + if value == MISSING: + del remote_dummy["options"][0][key] + else: + remote_dummy["options"][0].update({key: value}) + return remote_dummy + + class SlashOptionCommand(SlashCommand): + @staticmethod + def default_option() -> discord.Option: + return discord.Option(str, name="user", description="name") + + def __init__( + self, options: list[discord.Option | dict] | None = None, **kwargs + ): + async def dummy_callback(ctx, user): + pass + + if options is None: + options = [self.default_option()] + for n, i in enumerate(options): + if isinstance(i, dict): + d = self.default_option() + for key, value in i.items(): + d.__setattr__(key, value) + options[n] = d + super().__init__(func=dummy_callback, options=options, **kwargs) + + async def test_type(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"input_type": discord.SlashCommandOptionType(5)}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(type=5), + ) + + async def test_name(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"name": "pycord"}]), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name="pycord"), + ) + + async def test_description(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"description": "pycord_desc"}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(description="pycord_desc"), + ) + + async def test_name_localizations_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name_localizations={}), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name_localizations=None), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(name_localizations=MISSING), + ) + + async def test_name_localizations(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"name_localizations": {"no": "testing_no"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"name_localizations": {"no": "testing_no", "ja": "testing_ja"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"name_localizations": {"ja": "testing_ja", "no": "testing_no"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + name_localizations={"no": "testing_no"} + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + name_localizations={"no": "testing_no", "ja": "testing_ja"} + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + name_localizations={"ja": "testing_ja", "no": "testing_no"} + ), + ) + + async def test_description_localizations_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(description_localizations={}), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(description_localizations=None), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations=MISSING + ), + ) + + async def test_description_localizations(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"description_localizations": {"no": "testing_desc_es"}}] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "description_localizations": { + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + } + ] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "description_localizations": { + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + } + ] + ), + TestCommandSyncingWithOption.dict_factory(), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations={"no": "testing_desc_es"} + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations={ + "no": "testing_desc_es", + "ja": "testing_desc_jp", + } + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory( + description_localizations={ + "ja": "testing_desc_jp", + "no": "testing_desc_es", + } + ), + ) + + async def test_required_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"required": False}]), + TestCommandSyncingWithOption.dict_factory(required=MISSING), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(required=True), + ) + + async def test_required(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(required=MISSING), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(required=False), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"required": False}]), + TestCommandSyncingWithOption.dict_factory(required=True), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"required": None}]), + TestCommandSyncingWithOption.dict_factory(required=MISSING), + ) + + async def test_choices_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(choices=MISSING), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(choices=[]), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + + async def test_choices(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"choices": [discord.OptionChoice("a"), discord.OptionChoice("b")]}] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[{"name": "a", "value": "a"}, {"name": "b", "value": "b"}] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("x"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "x", "value": "x"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"choices": [discord.OptionChoice("a"), discord.OptionChoice("c")]}] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[ + {"name": "a", "value": "a"}, + {"name": "b", "value": "b"}, + {"name": "c", "value": "c"}, + ] + ), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "choices": [ + discord.OptionChoice("a"), + discord.OptionChoice("b"), + discord.OptionChoice("c"), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory( + choices=[{"name": "a", "value": "a"}, {"name": "c", "value": "c"}] + ), + ) + + async def test_channel_type_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(channel_types=[]), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(channel_types=MISSING), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + + async def test_channel_types(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=MISSING), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(5)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 5]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [ + { + "channel_types": [ + discord.ChannelType(0), + discord.ChannelType(1), + discord.ChannelType(15), + ] + } + ] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1]), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"channel_types": [discord.ChannelType(0), discord.ChannelType(1)]}] + ), + TestCommandSyncingWithOption.dict_factory(channel_types=[0, 1, 15]), + ) + + async def test_min_value_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_value=MISSING), + ) + + async def test_min_value(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5}]), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 6}]), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5}]), + TestCommandSyncingWithOption.dict_factory(min_value=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Floats + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.5}]), + TestCommandSyncingWithOption.dict_factory(min_value=5.5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 6.0}]), + TestCommandSyncingWithOption.dict_factory(min_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.77}]), + TestCommandSyncingWithOption.dict_factory(min_value=6.123), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_value=5.333), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.333}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Mixed + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(min_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 6}]), + TestCommandSyncingWithOption.dict_factory(min_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(min_value=6), + ) + + async def test_max_value_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_value=MISSING), + ) + + async def test_max_value(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5}]), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 6}]), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5}]), + TestCommandSyncingWithOption.dict_factory(max_value=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Floats + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.5}]), + TestCommandSyncingWithOption.dict_factory(max_value=5.5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 6.0}]), + TestCommandSyncingWithOption.dict_factory(max_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.77}]), + TestCommandSyncingWithOption.dict_factory(max_value=6.123), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_value=5.333), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.333}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + # Mixed + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(max_value=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 6}]), + TestCommandSyncingWithOption.dict_factory(max_value=5.0), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_value": 5.0}]), + TestCommandSyncingWithOption.dict_factory(max_value=6), + ) + + async def test_min_length_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_length=MISSING), + ) + + async def test_min_length(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 5}]), + TestCommandSyncingWithOption.dict_factory(min_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 6}]), + TestCommandSyncingWithOption.dict_factory(min_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 5}]), + TestCommandSyncingWithOption.dict_factory(min_length=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(min_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"min_length": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + async def test_max_length_defaults(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_length=MISSING), + ) + + async def test_max_length(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 5}]), + TestCommandSyncingWithOption.dict_factory(max_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 6}]), + TestCommandSyncingWithOption.dict_factory(max_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 5}]), + TestCommandSyncingWithOption.dict_factory(max_length=6), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(max_length=5), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand([{"max_length": 5}]), + TestCommandSyncingWithOption.dict_factory(), + ) + + async def test_autocomplete_default(self): + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(autocomplete=False), + ) + assert not await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(autocomplete=MISSING), + ) + + async def test_autocomplete(self): + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand(), + TestCommandSyncingWithOption.dict_factory(autocomplete=True), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"autocomplete": lambda x: x}] + ), + TestCommandSyncingWithOption.dict_factory(autocomplete=False), + ) + assert await edit_needed( + TestCommandSyncingWithOption.SlashOptionCommand( + [{"autocomplete": lambda x: x}] + ), + TestCommandSyncingWithOption.dict_factory(autocomplete=MISSING), + ) + + +class TestSubCommandSyncing: + + @staticmethod + def dict_factory(top: dict, command: dict) -> dict[str, Any]: + remote_dummy = TestCommandSyncing.dict_factory(**top) + if "name" not in top: + remote_dummy["name"] = "subcommand" + remote_dummy["options"] = [ + ApplicationCommandOption( + type=1, + name="testing", + name_localizations=None, + description="desc", + description_localizations=None, + required=True, + ) + ] + for key, value in command.items(): + if value == MISSING: + del remote_dummy["options"][0][key] + else: + remote_dummy["options"][0].update({key: value}) + return remote_dummy + + async def test_parent_name(self): + async def edit_needed( + local: SlashCommand | SlashCommandGroup, remote: ApplicationCommand + ): + b = DummyBot() + b.add_application_command(local) + r = await b.get_desynced_commands(prefetched=[remote]) + return r[0]["action"] == "upsert" and r[1]["action"] == "delete" + + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert not await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("newgroupname") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({"name": "new_discord_name"}, {})) + + async def test_parent_attrs(self): + s = SlashCommandGroup("subcommand", description="newdescname") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed( + s, self.dict_factory({"description": "new_discord_desc"}, {}) + ) + + async def test_child_name(self): + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(parent=s)) + assert not await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(name="newsubcommand_name")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {"name": "new_discord_name"})) + + async def test_child(self): + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(description="newdescript")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand()) + assert await edit_needed( + s, self.dict_factory({}, {"description": "new_descript"}) + ) + + async def test_subcommand_counts(self): + s = SlashCommandGroup("subcommand") + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + s.add_command(SlashCommand(parent=s)) + rd = self.dict_factory({}, {}) + rd["options"] = [] + assert await edit_needed(s, rd) + + +class TestSubSubCommandSyncing: + + @staticmethod + def dict_factory(top: dict, command: dict) -> dict[str, Any]: + remote_dummy = TestCommandSyncing.dict_factory(**top) + if "name" not in top: + remote_dummy["name"] = "subcommand" + remote_dummy["options"] = [ + ApplicationCommandOption( + type=2, + name="testing", + name_localizations=None, + description="desc", + description_localizations=None, + required=True, + options=[ + ApplicationCommandOption( + type=1, + name="testing", + name_localizations=None, + description="desc", + description_localizations=None, + required=True, + ) + ], + ) + ] + for key, value in command.items(): + if value == MISSING: + del remote_dummy["options"][0]["options"][0][key] + else: + remote_dummy["options"][0]["options"][0].update({key: value}) + return remote_dummy + + async def test_child_name(self): + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(parent=ss)) + assert not await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(name="newsubcommand_name")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand()) + assert await edit_needed(s, self.dict_factory({}, {"name": "new_discord_name"})) + + async def test_child(self): + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(description="newdescript")) + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand()) + assert await edit_needed( + s, self.dict_factory({}, {"description": "new_descript"}) + ) + + async def test_subsubcommand_counts(self): + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + assert await edit_needed(s, self.dict_factory({}, {})) + s = SlashCommandGroup("subcommand") + ss = s.create_subgroup("testing", description="desc") + ss.add_command(SlashCommand(parent=ss)) + rd = self.dict_factory({}, {}) + rd["options"][0]["options"] = [] + assert await edit_needed(s, rd)