0.3.9.6.a1
Added `/bot_restart {reason}` command for moderators to restart the bot from Discord
			
			
This commit is contained in:
		
							parent
							
								
									b780c4069e
								
							
						
					
					
						commit
						a25dca76e7
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								bot.py
									
									
									
									
									
								
							@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Version consists of:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# 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 ----------
 | 
					# ---------- Env loading ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										157
									
								
								modules/common/bot_restart.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								modules/common/bot_restart.py
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user