Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions tests/gateway/test_discord_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down