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.
This commit is contained in:
@@ -19,9 +19,9 @@ LAVALINK_PORT=13592
|
|||||||
EMOJI_SYNC=false
|
EMOJI_SYNC=false
|
||||||
JISHAKU_ENABLED=false
|
JISHAKU_ENABLED=false
|
||||||
|
|
||||||
# Slash (/) command sync — guild sync makes commands appear immediately
|
# Slash (/) command sync — use ONE mode to avoid duplicate commands in Discord
|
||||||
SLASH_SYNC_GLOBAL=true
|
SLASH_SYNC_MODE=global
|
||||||
SLASH_SYNC_ALL_GUILDS=true
|
SLASH_CLEAR_GUILD_COMMANDS=true
|
||||||
# SLASH_GUILD_IDS=
|
# SLASH_GUILD_IDS=
|
||||||
|
|
||||||
# Traefik (optional override — default in compose: letsencrypt)
|
# Traefik (optional override — default in compose: letsencrypt)
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ LAVALINK_PASSWORD="youshallnotpass"
|
|||||||
LAVALINK_SECURE="false"
|
LAVALINK_SECURE="false"
|
||||||
LAVALINK_PORT="13592"
|
LAVALINK_PORT="13592"
|
||||||
|
|
||||||
SLASH_SYNC_GLOBAL=true
|
SLASH_SYNC_MODE=global
|
||||||
# Instant / commands in every connected server (set false if you have many guilds and hit rate limits)
|
# global = one set worldwide (default, no duplicates)
|
||||||
SLASH_SYNC_ALL_GUILDS=true
|
# guild = instant only in connected servers (set SLASH_GUILD_IDS or rely on all guilds)
|
||||||
# Or limit instant sync to specific guild IDs (comma-separated). Overrides ALL_GUILDS when set.
|
|
||||||
# SLASH_GUILD_IDS=
|
# SLASH_GUILD_IDS=
|
||||||
|
# Clears old per-server copies that caused duplicate /commands in Discord
|
||||||
|
SLASH_CLEAR_GUILD_COMMANDS=true
|
||||||
|
|
||||||
# ── API / Dashboard Backend ───────────────────────────────────────────────────
|
# ── API / Dashboard Backend ───────────────────────────────────────────────────
|
||||||
API_ENABLED="false"
|
API_ENABLED="false"
|
||||||
|
|||||||
@@ -114,9 +114,11 @@ async def on_ready():
|
|||||||
|
|
||||||
@client.event
|
@client.event
|
||||||
async def on_guild_join(guild: discord.Guild):
|
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:
|
try:
|
||||||
from utils.slash_sync import sync_guild
|
from utils.slash_sync import _sync_mode, sync_guild
|
||||||
|
if _sync_mode() == "guild":
|
||||||
synced = await sync_guild(client, guild, copy_global=True)
|
synced = await sync_guild(client, guild, copy_global=True)
|
||||||
print(f"◈ Slash sync for new guild {guild.id}: {len(synced)} command(s)")
|
print(f"◈ Slash sync for new guild {guild.id}: {len(synced)} command(s)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -303,28 +303,29 @@ class Owner(commands.Cog):
|
|||||||
@commands.hybrid_command(name="syncslash", help="Re-register all slash (/) commands with Discord.")
|
@commands.hybrid_command(name="syncslash", help="Re-register all slash (/) commands with Discord.")
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def syncslash(self, ctx, scope: str = "all"):
|
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:
|
if ctx.interaction:
|
||||||
await ctx.defer(ephemeral=True)
|
await ctx.defer(ephemeral=True)
|
||||||
from utils.slash_sync import sync_slash_commands
|
from utils.slash_sync import sync_slash_commands
|
||||||
|
|
||||||
scope = (scope or "all").lower().strip()
|
scope = (scope or "all").lower().strip()
|
||||||
if scope == "global":
|
if scope == "global":
|
||||||
result = await sync_slash_commands(self.client, guilds=[], global_sync=True)
|
result = await sync_slash_commands(self.client, guilds=list(self.client.guilds), global_sync=True)
|
||||||
elif scope == "guild":
|
elif scope in ("guild", "clear"):
|
||||||
if not ctx.guild:
|
# Clear guild-scoped copies in this server (or all) to remove duplicates
|
||||||
return await ctx.send("Use this in a server, or pass scope `all` / `global`.")
|
targets = [ctx.guild] if ctx.guild and scope == "clear" else list(self.client.guilds)
|
||||||
result = await sync_slash_commands(
|
if not targets:
|
||||||
self.client, guilds=[ctx.guild], global_sync=False
|
return await ctx.send("No guild to clear.")
|
||||||
)
|
result = await sync_slash_commands(self.client, guilds=targets, global_sync=(scope != "clear"))
|
||||||
else:
|
else:
|
||||||
result = await sync_slash_commands(self.client)
|
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 [])
|
err_n = len(result.get("errors") or [])
|
||||||
await ctx.send(
|
await ctx.send(
|
||||||
f"{TICK} Slash sync done — tree `{result.get('local_tree')}`, "
|
f"{TICK} Slash sync done — mode `{result.get('mode')}`, "
|
||||||
f"global `{result.get('global')}`, guilds `{guild_n}`, errors `{err_n}`."
|
f"tree `{result.get('local_tree')}`, global `{result.get('global')}`, "
|
||||||
|
f"cleared guilds `{cleared}`, errors `{err_n}`."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,12 @@
|
|||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
"""Register slash (/) commands with Discord.
|
"""Register slash (/) commands with Discord.
|
||||||
|
|
||||||
Global sync alone can take up to ~1 hour to appear in clients.
|
IMPORTANT: Never register the same commands both globally AND per-guild —
|
||||||
Guild sync makes them available immediately in every server the bot is in.
|
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
|
from __future__ import annotations
|
||||||
@@ -31,19 +35,38 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger("axiom.slash_sync")
|
logger = logging.getLogger("axiom.slash_sync")
|
||||||
|
|
||||||
# Discord hard limit for top-level application commands (global or per-guild)
|
|
||||||
MAX_TOP_LEVEL = 100
|
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]:
|
def _parse_guild_ids(raw: str | None) -> list[int]:
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
out: list[int] = []
|
return [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
|
||||||
for part in raw.split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if part.isdigit():
|
def _sync_mode() -> str:
|
||||||
out.append(int(part))
|
"""
|
||||||
return out
|
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:
|
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]:
|
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())
|
commands_list = list(bot.tree.get_commands())
|
||||||
if len(commands_list) <= limit:
|
if len(commands_list) <= limit:
|
||||||
return []
|
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)]
|
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)]
|
groups = [c for c in commands_list if isinstance(c, app_commands.Group)]
|
||||||
ordered = groups + singles
|
ordered = groups + singles
|
||||||
@@ -68,6 +89,15 @@ def _prune_tree_to_limit(bot: "commands.Bot", limit: int = MAX_TOP_LEVEL) -> lis
|
|||||||
return removed
|
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(
|
async def sync_guild(
|
||||||
bot: "commands.Bot",
|
bot: "commands.Bot",
|
||||||
guild: discord.abc.Snowflake,
|
guild: discord.abc.Snowflake,
|
||||||
@@ -86,77 +116,51 @@ async def sync_slash_commands(
|
|||||||
global_sync: bool = True,
|
global_sync: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Sync application commands.
|
Sync application commands without creating global+guild duplicates.
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
SLASH_SYNC_GLOBAL=true|false (default true)
|
SLASH_SYNC_MODE=global|guild (default: global)
|
||||||
SLASH_GUILD_IDS=id,id (optional; if set, only these guilds get instant sync)
|
SLASH_GUILD_IDS=id,id (optional guild list for guild mode)
|
||||||
SLASH_SYNC_ALL_GUILDS=true (default true — sync every connected guild for instant /)
|
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")
|
mode = _sync_mode()
|
||||||
do_global = global_sync and env_global
|
clear_guild = _env_bool("SLASH_CLEAR_GUILD_COMMANDS", True)
|
||||||
sync_all = os.getenv("SLASH_SYNC_ALL_GUILDS", "true").strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
env_guild_ids = _parse_guild_ids(os.getenv("SLASH_GUILD_IDS"))
|
env_guild_ids = _parse_guild_ids(os.getenv("SLASH_GUILD_IDS"))
|
||||||
|
|
||||||
removed = _prune_tree_to_limit(bot)
|
removed = _prune_tree_to_limit(bot)
|
||||||
if removed:
|
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(
|
print(
|
||||||
f"\033[33m◈ Slash tree pruned by {len(removed)} (Discord max {MAX_TOP_LEVEL} top-level)\033[0m"
|
f"\033[33m◈ Slash tree pruned by {len(removed)} (Discord max {MAX_TOP_LEVEL} top-level)\033[0m"
|
||||||
)
|
)
|
||||||
|
|
||||||
local_count = _tree_command_count(bot)
|
local_count = _tree_command_count(bot)
|
||||||
result: dict = {
|
result: dict = {
|
||||||
|
"mode": mode,
|
||||||
"local_tree": local_count,
|
"local_tree": local_count,
|
||||||
"global": None,
|
"global": None,
|
||||||
"guilds": {},
|
"guilds": {},
|
||||||
|
"cleared_guilds": [],
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"pruned": removed,
|
"pruned": removed,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _try_sync(label: str, factory):
|
async def _try_sync(label: str, factory):
|
||||||
try:
|
try:
|
||||||
synced = await factory()
|
return await factory(), None
|
||||||
return synced, None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
detail = str(e)
|
logger.error("%s failed: %s", label, 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}"
|
|
||||||
return None, f"{label}: {e}"
|
return None, f"{label}: {e}"
|
||||||
|
|
||||||
if do_global:
|
# Resolve target guilds (for guild mode or for clearing duplicates)
|
||||||
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] = []
|
|
||||||
if guilds is not None:
|
if guilds is not None:
|
||||||
target_guilds = list(guilds)
|
target_guilds = list(guilds)
|
||||||
elif env_guild_ids:
|
elif env_guild_ids:
|
||||||
target_guilds = [discord.Object(id=gid) for gid in env_guild_ids]
|
target_guilds = [discord.Object(id=gid) for gid in env_guild_ids]
|
||||||
elif sync_all:
|
else:
|
||||||
target_guilds = list(bot.guilds)
|
target_guilds = list(bot.guilds)
|
||||||
|
|
||||||
|
if mode == "guild":
|
||||||
|
# Instant per-server only — do NOT also sync globally (would duplicate)
|
||||||
for guild in target_guilds:
|
for guild in target_guilds:
|
||||||
gid = getattr(guild, "id", guild)
|
gid = getattr(guild, "id", guild)
|
||||||
|
|
||||||
@@ -164,6 +168,7 @@ async def sync_slash_commands(
|
|||||||
async def _factory():
|
async def _factory():
|
||||||
bot.tree.copy_global_to(guild=g)
|
bot.tree.copy_global_to(guild=g)
|
||||||
return await bot.tree.sync(guild=g)
|
return await bot.tree.sync(guild=g)
|
||||||
|
|
||||||
return _factory
|
return _factory
|
||||||
|
|
||||||
synced, err = await _try_sync(f"Guild {gid} slash sync", _make_factory())
|
synced, err = await _try_sync(f"Guild {gid} slash sync", _make_factory())
|
||||||
@@ -172,13 +177,44 @@ async def sync_slash_commands(
|
|||||||
print(f"\033[31m◈ {err}\033[0m")
|
print(f"\033[31m◈ {err}\033[0m")
|
||||||
else:
|
else:
|
||||||
result["guilds"][str(gid)] = len(synced or [])
|
result["guilds"][str(gid)] = len(synced or [])
|
||||||
print(f"◈ Slash sync (guild {gid}): {result['guilds'][str(gid)]} command(s) — instant")
|
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)
|
await asyncio.sleep(0.35)
|
||||||
|
|
||||||
prefix_count = len(list(bot.commands))
|
prefix_count = len(list(bot.commands))
|
||||||
print(
|
print(
|
||||||
f"Synced Total {prefix_count} Client Commands and "
|
f"Synced Total {prefix_count} Client Commands and "
|
||||||
f"{result.get('global') or local_count} Slash Commands "
|
f"{result.get('global') or local_count} Slash Commands "
|
||||||
+ (f" (guild-synced: {len(result['guilds'])})" if result["guilds"] else "")
|
f"[mode={mode}]"
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user