Refactor slash command synchronization logic to prevent duplicate commands in Discord. Update environment variables for command sync modes and enhance command clearing functionality. Improve command registration handling in the bot to streamline user experience.
Some checks failed
CI / Bot (Python) (push) Successful in 12s
CI / Dashboard (Next.js) (push) Failing after 9s

This commit is contained in:
TheOnlyMace
2026-07-21 22:53:51 +02:00
parent 1da2085087
commit a52d84467d
5 changed files with 130 additions and 90 deletions

View File

@@ -12,8 +12,12 @@
# ╚══════════════════════════════════════════════════════════════════╝
"""Register slash (/) commands with Discord.
Global sync alone can take up to ~1 hour to appear in clients.
Guild sync makes them available immediately in every server the bot is in.
IMPORTANT: Never register the same commands both globally AND per-guild —
Discord shows them twice in that server (e.g. /afk, /2048 duplicated).
Modes (SLASH_SYNC_MODE):
global — default. Sync once worldwide; clears leftover guild copies.
guild — instant in connected servers only (no global registration).
"""
from __future__ import annotations
@@ -31,19 +35,38 @@ if TYPE_CHECKING:
logger = logging.getLogger("axiom.slash_sync")
# Discord hard limit for top-level application commands (global or per-guild)
MAX_TOP_LEVEL = 100
def _env_bool(key: str, default: bool) -> bool:
raw = os.getenv(key)
if raw is None:
return default
return raw.strip().lower() in ("1", "true", "yes", "on")
def _parse_guild_ids(raw: str | None) -> list[int]:
if not raw:
return []
out: list[int] = []
for part in raw.split(","):
part = part.strip()
if part.isdigit():
out.append(int(part))
return out
return [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
def _sync_mode() -> str:
"""
Resolve sync mode.
- SLASH_SYNC_MODE=global|guild
- Legacy: SLASH_SYNC_ALL_GUILDS / SLASH_GUILD_IDS imply guild mode if set true/nonempty
while SLASH_SYNC_GLOBAL=false
"""
mode = (os.getenv("SLASH_SYNC_MODE") or "").strip().lower()
if mode in ("global", "guild"):
return mode
# Legacy env compatibility — prefer no duplicates (global) unless guild-only requested
if _env_bool("SLASH_SYNC_ALL_GUILDS", False) or _parse_guild_ids(os.getenv("SLASH_GUILD_IDS")):
if not _env_bool("SLASH_SYNC_GLOBAL", True):
return "guild"
return "global"
def _tree_command_count(bot: "commands.Bot") -> int:
@@ -51,12 +74,10 @@ def _tree_command_count(bot: "commands.Bot") -> int:
def _prune_tree_to_limit(bot: "commands.Bot", limit: int = MAX_TOP_LEVEL) -> list[str]:
"""Drop excess top-level app commands so Discord accepts the sync."""
commands_list = list(bot.tree.get_commands())
if len(commands_list) <= limit:
return []
# Prefer keeping groups (more coverage) over single commands; drop from the end
singles = [c for c in commands_list if not isinstance(c, app_commands.Group)]
groups = [c for c in commands_list if isinstance(c, app_commands.Group)]
ordered = groups + singles
@@ -68,6 +89,15 @@ def _prune_tree_to_limit(bot: "commands.Bot", limit: int = MAX_TOP_LEVEL) -> lis
return removed
async def clear_guild_commands(
bot: "commands.Bot",
guild: discord.abc.Snowflake,
) -> None:
"""Remove guild-scoped slash commands so only global ones remain (fixes duplicates)."""
bot.tree.clear_commands(guild=guild)
await bot.tree.sync(guild=guild)
async def sync_guild(
bot: "commands.Bot",
guild: discord.abc.Snowflake,
@@ -86,99 +116,105 @@ async def sync_slash_commands(
global_sync: bool = True,
) -> dict:
"""
Sync application commands.
Sync application commands without creating global+guild duplicates.
Env:
SLASH_SYNC_GLOBAL=true|false (default true)
SLASH_GUILD_IDS=id,id (optional; if set, only these guilds get instant sync)
SLASH_SYNC_ALL_GUILDS=true (default true — sync every connected guild for instant /)
SLASH_SYNC_MODE=global|guild (default: global)
SLASH_GUILD_IDS=id,id (optional guild list for guild mode)
SLASH_CLEAR_GUILD_COMMANDS=true (default true in global mode — removes old guild copies)
"""
env_global = os.getenv("SLASH_SYNC_GLOBAL", "true").strip().lower() in ("1", "true", "yes", "on")
do_global = global_sync and env_global
sync_all = os.getenv("SLASH_SYNC_ALL_GUILDS", "true").strip().lower() in ("1", "true", "yes", "on")
mode = _sync_mode()
clear_guild = _env_bool("SLASH_CLEAR_GUILD_COMMANDS", True)
env_guild_ids = _parse_guild_ids(os.getenv("SLASH_GUILD_IDS"))
removed = _prune_tree_to_limit(bot)
if removed:
logger.warning(
"Pruned %s top-level slash command(s) to stay under Discord's %s limit: %s",
len(removed),
MAX_TOP_LEVEL,
", ".join(removed[:20]) + ("" if len(removed) > 20 else ""),
)
print(
f"\033[33m◈ Slash tree pruned by {len(removed)} (Discord max {MAX_TOP_LEVEL} top-level)\033[0m"
)
local_count = _tree_command_count(bot)
result: dict = {
"mode": mode,
"local_tree": local_count,
"global": None,
"guilds": {},
"cleared_guilds": [],
"errors": [],
"pruned": removed,
}
async def _try_sync(label: str, factory):
try:
synced = await factory()
return synced, None
return await factory(), None
except Exception as e:
detail = str(e)
logger.error("%s failed: %s", label, detail)
dropped = []
for cmd in list(bot.tree.get_commands()):
if cmd.name in detail or getattr(cmd, "qualified_name", "") in detail:
bot.tree.remove_command(cmd.name)
dropped.append(cmd.name)
if dropped:
print(f"\033[33m◈ Removed invalid slash cmd(s) after error: {', '.join(dropped)}\033[0m")
try:
synced = await factory()
return synced, None
except Exception as e2:
return None, f"{label}: {e2}"
logger.error("%s failed: %s", label, e)
return None, f"{label}: {e}"
if do_global:
synced, err = await _try_sync("Global slash sync", lambda: bot.tree.sync())
if err:
result["errors"].append(err)
print(f"\033[31m◈ {err}\033[0m")
else:
result["global"] = len(synced or [])
print(f"◈ Slash sync (global): {result['global']} command(s)")
target_guilds: list[discord.abc.Snowflake] = []
# Resolve target guilds (for guild mode or for clearing duplicates)
if guilds is not None:
target_guilds = list(guilds)
elif env_guild_ids:
target_guilds = [discord.Object(id=gid) for gid in env_guild_ids]
elif sync_all:
else:
target_guilds = list(bot.guilds)
for guild in target_guilds:
gid = getattr(guild, "id", guild)
if mode == "guild":
# Instant per-server only — do NOT also sync globally (would duplicate)
for guild in target_guilds:
gid = getattr(guild, "id", guild)
def _make_factory(g=guild):
async def _factory():
bot.tree.copy_global_to(guild=g)
return await bot.tree.sync(guild=g)
return _factory
def _make_factory(g=guild):
async def _factory():
bot.tree.copy_global_to(guild=g)
return await bot.tree.sync(guild=g)
synced, err = await _try_sync(f"Guild {gid} slash sync", _make_factory())
if err:
result["errors"].append(err)
print(f"\033[31m◈ {err}\033[0m")
else:
result["guilds"][str(gid)] = len(synced or [])
print(f"◈ Slash sync (guild {gid}): {result['guilds'][str(gid)]} command(s) — instant")
await asyncio.sleep(0.35)
return _factory
synced, err = await _try_sync(f"Guild {gid} slash sync", _make_factory())
if err:
result["errors"].append(err)
print(f"\033[31m◈ {err}\033[0m")
else:
result["guilds"][str(gid)] = len(synced or [])
print(f"◈ Slash sync (guild {gid}): {result['guilds'][str(gid)]} command(s)")
await asyncio.sleep(0.35)
else:
# Global mode — one registry worldwide
if global_sync:
synced, err = await _try_sync("Global slash sync", lambda: bot.tree.sync())
if err:
result["errors"].append(err)
print(f"\033[31m◈ {err}\033[0m")
else:
result["global"] = len(synced or [])
print(f"◈ Slash sync (global): {result['global']} command(s)")
# Remove guild-scoped copies left from older “sync both” runs
if clear_guild and target_guilds:
for guild in target_guilds:
gid = getattr(guild, "id", guild)
def _make_clear(g=guild):
async def _factory():
await clear_guild_commands(bot, g)
return []
return _factory
_, err = await _try_sync(f"Clear guild {gid} slash cmds", _make_clear())
if err:
result["errors"].append(err)
print(f"\033[31m◈ {err}\033[0m")
else:
result["cleared_guilds"].append(str(gid))
print(f"◈ Cleared guild-scoped slash cmds for {gid} (removes duplicates)")
await asyncio.sleep(0.35)
prefix_count = len(list(bot.commands))
print(
f"Synced Total {prefix_count} Client Commands and "
f"{result.get('global') or local_count} Slash Commands"
+ (f" (guild-synced: {len(result['guilds'])})" if result["guilds"] else "")
f"{result.get('global') or local_count} Slash Commands "
f"[mode={mode}]"
)
return result