# 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)