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

221 lines
7.8 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.
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
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")
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 []
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:
return len(bot.tree.get_commands())
def _prune_tree_to_limit(bot: "commands.Bot", limit: int = MAX_TOP_LEVEL) -> list[str]:
commands_list = list(bot.tree.get_commands())
if len(commands_list) <= limit:
return []
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 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,
*,
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 without creating global+guild duplicates.
Env:
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)
"""
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:
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:
return await factory(), None
except Exception as e:
logger.error("%s failed: %s", label, e)
return None, f"{label}: {e}"
# 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]
else:
target_guilds = list(bot.guilds)
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
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"[mode={mode}]"
)
return result