221 lines
7.8 KiB
Python
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
|