diff --git a/bot.py b/bot.py index f066d74..e58207d 100644 --- a/bot.py +++ b/bot.py @@ -9,7 +9,7 @@ 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.5.a4" +VERSION = "0.3.9.6.a1" # ---------- Env loading ---------- diff --git a/modules/common/bot_restart.py b/modules/common/bot_restart.py new file mode 100644 index 0000000..dabd155 --- /dev/null +++ b/modules/common/bot_restart.py @@ -0,0 +1,157 @@ +# modules/common/bot_restart.py +import os +import re +import time +import asyncio +from datetime import datetime, timezone + +import discord +from discord.ext import commands +from discord import app_commands + +from modules.common.settings import cfg +from mod_perms import require_mod_interaction + +WEAK_REASONS = { + "stuck", "idk", "dont know", "don't know", "unknown", "?", "lag", + "restart", "restarting", "update", "updating", "bug", "crash", "crashed" +} + +def _now_utc_str() -> str: + return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC') + +def _reason_ok(s: str) -> tuple[bool, str | None]: + """ + Enforce a descriptive reason: + - >= 20 characters + - >= 4 words + - at least 3 words with length >= 3 + - reject trivial/weak phrases + """ + if not s: + return False, "Reason is required." + s = s.strip() + if len(s) < 20: + return False, "Please provide a more descriptive reason (≥ 20 characters)." + words = re.findall(r"[A-Za-z0-9'-]+", s) + if len(words) < 4: + return False, "Please include at least 4 words." + longish = [w for w in words if len(w) >= 3] + if len(longish) < 3: + return False, "Add more detail—use at least three meaningful words (≥3 letters)." + low = s.lower() + if low in WEAK_REASONS or any(low == w for w in WEAK_REASONS): + return False, "Reason is too vague. Please explain what happened." + return True, None + +class BotRestartCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self._restarting = False + r = cfg(bot) + self.modlog_channel_id = r.int('modlog_channel_id', 0) + + async def _send_modlog(self, guild: discord.Guild, content: str): + if not self.modlog_channel_id: + print("[bot_restart] modlog_channel_id not configured; skipping modlog message.") + return + ch = guild.get_channel(self.modlog_channel_id) + if not ch: + # fallback: global fetch + ch = self.bot.get_channel(self.modlog_channel_id) + if ch: + try: + await ch.send(content, allowed_mentions=discord.AllowedMentions.none()) + except Exception as e: + print("[bot_restart] failed to send modlog:", repr(e)) + else: + print(f"[bot_restart] channel id {self.modlog_channel_id} not found.") + + def _current_version(self) -> str | None: + """Best-effort: read last detected version from boot_state.""" + dm = getattr(self.bot, "data_manager", None) + if not dm: + return None + try: + st = (dm.get('boot_state') or [{}])[-1] if dm.get('boot_state') else {} + v = st.get('last_version') + return v if v else None + except Exception: + return None + + async def _do_restart(self, delay: float = 2.0): + if self._restarting: + return + self._restarting = True + try: + await asyncio.sleep(delay) + # Graceful close first + try: + await self.bot.close() + except Exception as e: + print("[bot_restart] bot.close() raised:", repr(e)) + # Hard exit so container/wrapper restarts us + os._exit(0) # noqa: E999 + except Exception as e: + print("[bot_restart] restart sequence failed:", repr(e)) + # As a last resort, still try to exit + try: + os._exit(1) + except Exception: + pass + + @app_commands.command(name="bot_restart", description="(Mod-only) Restart the bot with a descriptive reason.") + @app_commands.describe(reason="Explain why a restart is necessary (be specific).") + async def bot_restart(self, interaction: discord.Interaction, reason: str): + # Mods only + if not await require_mod_interaction(interaction): + return + + ok, err = _reason_ok(reason) + if not ok: + return await interaction.response.send_message(f"❌ {err}", ephemeral=True) + + # Ack immediately to avoid 'interaction failed' + await interaction.response.send_message("🔁 Restart requested — posting to modlog and restarting shortly…", ephemeral=True) + + guild = interaction.guild + stamp = _now_utc_str() + ver = self._current_version() or "unknown" + + log = ( + "🔁 **Bot Restart Requested**\n" + f"**By:** {interaction.user.mention}\n" + f"**When:** {stamp}\n" + f"**Running version:** `{ver}`\n" + f"**Reason:** {reason.strip()}" + ) + if guild: + await self._send_modlog(guild, log) + else: + print("[bot_restart] no guild found on interaction; modlog not sent.") + + # small delay to let the modlog flush across the gateway + await self._do_restart(delay=2.0) + +async def setup(bot: commands.Bot): + cog = BotRestartCog(bot) + await bot.add_cog(cog) + + # Idempotent (re)registration following your existing pattern + home_gid = cfg(bot).int('home_guild_id', 0) + guild_obj = discord.Object(id=home_gid) if home_gid else None + + def _rm(name: str): + try: + bot.tree.remove_command(name, guild=guild_obj) + except Exception: + try: + bot.tree.remove_command(name, guild=None) + except Exception: + pass + + _rm("bot_restart") + if home_gid: + bot.tree.add_command(cog.bot_restart, guild=guild_obj) + else: + bot.tree.add_command(cog.bot_restart)