From 118ac6a4006c35ea51c674700c3ce88e82a195ed Mon Sep 17 00:00:00 2001 From: kasnol <8008418+kasnol@users.noreply.github.com> Date: Mon, 18 May 2026 12:45:43 +0800 Subject: [PATCH] fix(discord): delete stale slash commands before creating new ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord enforces the 100 global application-command cap server-side at create time. _safe_sync_slash_commands created net-new commands first and deleted stale ones last, so a large command-set delta (e.g. after an update that renames/adds commands) could transiently push the server count past 100. Discord then rejects with HTTP 400 code 30032, the exception aborts the sync, and because the stale deletions ran last they never execute to free space — leaving the app wedged on every reconnect. Prune stale commands before creating new ones so the peak server-side count never exceeds max(len(existing), len(desired)) <= 100. The trailing delete loop is removed (now redundant). Counting/summary semantics are unchanged. Adds a regression test asserting every stale delete precedes the first net-new create (fails on the old ordering, passes with the fix). --- gateway/platforms/discord.py | 18 ++++-- tests/gateway/test_discord_connect.py | 92 +++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 32a0026973ae..c38a2c0cd769 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1286,6 +1286,20 @@ async def mutate(call, *args): mutation_count += 1 return result + # Delete server-side commands that are no longer desired BEFORE + # creating any net-new ones. Discord enforces the 100 global + # application-command cap server-side at create time; creating new + # commands while stale ones still exist can transiently push the + # server count past 100, which Discord rejects with HTTP 400 code + # 30032. That aborts the sync, and since stale deletions used to run + # last they never execute to free space — leaving the app wedged on + # every reconnect. Pruning first keeps the peak count at + # max(len(existing), len(desired)) <= 100. + for stale_key in [k for k in existing_by_key if k not in desired_by_key]: + current = existing_by_key.pop(stale_key) + await mutate(http.delete_global_command, app_id, current.id) + deleted += 1 + for key, desired in desired_by_key.items(): current = existing_by_key.pop(key, None) if current is None: @@ -1309,10 +1323,6 @@ async def mutate(call, *args): await mutate(http.edit_global_command, app_id, current.id, desired) updated += 1 - for current in existing_by_key.values(): - await mutate(http.delete_global_command, app_id, current.id) - deleted += 1 - return { "total": len(desired_payloads), "unchanged": unchanged, diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 43f88bcf9dad..612abf6664bb 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -457,6 +457,98 @@ def to_dict(self): fake_http.delete_global_command.assert_awaited_once_with(999, 13) +@pytest.mark.asyncio +async def test_safe_sync_deletes_stale_before_creating_new(): + """Stale commands must be deleted before net-new ones are created. + + Discord enforces the 100 global application-command cap server-side at + create time. Creating new commands while stale ones still exist can push + the server count transiently past 100 (HTTP 400 / code 30032), aborting + the sync before the stale deletions run — wedging the app on every + reconnect. The reconcile must prune first so the peak count never exceeds + max(len(existing), len(desired)). + """ + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + + class _DesiredCommand: + def __init__(self, payload): + self._payload = payload + + def to_dict(self, tree): + assert tree is not None + return dict(self._payload) + + class _ExistingCommand: + def __init__(self, command_id, payload): + self.id = command_id + self.name = payload["name"] + self.type = SimpleNamespace(value=payload["type"]) + self._payload = payload + + def to_dict(self): + return { + "id": self.id, + "application_id": 999, + **self._payload, + "name_localizations": {}, + "description_localizations": {}, + } + + def _cmd(name): + return { + "name": name, + "description": f"desc for {name}", + "type": 1, + "options": [], + "nsfw": False, + "dm_permission": True, + "default_member_permissions": None, + } + + desired_new = _cmd("newcmd") + stale_a = _ExistingCommand(101, _cmd("stale-a")) + stale_b = _ExistingCommand(102, _cmd("stale-b")) + + order: list[str] = [] + + async def _record_upsert(app_id, payload): + order.append(f"upsert:{payload['name']}") + + async def _record_delete(app_id, command_id): + order.append(f"delete:{command_id}") + + fake_tree = SimpleNamespace( + get_commands=lambda: [_DesiredCommand(desired_new)], + fetch_commands=AsyncMock(return_value=[stale_a, stale_b]), + ) + fake_http = SimpleNamespace( + upsert_global_command=AsyncMock(side_effect=_record_upsert), + edit_global_command=AsyncMock(), + delete_global_command=AsyncMock(side_effect=_record_delete), + ) + adapter._client = SimpleNamespace( + tree=fake_tree, + http=fake_http, + application_id=999, + user=SimpleNamespace(id=999), + ) + + summary = await adapter._safe_sync_slash_commands() + + assert summary == { + "total": 1, + "unchanged": 0, + "updated": 0, + "recreated": 0, + "created": 1, + "deleted": 2, + } + # Every stale delete must precede the net-new create. + last_delete = max(i for i, op in enumerate(order) if op.startswith("delete:")) + first_upsert = min(i for i, op in enumerate(order) if op.startswith("upsert:")) + assert last_delete < first_upsert, order + + @pytest.mark.asyncio async def test_safe_sync_slash_commands_recreates_metadata_only_diffs(): adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))