Files
Mace-AIO-Discord-Bot---With…/bot/utils/slash_sync.py

185 lines
6.9 KiB
Python

# ╔══════════════════════════════════════════════════════════════════╗
# ║ ║
# ║ +-+-+-+-+-+-+-+-+ ║
# ║ |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