358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""
|
|
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()
|