- Bugfix for commands not fully populating docs site - Deployed version uses global commands. The docs site should now pick these up as well
255 lines
8.3 KiB
Python
255 lines
8.3 KiB
Python
import os, signal, asyncio, pathlib
|
||
import discord
|
||
from discord.ext import commands
|
||
from dotenv import load_dotenv
|
||
|
||
from data_manager import DataManager
|
||
from modules.common.settings import cfg as cfg_helper
|
||
from modules.common.boot_notice import post_boot_notice
|
||
|
||
# Version consists of:
|
||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
||
VERSION = "0.4.0.0.a2"
|
||
|
||
# ---------- Env loading ----------
|
||
|
||
load_dotenv()
|
||
|
||
def _get_env(name: str, default: str = "") -> str:
|
||
v = os.getenv(name, "")
|
||
return (v or "").strip().strip('"').strip("'") or default
|
||
|
||
TOKEN = _get_env("DISCORD_TOKEN")
|
||
DATA_FILE = _get_env("SHAI_DATA") or _get_env("SHAI_DATA_FILE") or "/data/data.json"
|
||
|
||
print("[Config] DISCORD_TOKEN set:", bool(TOKEN))
|
||
print("[Config] DATA_FILE:", DATA_FILE)
|
||
|
||
# ---------- Discord intents ----------
|
||
|
||
intents = discord.Intents.default()
|
||
intents.guilds = True
|
||
intents.members = True
|
||
intents.message_content = True
|
||
intents.reactions = True
|
||
intents.emojis_and_stickers = True
|
||
intents.voice_states = True
|
||
|
||
# ---------- Bot + DataManager ----------
|
||
|
||
if not TOKEN:
|
||
print("[Config] WARNING: DISCORD_TOKEN is empty. The bot will fail to log in.")
|
||
|
||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||
|
||
# Ensure data path exists and is seeded
|
||
os.makedirs(os.path.dirname(DATA_FILE) or ".", exist_ok=True)
|
||
if not os.path.exists(DATA_FILE):
|
||
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
||
f.write("{}")
|
||
|
||
bot.data_manager = DataManager(DATA_FILE)
|
||
|
||
# ---------- Self-check: resolve from ENV first, then cfg_helper ----------
|
||
def _resolve_channel_id(c, key: str) -> int:
|
||
# 1) ENV always wins
|
||
env_key = f"SHAI_{key.upper()}"
|
||
raw = os.getenv(env_key, "").strip().strip('"').strip("'")
|
||
if raw.isdigit():
|
||
return int(raw)
|
||
|
||
# 2) Try cfg_helper (if it happens to know)
|
||
try:
|
||
v = int(c.int(key, 0))
|
||
if v:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
|
||
# 3) Last resort: legacy bot.config shapes
|
||
try:
|
||
# bot.config like dict
|
||
v = int(getattr(c, "get", lambda *_: 0)(key, 0))
|
||
if v:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
|
||
return 0
|
||
|
||
|
||
async def _guild_selfcheck(g: discord.Guild, c):
|
||
problems = []
|
||
|
||
def _need_channel(id_key, *perms):
|
||
cid = _resolve_channel_id(c, id_key)
|
||
if not cid:
|
||
problems.append(f"Missing config key: {id_key}")
|
||
return
|
||
|
||
ch = g.get_channel(cid)
|
||
if not ch:
|
||
problems.append(f"Channel not found: {id_key}={cid}")
|
||
return
|
||
|
||
me = g.me
|
||
p = ch.permissions_for(me)
|
||
for perm in perms:
|
||
if not getattr(p, perm, False):
|
||
problems.append(f"Missing permission on #{ch.name}: {perm}")
|
||
|
||
_need_channel('mod_channel_id', 'read_messages', 'send_messages', 'add_reactions', 'read_message_history')
|
||
_need_channel('modlog_channel_id', 'read_messages', 'send_messages')
|
||
_need_channel('pirates_list_channel_id', 'read_messages', 'send_messages')
|
||
|
||
if problems:
|
||
print(f"[SelfCheck:{g.name}]")
|
||
for p in problems:
|
||
print(" -", p)
|
||
|
||
# ---------- events ----------
|
||
|
||
@bot.event
|
||
async def on_ready():
|
||
import asyncio
|
||
|
||
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
|
||
print("[Intents] members:", bot.intents.members,
|
||
"/ message_content:", bot.intents.message_content,
|
||
"/ voice_states:", bot.intents.voice_states)
|
||
|
||
env_cfg = cfg_helper(bot)
|
||
|
||
# Per-guild permission sanity checks (env-aware)
|
||
try:
|
||
await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds])
|
||
except Exception as e:
|
||
print("[SelfCheck] failed:", repr(e))
|
||
|
||
# ---------- Slash command scope & sync ----------
|
||
#
|
||
# Toggle here (or set SHAI_SLASH_GUILD_ONLY=true/false):
|
||
guild_only = env_cfg.bool("slash_guild_only", True)
|
||
|
||
# Choose target guilds for "instant" registration
|
||
target_gids = set()
|
||
for key in ("home_guild_id", "dev_guild_id"):
|
||
val = env_cfg.get(key)
|
||
if val:
|
||
try:
|
||
target_gids.add(int(val))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
if guild_only and target_gids:
|
||
print(f"[Slash] Mode: GUILD-ONLY to {sorted(target_gids)}")
|
||
|
||
# Copy all currently-loaded global commands to each target guild
|
||
for gid in sorted(target_gids):
|
||
g = bot.get_guild(gid)
|
||
if not g:
|
||
print(f"[Slash] Guild {gid}: not in cache; skipping copy/sync.")
|
||
continue
|
||
bot.tree.copy_global_to(guild=g)
|
||
g_cmds = await bot.tree.sync(guild=g)
|
||
names = ", ".join(f"/{c.name}" for c in g_cmds) if g_cmds else "(none)"
|
||
print(f"[Slash] Synced {len(g_cmds)} commands to {g.name} ({g.id}): {names}")
|
||
|
||
# Now remove global commands so only guild-scoped remain
|
||
bot.tree.clear_commands(guild=None)
|
||
cleared = await bot.tree.sync() # push empty global set (purges old global copies)
|
||
print(f"[Slash] Cleared global commands (now {len(cleared)}).")
|
||
|
||
else:
|
||
print("[Slash] Mode: GLOBAL")
|
||
# Purge any old per-guild copies in target guilds (to avoid dupes),
|
||
# then sync globally once.
|
||
for gid in sorted(target_gids):
|
||
g = bot.get_guild(gid)
|
||
if not g:
|
||
print(f"[Slash] Guild {gid}: not in cache; skip purge.")
|
||
continue
|
||
bot.tree.clear_commands(guild=g)
|
||
await bot.tree.sync(guild=g)
|
||
print(f"[Slash] Purged guild-specific commands in {g.name} ({g.id}).")
|
||
|
||
global_cmds = await bot.tree.sync()
|
||
names = ", ".join(f"/{c.name}" for c in global_cmds) if global_cmds else "(none)"
|
||
print(f"[Slash] Synced {len(global_cmds)} commands globally: {names}")
|
||
|
||
# --- Always print what actually exists after sync ---
|
||
def _fmt_cmds(cmds):
|
||
try:
|
||
return ", ".join(f"/{c.name}" for c in cmds) if cmds else "(none)"
|
||
except Exception:
|
||
return "(unreadable)"
|
||
|
||
# Global list
|
||
try:
|
||
global_cmds = await bot.tree.fetch_commands()
|
||
print(f"[Slash] Global commands ({len(global_cmds)}): {_fmt_cmds(global_cmds)}")
|
||
except Exception as e:
|
||
print("[Slash] Failed to fetch global commands:", repr(e))
|
||
|
||
# Guild lists
|
||
for gid in sorted(target_gids):
|
||
g = bot.get_guild(gid)
|
||
if not g:
|
||
print(f"[Slash] Guild {gid}: not in cache; cannot fetch commands.")
|
||
continue
|
||
try:
|
||
g_cmds = await bot.tree.fetch_commands(guild=g)
|
||
print(f"[Slash] {g.name} ({g.id}) guild commands ({len(g_cmds)}): {_fmt_cmds(g_cmds)}")
|
||
except Exception as e:
|
||
print(f"[Slash] Failed to fetch commands for guild {gid}:", repr(e))
|
||
|
||
except Exception as e:
|
||
print("[Slash] Sync failed:", repr(e))
|
||
|
||
# Post boot status message
|
||
try:
|
||
await post_boot_notice(bot)
|
||
except Exception as e:
|
||
print("[BootNotice] failed:", repr(e))
|
||
|
||
# ---------- Auto-discover extensions ----------
|
||
|
||
modules_path = pathlib.Path(__file__).parent / "modules"
|
||
extensions = []
|
||
for folder in modules_path.iterdir():
|
||
if not folder.is_dir():
|
||
continue
|
||
# skip non-cog helpers under modules/common
|
||
if folder.name == "common":
|
||
continue
|
||
for file in folder.glob("*.py"):
|
||
if file.name == "__init__.py":
|
||
continue
|
||
extensions.append(f"modules.{folder.name}.{file.stem}")
|
||
|
||
def _install_signal_handlers(loop, bot_obj):
|
||
def _graceful(*_):
|
||
loop.create_task(bot_obj.close())
|
||
for s in (signal.SIGTERM, signal.SIGINT):
|
||
try:
|
||
loop.add_signal_handler(s, _graceful)
|
||
except NotImplementedError:
|
||
pass # Windows
|
||
|
||
async def main():
|
||
print(f"[STARTUP] ShaiWatcher booting v{VERSION}")
|
||
async with bot:
|
||
for ext in extensions:
|
||
try:
|
||
await bot.load_extension(ext)
|
||
print(f"[Modules] Loaded: {ext}")
|
||
except Exception as e:
|
||
print(f"[Modules] Failed to load {ext}:", repr(e))
|
||
loop = asyncio.get_running_loop()
|
||
_install_signal_handlers(loop, bot)
|
||
await bot.start(TOKEN)
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|