Skip to content
Open
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
15 changes: 11 additions & 4 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,17 @@ async def mutate(call, *args):
mutation_count += 1
return result

# CRITICAL: Delete obsolete commands FIRST to stay under Discord's 100-command
# limit. If we try to create new commands before deleting old ones, we risk
# temporarily exceeding 100 (error 30032), which silently breaks ALL slash
# commands. This pass identifies and deletes commands that are in Discord
# but no longer in the desired set.
obsolete_keys = set(existing_by_key.keys()) - set(desired_by_key.keys())
for key in obsolete_keys:
current = existing_by_key.pop(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 @@ -1495,10 +1506,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
140 changes: 140 additions & 0 deletions tests/gateway/test_discord_sync_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Test Discord slash command sync respects the 100-command hard limit."""

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import sys

import pytest

from gateway.config import PlatformConfig


def _ensure_discord_mock():
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return
if sys.modules.get("discord") is None:
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
sys.modules["discord"] = discord_mod
sys.modules["discord.ext"] = MagicMock()
sys.modules["discord.ext.commands"] = MagicMock()


_ensure_discord_mock()

from plugins.platforms.discord.adapter import DiscordAdapter


class _FakeTreeCommand:
"""Minimal command stub matching discord.py tree command API."""

def __init__(self, name: str, command_type: int = 1):
self.name = name
self.type = command_type

def to_dict(self, _tree):
return {"name": self.name, "type": self.type}


@pytest.fixture
def adapter():
"""Create a Discord adapter with mocked Discord client."""
_ensure_discord_mock()
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)

# Mock the Discord client and tree
adapter._client = MagicMock()
adapter._client.tree = MagicMock()
adapter._client.http = AsyncMock()
adapter._client.application_id = "test_app_id"

adapter._sleep_between_command_sync_mutations = AsyncMock()
adapter._existing_command_to_payload = MagicMock(side_effect=lambda cmd: {"name": cmd.name})
adapter._canonicalize_app_command_payload = MagicMock(side_effect=lambda p: p)
adapter._patchable_app_command_payload = MagicMock(side_effect=lambda p: p)

return adapter


@pytest.mark.asyncio
async def test_safe_sync_deletes_before_creating():
"""Sync must delete obsolete commands BEFORE creating new ones.

Discord's 100-command limit is enforced when trying to upsert. If we
have 100 commands on Discord, try to add 1 new one, and haven't deleted
any yet, Discord rejects with error 30032.

The fix: identify and delete obsolete commands first, then create/update.
This ensures we never temporarily exceed 100 during the sync operation.

This is a regression guard for the samuraiheart bug where sync would fail
with error 30032 even though the registration code properly capped at 100.
"""
_ensure_discord_mock()
config = PlatformConfig(enabled=True, token="fake-token")
adapter = DiscordAdapter(config)

adapter._client = MagicMock()
adapter._client.tree = MagicMock()
adapter._client.http = AsyncMock()
adapter._client.application_id = "test_app_id"
adapter._sleep_between_command_sync_mutations = AsyncMock()
adapter._existing_command_to_payload = MagicMock(side_effect=lambda cmd: {"name": cmd.name})
adapter._canonicalize_app_command_payload = MagicMock(side_effect=lambda p: p)
adapter._patchable_app_command_payload = MagicMock(side_effect=lambda p: p)

# Simulate having 100 commands on Discord, with 1 that's no longer desired
# and 1 new command that should be created.
# Existing on Discord: cmd_0, cmd_1, ..., cmd_99 (100 total)
# Desired locally: cmd_1, cmd_2, ..., cmd_99, cmd_new (100 total)
# So: delete cmd_0 (1 deletion), create cmd_new (1 creation)

existing_commands = [
SimpleNamespace(id=f"id_{i}", name=f"cmd_{i}", type=1)
for i in range(100)
]
adapter._client.tree.fetch_commands = AsyncMock(return_value=existing_commands)

adapter._client.tree.get_commands = MagicMock(
return_value=[
_FakeTreeCommand(name=f"cmd_{i}", command_type=1)
for i in range(1, 100)
] + [_FakeTreeCommand(name="cmd_new", command_type=1)]
)

# Track the order of mutations
mutation_log = []

async def mock_delete(*args):
mutation_log.append(("delete", args[-1]))

async def mock_upsert(*args):
mutation_log.append(("create", args[-1].get("name")))

adapter._client.http.delete_global_command = mock_delete
adapter._client.http.upsert_global_command = mock_upsert
adapter._client.http.edit_global_command = AsyncMock()

# Call sync
await adapter._safe_sync_slash_commands()

# Verify that:
# 1. A deletion happened (cmd_0)
# 2. It happened BEFORE any creation
# 3. The creation of cmd_new happened AFTER deletion
deletes = [m for m in mutation_log if m[0] == "delete"]
creates = [m for m in mutation_log if m[0] == "create"]

assert len(deletes) >= 1, "At least one command should be deleted"
assert len(creates) >= 1, "At least one command should be created"

# The key assertion: all deletions should come before all creations.
# Find the index of the last delete and the first create.
last_delete_idx = max(i for i, m in enumerate(mutation_log) if m[0] == "delete")
first_create_idx = min(i for i, m in enumerate(mutation_log) if m[0] == "create")

assert last_delete_idx < first_create_idx, (
f"Deletions must happen before creations to avoid exceeding 100-command limit. "
f"Last delete at index {last_delete_idx}, first create at index {first_create_idx}"
)