Gameurbot/bot.py

359 lines
14 KiB
Python
Raw Permalink 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
import sys
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
self._agent_proc: asyncio.subprocess.Process | 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.")
# Automation : Lancement de l'agent en tâche de fond
try:
self._agent_proc = await asyncio.create_subprocess_exec(
sys.executable, "agent.py",
stdout=None, stderr=None # L'agent loggera dans son propre flux si besoin
)
log.info(f"✅ Agent lancé automatiquement (PID {self._agent_proc.pid})")
except Exception as e:
log.error(f"❌ Impossible de lancer l'agent automatiquement : {e}")
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")
)
async def close(self):
"""Action à la fermeture du bot (propre)."""
if self._agent_proc:
log.info("Arrêt de l'agent...")
try:
self._agent_proc.terminate()
await self._agent_proc.wait()
except Exception:
pass
if self._api_runner:
log.info("Arrêt du serveur API HTTP...")
if hasattr(self, "_api_site") and self._api_site:
await self._api_site.stop()
await self._api_runner.cleanup()
await super().close()
# ── 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()
port = self.cfg.get("api_port", 8765)
site = web.TCPSite(runner, "0.0.0.0", port)
try:
await site.start()
self._api_runner = runner
self._api_site = site
log.info(f"✅ API HTTP en écoute sur le port {port}")
except OSError as e:
if e.errno == 98:
log.critical(f"❌ IMPOSSIBLE DE DÉMARRER L'API : Le port {port} est déjà utilisé.")
log.critical("Vérifiez qu'une autre instance du bot n'est pas déjà lancée.")
else:
log.critical(f"❌ Erreur lors du démarrage de l'API : {e}")
# On peut choisir de fermer le bot ici si l'API est critique
await self.close()
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: 0x00FF9D, # Vert Matrix / Cyberpunk
STATUS_WARNING: 0xFFB300, # Or
STATUS_CRITICAL: 0xFF3D00, # Rouge Vif
}
# Embed Principal
embed = discord.Embed(
color=COLOR[r.status],
timestamp=datetime.now(timezone.utc),
)
# En-tête premium
status_text = {
STATUS_OK: "OPÉRATIONNEL",
STATUS_WARNING: "CHARGE ÉLEVÉE",
STATUS_CRITICAL: "SURCHARGE SYSTÈME",
}
embed.set_author(
name=f"STATUT DU SERVEUR — {status_text[r.status]}",
icon_url="https://cdn-icons-png.flaticon.com/512/689/689307.png" # Icone de serveur
)
embed.set_footer(text="Système de monitoring Bêta • Temps réel")
# ── Ressources Système (Colonne 1) ──
cpu_bar = self._bar(r.cpu, 100, length=8)
ram_bar = self._bar(r.ram_pct, 100, length=8)
temp_str = f" • 🌡️ `{r.cpu_temp}°C`" if r.cpu_temp else ""
load_str = f"`{r.load_avg[0]:.2f}` | `{r.load_avg[1]:.2f}` | `{r.load_avg[2]:.2f}`"
res_val = (
f"**CPU** {cpu_bar} **{r.cpu:.0f}%**{temp_str}\n"
f"**RAM** {ram_bar} **{r.ram_pct:.0f}%**\n"
f"┗ `{r.ram_used_gb:.1f} / {r.ram_total_gb:.1f} GB`"
)
embed.add_field(name="🚀 Ressources", value=res_val, inline=True)
embed.add_field(name="📈 Charge Load", value=f"\n{load_str}", inline=True)
# ── Services Actifs (Section Large) ──
running = [s for s in r.services if s.running]
if running:
svc_lines = []
for svc in running:
icon = "🔥" if svc.cpu >= 40 else ("" if svc.cpu >= 20 else "🔹")
svc_lines.append(
f"{icon} **{svc.name}**\n"
f"╚ `CPU {svc.cpu:2.0f}%` `RAM {svc.ram_gb:4.1f} GB`"
)
embed.add_field(
name="🎮 Services Actifs",
value="\n".join(svc_lines),
inline=False
)
# ── Diagnostics & Analyse ──
if r.status != STATUS_OK:
diag_val = "\n".join([f"{line}" for line in r.cause_lines])
embed.add_field(name="🔍 Analyse Diagnostic", value=f"```\n{diag_val}\n```", inline=False)
# ── ETA & Footer ──
eta_icon = "🟢" if r.status == STATUS_OK else ""
embed.add_field(name="⏱️ Prévision", value=f"{eta_icon} {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()