""" 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()