185 lines
6.9 KiB
Python
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
|