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