diff --git a/.env.docker.example b/.env.docker.example index 7222c74..6dcece8 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -19,9 +19,9 @@ LAVALINK_PORT=13592 EMOJI_SYNC=false JISHAKU_ENABLED=false -# Slash (/) command sync — guild sync makes commands appear immediately -SLASH_SYNC_GLOBAL=true -SLASH_SYNC_ALL_GUILDS=true +# Slash (/) command sync — use ONE mode to avoid duplicate commands in Discord +SLASH_SYNC_MODE=global +SLASH_CLEAR_GUILD_COMMANDS=true # SLASH_GUILD_IDS= # Traefik (optional override — default in compose: letsencrypt) diff --git a/bot/.env.example b/bot/.env.example index 6e2622d..438ab65 100644 --- a/bot/.env.example +++ b/bot/.env.example @@ -27,11 +27,12 @@ LAVALINK_PASSWORD="youshallnotpass" LAVALINK_SECURE="false" LAVALINK_PORT="13592" -SLASH_SYNC_GLOBAL=true -# Instant / commands in every connected server (set false if you have many guilds and hit rate limits) -SLASH_SYNC_ALL_GUILDS=true -# Or limit instant sync to specific guild IDs (comma-separated). Overrides ALL_GUILDS when set. +SLASH_SYNC_MODE=global +# global = one set worldwide (default, no duplicates) +# guild = instant only in connected servers (set SLASH_GUILD_IDS or rely on all guilds) # SLASH_GUILD_IDS= +# Clears old per-server copies that caused duplicate /commands in Discord +SLASH_CLEAR_GUILD_COMMANDS=true # ── API / Dashboard Backend ─────────────────────────────────────────────────── API_ENABLED="false" diff --git a/bot/Axiom.py b/bot/Axiom.py index 5144ad1..99fc103 100644 --- a/bot/Axiom.py +++ b/bot/Axiom.py @@ -114,11 +114,13 @@ async def on_ready(): @client.event async def on_guild_join(guild: discord.Guild): - # Instant slash commands in the new server + # Only guild-mode needs per-server registration; global mode must not + # copy commands into the guild or Discord shows duplicates (/afk twice). try: - from utils.slash_sync import sync_guild - synced = await sync_guild(client, guild, copy_global=True) - print(f"◈ Slash sync for new guild {guild.id}: {len(synced)} command(s)") + from utils.slash_sync import _sync_mode, sync_guild + if _sync_mode() == "guild": + synced = await sync_guild(client, guild, copy_global=True) + print(f"◈ Slash sync for new guild {guild.id}: {len(synced)} command(s)") except Exception as e: print(f"Slash sync on guild join failed: {e}") diff --git a/bot/cogs/commands/owner.py b/bot/cogs/commands/owner.py index 4b6e447..5ec5b31 100644 --- a/bot/cogs/commands/owner.py +++ b/bot/cogs/commands/owner.py @@ -303,28 +303,29 @@ class Owner(commands.Cog): @commands.hybrid_command(name="syncslash", help="Re-register all slash (/) commands with Discord.") @commands.is_owner() async def syncslash(self, ctx, scope: str = "all"): - """Force-sync slash commands. scope: all | global | guild""" + """Force-sync slash commands. scope: all | global | clear""" if ctx.interaction: await ctx.defer(ephemeral=True) from utils.slash_sync import sync_slash_commands scope = (scope or "all").lower().strip() if scope == "global": - result = await sync_slash_commands(self.client, guilds=[], global_sync=True) - elif scope == "guild": - if not ctx.guild: - return await ctx.send("Use this in a server, or pass scope `all` / `global`.") - result = await sync_slash_commands( - self.client, guilds=[ctx.guild], global_sync=False - ) + result = await sync_slash_commands(self.client, guilds=list(self.client.guilds), global_sync=True) + elif scope in ("guild", "clear"): + # Clear guild-scoped copies in this server (or all) to remove duplicates + targets = [ctx.guild] if ctx.guild and scope == "clear" else list(self.client.guilds) + if not targets: + return await ctx.send("No guild to clear.") + result = await sync_slash_commands(self.client, guilds=targets, global_sync=(scope != "clear")) else: result = await sync_slash_commands(self.client) - guild_n = len(result.get("guilds") or {}) + cleared = len(result.get("cleared_guilds") or []) err_n = len(result.get("errors") or []) await ctx.send( - f"{TICK} Slash sync done — tree `{result.get('local_tree')}`, " - f"global `{result.get('global')}`, guilds `{guild_n}`, errors `{err_n}`." + f"{TICK} Slash sync done — mode `{result.get('mode')}`, " + f"tree `{result.get('local_tree')}`, global `{result.get('global')}`, " + f"cleared guilds `{cleared}`, errors `{err_n}`." ) diff --git a/bot/utils/slash_sync.py b/bot/utils/slash_sync.py index fa9fc5c..ca19444 100644 --- a/bot/utils/slash_sync.py +++ b/bot/utils/slash_sync.py @@ -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