0.3.9.6.a3
Cogified the restart command `/bot_restart {reason}` -> `/power restart {reason}` for future enhancements
This commit is contained in:
parent
95d91b6f3e
commit
40ef32c530
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.6.a2"
|
VERSION = "0.3.9.6.a3"
|
||||||
|
|
||||||
# ---------- Env loading ----------
|
# ---------- Env loading ----------
|
||||||
|
|
||||||
|
@ -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)
|
|
0
modules/power/__init__.py
Normal file
0
modules/power/__init__.py
Normal file
150
modules/power/power.py
Normal file
150
modules/power/power.py
Normal file
@ -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")
|
Loading…
Reference in New Issue
Block a user