Gameurbot/bot.py

328 lines
13 KiB
Python
Raw Normal View History

"""
bot.py Bot Discord de monitoring serveur.
Lance simultanément :
Un serveur HTTP (aiohttp) qui reçoit les métriques de l'agent
Le bot Discord qui met à jour les messages épinglés dans les guildes configurées
Lancement : python bot.py
"""
import asyncio
import json
import os
import logging
from datetime import datetime, timezone
from pathlib import Path
import discord
from discord.ext import commands, tasks
from aiohttp import web
from status_engine import StatusEngine, AnalysisResult, STATUS_OK, STATUS_WARNING, STATUS_CRITICAL
# ─── Logging ──────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("bot")
# ─── Config ───────────────────────────────────────────────────────────────────
CONFIG_PATH = Path("config.json")
def load_config() -> dict:
with open(CONFIG_PATH, encoding="utf-8") as f:
return json.load(f)
def save_config(cfg: dict):
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# ─── Bot ──────────────────────────────────────────────────────────────────────
class StatusBot(commands.Bot):
def __init__(self, cfg: dict):
intents = discord.Intents.default()
intents.guilds = True
super().__init__(command_prefix="!", intents=intents)
self.cfg = cfg
self.engine = StatusEngine()
# guild_id (str) → {"channel_id": int, "message_id": int | None}
self.targets: dict[str, dict] = cfg.get("guilds", {})
self._api_runner: web.AppRunner | None = None
# ── Lifecycle ─────────────────────────────────────────────────────────────
async def setup_hook(self):
# On lance l'API et la boucle AVANT la synchro des commandes (qui peut être longue)
asyncio.create_task(self._start_api_server())
self.update_loop.start()
log.info("Services de monitoring démarrés.")
await self.tree.sync()
log.info("Slash commands synced.")
async def on_ready(self):
log.info(f"Connecté en tant que {self.user} (ID {self.user.id})")
await self.change_presence(
activity=discord.Activity(type=discord.ActivityType.watching, name="l'état du serveur")
)
# ── API HTTP (réception métriques) ────────────────────────────────────────
async def _start_api_server(self):
app = web.Application()
app.router.add_post("/metrics", self._handle_metrics)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", self.cfg.get("api_port", 8765))
await site.start()
self._api_runner = runner
log.info(f"API HTTP en écoute sur le port {self.cfg.get('api_port', 8765)}")
async def _handle_metrics(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.Response(status=400, text="JSON invalide")
if data.get("api_key") != self.cfg.get("api_secret"):
return web.Response(status=403, text="Clé API invalide")
log.info(f"Métriques reçues de l'agent (CPU: {data.get('system', {}).get('cpu_percent') or '?'}%)")
self.engine.push(data)
return web.Response(status=200, text="ok")
# ── Boucle de mise à jour ─────────────────────────────────────────────────
@tasks.loop(seconds=20)
async def update_loop(self):
if not self.engine.has_data:
return
result = self.engine.analyse()
if result is None:
return
embed = self._build_embed(result)
for guild_id_str, target in self.targets.items():
if not isinstance(target, dict):
continue
channel_id = target.get("channel_id")
if not channel_id:
continue
channel = self.get_channel(channel_id)
if not channel:
try:
channel = await self.fetch_channel(channel_id)
except Exception as exc:
log.warning(f"Impossible de récupérer le salon {channel_id}: {exc}")
continue
msg_id = target.get("message_id")
try:
if msg_id:
# Mise à jour du message existant
msg = await channel.fetch_message(msg_id)
await msg.edit(embed=embed)
else:
# Premier envoi : on envoie et on épingle
msg = await channel.send(embed=embed)
await msg.pin()
target["message_id"] = msg.id
save_config(self.cfg)
log.info(f"Message épinglé dans #{channel.name} ({guild_id_str})")
except discord.NotFound:
# Le message a été supprimé — on recrée
target["message_id"] = None
save_config(self.cfg)
except discord.Forbidden:
log.warning(f"Permissions insuffisantes dans #{channel.name}")
except Exception as exc:
log.error(f"Erreur update guilde {guild_id_str}: {exc}")
@update_loop.before_loop
async def before_loop(self):
await self.wait_until_ready()
# ── Construction de l'embed ───────────────────────────────────────────────
def _build_embed(self, r: AnalysisResult) -> discord.Embed:
COLOR = {
STATUS_OK: 0x2ECC71, # vert
STATUS_WARNING: 0xF39C12, # orange
STATUS_CRITICAL: 0xE74C3C, # rouge
}
TITLE = {
STATUS_OK: "🟢 Serveur — Tout est normal",
STATUS_WARNING: "🟡 Serveur — Charge élevée",
STATUS_CRITICAL: "🔴 Serveur — Surchargé",
}
embed = discord.Embed(
title=TITLE[r.status],
color=COLOR[r.status],
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text="Dernière mise à jour")
# ── Ressources globales ──
cpu_bar = self._bar(r.cpu, 100)
ram_bar = self._bar(r.ram_pct, 100)
swap_bar = self._bar(r.swap_pct, 100)
temp_str = f" 🌡️ {r.cpu_temp}°C" if r.cpu_temp else ""
resources = (
f"`CPU ` {cpu_bar} **{r.cpu:.0f}%**{temp_str}\n"
f"`RAM ` {ram_bar} **{r.ram_pct:.0f}%** "
f"({r.ram_used_gb:.1f}/{r.ram_total_gb:.1f} GB)\n"
)
if r.swap_pct > 5:
resources += f"`SWAP` {swap_bar} **{r.swap_pct:.0f}%**\n"
resources += (
f"\n📈 Charge (1m / 5m / 15m) : "
f"`{r.load_avg[0]:.2f}` / `{r.load_avg[1]:.2f}` / `{r.load_avg[2]:.2f}`"
)
embed.add_field(name="📊 Ressources", value=resources, inline=False)
# ── Services ──
running = [s for s in r.services if s.running]
stopped = [s for s in r.services if not s.running]
if running:
lines = []
for svc in running:
cpu_indicator = ""
if svc.cpu >= 40:
cpu_indicator = " 🔥"
elif svc.cpu >= 20:
cpu_indicator = ""
lines.append(
f"{svc.name}{cpu_indicator}"
f"CPU **{svc.cpu:.0f}%** | RAM **{svc.ram_gb:.1f} GB**"
)
embed.add_field(
name="🎮 Services actifs",
value="\n".join(lines),
inline=False
)
if stopped:
stopped_names = "".join(s.name for s in stopped)
embed.add_field(name="💤 Services arrêtés", value=stopped_names, inline=False)
# ── Cause ──
if r.status != STATUS_OK:
cause_text = "\n".join(r.cause_lines)
embed.add_field(name="🔍 Pourquoi ça rame ?", value=cause_text, inline=False)
# ── ETA ──
embed.add_field(name="⏱️ Retour à la normale", value=r.eta_text, inline=False)
return embed
def _bar(self, value: float, max_val: float, length: int = 10) -> str:
"""Génère une barre de progression ASCII."""
filled = round((value / max_val) * length)
filled = max(0, min(length, filled))
bar = "" * filled + "" * (length - filled)
return f"`{bar}`"
# ─── Slash commands ───────────────────────────────────────────────────────────
def setup_commands(bot: StatusBot):
@bot.tree.command(name="status", description="Affiche l'état actuel du serveur")
async def cmd_status(interaction: discord.Interaction):
if not bot.engine.has_data:
await interaction.response.send_message(
"⏳ En attente des premières métriques de l'agent…", ephemeral=True
)
return
result = bot.engine.analyse()
embed = bot._build_embed(result)
await interaction.response.send_message(embed=embed)
@bot.tree.command(name="setup", description="(Admin) Configure ce salon comme tableau de bord de statut")
@discord.app_commands.default_permissions(administrator=True)
async def cmd_setup(interaction: discord.Interaction):
guild_id = str(interaction.guild_id)
if guild_id not in bot.targets:
bot.targets[guild_id] = {}
bot.cfg["guilds"] = bot.targets
bot.targets[guild_id]["channel_id"] = interaction.channel_id
bot.targets[guild_id]["message_id"] = None # forcera la recréation
save_config(bot.cfg)
await interaction.response.send_message(
f"✅ Ce salon sera utilisé pour le tableau de bord de statut.\n"
f"Le message épinglé apparaîtra dans les 20 secondes.",
ephemeral=True
)
log.info(f"Setup: guilde {guild_id} → channel {interaction.channel_id}")
@bot.tree.command(name="services", description="Détail de tous les services détectés")
async def cmd_services(interaction: discord.Interaction):
if not bot.engine.has_data:
await interaction.response.send_message("⏳ Pas encore de données.", ephemeral=True)
return
result = bot.engine.analyse()
lines = []
for svc in result.services:
status_icon = "🟢" if svc.running else ""
if svc.running:
lines.append(f"{status_icon} **{svc.name}** — CPU {svc.cpu:.0f}% | RAM {svc.ram_gb:.2f} GB")
else:
lines.append(f"{status_icon} **{svc.name}** — arrêté")
embed = discord.Embed(
title="🖥️ État des services",
description="\n".join(lines) or "Aucun service détecté",
color=0x3498DB,
timestamp=datetime.now(timezone.utc),
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# ─── Entry point ──────────────────────────────────────────────────────────────
def main():
if not CONFIG_PATH.exists():
print(f"[ERREUR] Fichier {CONFIG_PATH} introuvable. Copie config.example.json → config.json et remplis-le.")
return
cfg = load_config()
token = cfg.get("discord_token") or os.environ.get("DISCORD_TOKEN")
if not token:
print("[ERREUR] Aucun token Discord dans config.json (clé 'discord_token') ni en variable d'env DISCORD_TOKEN.")
return
bot = StatusBot(cfg)
setup_commands(bot)
bot.run(token, log_handler=None)
if __name__ == "__main__":
main()