From 40ef32c53005dd061f21a7437d3d69afc27f04a7 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Mon, 11 Aug 2025 02:24:23 +0200 Subject: [PATCH] 0.3.9.6.a3 Cogified the restart command `/bot_restart {reason}` -> `/power restart {reason}` for future enhancements --- bot.py | 2 +- modules/common/bot_restart.py | 157 ---------------------------------- modules/power/__init__.py | 0 modules/power/power.py | 150 ++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 158 deletions(-) delete mode 100644 modules/common/bot_restart.py create mode 100644 modules/power/__init__.py create mode 100644 modules/power/power.py diff --git a/bot.py b/bot.py index 3131108..fcee552 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.6.a2" +VERSION = "0.3.9.6.a3" # ---------- Env loading ---------- diff --git a/modules/common/bot_restart.py b/modules/common/bot_restart.py deleted file mode 100644 index dabd155..0000000 --- a/modules/common/bot_restart.py +++ /dev/null @@ -1,157 +0,0 @@ -# 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) diff --git a/modules/power/__init__.py b/modules/power/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/power/power.py b/modules/power/power.py new file mode 100644 index 0000000..5508ef8 --- /dev/null +++ b/modules/power/power.py @@ -0,0 +1,150 @@ +# modules/power/power.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 + +# ---------------- helpers ---------------- + +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." + if sum(len(w) >= 3 for w in words) < 3: + return False, "Add more detail—use at least three meaningful words (≥ 3 letters)." + low = s.lower() + if low in WEAK_REASONS: + return False, "Reason is too vague. Please explain what happened." + return True, None + +async def _send_modlog(bot: commands.Bot, guild: discord.Guild, content: str): + modlog_channel_id = cfg(bot).int('modlog_channel_id', 0) + if not modlog_channel_id: + print("[power] modlog_channel_id not configured; skipping modlog.") + return + ch = guild.get_channel(modlog_channel_id) or bot.get_channel(modlog_channel_id) + if ch: + try: + await ch.send(content, allowed_mentions=discord.AllowedMentions.none()) + except Exception as e: + print("[power] failed to send modlog:", repr(e)) + else: + print(f"[power] channel id {modlog_channel_id} not found.") + +def _current_version(bot: commands.Bot) -> str | None: + """Best-effort: read last detected version from boot_state.""" + dm = getattr(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 _graceful_restart(bot: commands.Bot, delay: float = 2.0): + # small delay so modlog flushes out to Discord + await asyncio.sleep(delay) + try: + await bot.close() + except Exception as e: + print("[power] bot.close() raised:", repr(e)) + # Force exit so container/wrapper restarts us + os._exit(0) # noqa + +# ---------------- Cog + slash group ---------------- + +class PowerActionsCog(commands.Cog): + """Administrative power actions (mod-only).""" + def __init__(self, bot: commands.Bot): + self.bot = bot + + power = app_commands.Group(name="power", description="Administrative power actions (mod-only)") + + @power.command(name="restart", description="Restart the bot (mod-only). Provide a descriptive reason.") + @app_commands.describe(reason="Explain why a restart is necessary (be specific).") + async def 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) + + await interaction.response.send_message("🔁 Restart requested — logging to modlog and restarting…", ephemeral=True) + + guild = interaction.guild + stamp = _now_utc_str() + ver = _current_version(interaction.client) 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 _send_modlog(interaction.client, guild, log) + else: + print("[power] no guild on interaction; modlog not sent.") + + await _graceful_restart(interaction.client, delay=2.0) + +# ---------------- setup ---------------- + +async def setup(bot: commands.Bot): + cog = PowerActionsCog(bot) + await bot.add_cog(cog) + + home_gid = cfg(bot).int('home_guild_id', 0) + guild_obj = discord.Object(id=home_gid) if home_gid else None + + # remove any prior 'power' root to keep reloads idempotent + 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("power") + if home_gid: + bot.tree.add_command(cog.power, guild=guild_obj) + print("[power] Registered /power group to home guild", home_gid) + else: + bot.tree.add_command(cog.power) + print("[power] Registered /power group globally")