Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
804922b
fix: Initial Commit To Fix Command Auto Syncing
Icebluewolf Nov 5, 2025
ae4e171
fix: Apply Suggestions From Code Review
Icebluewolf Feb 17, 2026
a08ebef
refactor: Apply Suggestions From Code Review
Icebluewolf Feb 22, 2026
a5b3225
fix: Subcommand Checking
Icebluewolf Feb 22, 2026
d02ec72
refactor: Use Pipe Union
Icebluewolf Feb 22, 2026
24dd6f7
feat: Support IntegrationTypes Defaults Via AppInfo
Icebluewolf Mar 2, 2026
5723354
chore: Changelog
Icebluewolf Mar 2, 2026
2bc087d
feat: Implement Tests For Command Comparison
Icebluewolf Mar 3, 2026
93ff9c1
chore: Apply Minor Changes From Code Review
Icebluewolf Mar 3, 2026
2bf4228
fix: Use Dummy Data In Place Of Fetching AppInfo
Icebluewolf Mar 4, 2026
3fd30ed
fix: Only Checking First Sub Command
Icebluewolf Mar 19, 2026
689d86f
feat(tests): Checks For Multiple Sub Commands
Icebluewolf Mar 19, 2026
62a5900
Update tests/test_command_syncing.py
Paillat-dev Mar 18, 2026
748a960
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 18, 2026
085d02f
fix: Wow that's messed up
Paillat-dev Jul 27, 2026
345ba31
chore: Update Changelog Version
Icebluewolf Jul 27, 2026
455a995
feat: Add Default Integration Types When None Are Given
Icebluewolf Jul 28, 2026
11b3400
refactor: Use Enum For InteractionTypes
Icebluewolf Jul 28, 2026
af20adb
docs: Add To Changelog
Icebluewolf Jul 28, 2026
e93766b
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 28, 2026
e532b5d
Merge branch 'master' into fix-auto-sync
Lulalaby Jul 30, 2026
894d512
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 30, 2026
7637ea8
Merge branch 'master' into fix-auto-sync
Lulalaby Jul 30, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,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

Expand Down
276 changes: 204 additions & 72 deletions discord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -87,6 +90,175 @@
_log = logging.getLogger(__name__)


class DefaultComparison:
Comment thread
Icebluewolf marked this conversation as resolved.
"""
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))
]
)
Comment thread
Paillat-dev marked this conversation as resolved.


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"
Comment thread
Icebluewolf marked this conversation as resolved.
Outdated
"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.
Expand Down Expand Up @@ -245,6 +417,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,
Expand Down Expand Up @@ -276,82 +458,31 @@ 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 None:
return False
elif not _check_command(subcommand, match_):
return False
else:
return True
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()
Expand Down Expand Up @@ -382,19 +513,20 @@ 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,
"action": "edit",
"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"])}
)
Expand Down
Loading
Loading