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.3.9.7.a5" # ---------- 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())