Implement slash command synchronization features in environment files and update command definitions to hybrid commands across various cogs. Enhance command registration for improved user experience and streamline command handling in the bot.
This commit is contained in:
184
bot/utils/slash_sync.py
Normal file
184
bot/utils/slash_sync.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||
# ║ |H|e|x|a|H|o|s|t| ║
|
||||
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 HexaHost — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/hexahost ║
|
||||
# ║ github ── https://github.com/theoneandonlymace ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger("axiom.slash_sync")
|
||||
|
||||
# Discord hard limit for top-level application commands (global or per-guild)
|
||||
MAX_TOP_LEVEL = 100
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _tree_command_count(bot: "commands.Bot") -> int:
|
||||
return len(bot.tree.get_commands())
|
||||
|
||||
|
||||
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
|
||||
removed: list[str] = []
|
||||
while len(ordered) > limit:
|
||||
cmd = ordered.pop()
|
||||
bot.tree.remove_command(cmd.name)
|
||||
removed.append(cmd.name)
|
||||
return removed
|
||||
|
||||
|
||||
async def sync_guild(
|
||||
bot: "commands.Bot",
|
||||
guild: discord.abc.Snowflake,
|
||||
*,
|
||||
copy_global: bool = True,
|
||||
) -> list[app_commands.AppCommand]:
|
||||
if copy_global:
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
return await bot.tree.sync(guild=guild)
|
||||
|
||||
|
||||
async def sync_slash_commands(
|
||||
bot: "commands.Bot",
|
||||
*,
|
||||
guilds: Optional[Iterable[discord.abc.Snowflake]] = None,
|
||||
global_sync: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Sync application commands.
|
||||
|
||||
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 /)
|
||||
"""
|
||||
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")
|
||||
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 = {
|
||||
"local_tree": local_count,
|
||||
"global": None,
|
||||
"guilds": {},
|
||||
"errors": [],
|
||||
"pruned": removed,
|
||||
}
|
||||
|
||||
async def _try_sync(label: str, coro):
|
||||
try:
|
||||
synced = await coro
|
||||
return synced, None
|
||||
except Exception as e:
|
||||
# Drop invalid commands once and retry (common after bulk hybrid conversion)
|
||||
detail = str(e)
|
||||
logger.error("%s failed: %s", label, detail)
|
||||
dropped = []
|
||||
for cmd in list(bot.tree.get_commands()):
|
||||
# Heuristic: remove cmds whose name appears in the error payload
|
||||
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 coro
|
||||
return synced, None
|
||||
except Exception as e2:
|
||||
return None, f"{label}: {e2}"
|
||||
return None, f"{label}: {e}"
|
||||
|
||||
if do_global:
|
||||
synced, err = await _try_sync("Global slash sync", 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:
|
||||
target_guilds = list(guilds)
|
||||
elif env_guild_ids:
|
||||
target_guilds = [discord.Object(id=gid) for gid in env_guild_ids]
|
||||
elif sync_all:
|
||||
target_guilds = list(bot.guilds)
|
||||
|
||||
for guild in target_guilds:
|
||||
gid = getattr(guild, "id", guild)
|
||||
|
||||
async def _guild_sync(g=guild):
|
||||
bot.tree.copy_global_to(guild=g)
|
||||
return await bot.tree.sync(guild=g)
|
||||
|
||||
synced, err = await _try_sync(f"Guild {gid} slash sync", _guild_sync())
|
||||
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)
|
||||
|
||||
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 "")
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user