shaiwatcher/bot.py
Franz Rolfsvaag 1ede582a76 0.4.2.0.a1
- DD cycle data fetching
  - ShaiWatcher will now keep an updated loot table of the unique items in the DD each week
    The bot will **only** edit its message if already present, which should reduce message spam
  - Added command `/dd_update` to control the update behaviour. stop|resume|start [reason_text]
- Docsite changes
  - Added "ADMIN" tags to commands, signifying owner-only commands
  - Owner-only commands are now filtered under the "moderator" category
  - Added docs for `/dd_update`
- Logging
  - Added logging info for more verbose info relating to configuration and installation
2025-08-16 06:39:01 +02:00

256 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; doesnt trigger auto update)
VERSION = "0.4.2.0.a1"
# ---------- 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')
_need_channel('dd_channel_id', 'read_messages', 'send_messages', 'read_message_history')
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())