diff --git a/bot.py b/bot.py index a62e47a..45822f7 100755 --- a/bot.py +++ b/bot.py @@ -67,7 +67,8 @@ class MyBot(commands.Bot): "commandes.ticket_whitelist", "commandes.bug_report", "commandes.convocation", - "commandes.absence_staff" + "commandes.absence_staff", + "commandes.website_sync", ] for extension in extensions: diff --git a/commandes/website_sync.py b/commandes/website_sync.py new file mode 100644 index 0000000..71e584a --- /dev/null +++ b/commandes/website_sync.py @@ -0,0 +1,476 @@ +""" +Cog : website_sync +Commandes pour gérer la synchronisation JSON du site web. + +Slash commands disponibles : + /json-generate [cible] — Génère les JSON manuellement + /json-status — État des fichiers + prochaine génération + /json-preview [cible] [limite] — Aperçu sans écrire les fichiers + /json-history [limite] — Historique des N dernières générations + /json-config show — Affiche la config actuelle + /json-config set-guild — Définit le serveur cible (members.json) + /json-config set-schedule [m] — Heure de génération auto + /json-config set-output-dir — Changer le dossier de sortie + /json-config add-founder-role — Ajouter un nom de rôle fondateur + /json-config remove-founder-role — Retirer un nom de rôle fondateur + /json-config server-field — Active/désactive un champ servers.json + /json-config member-field — Active/désactive un champ members.json + /json-config reset — Remet la config par défaut +""" + +import asyncio +import json +import logging +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional + +import discord +from discord import app_commands +from discord.ext import commands, tasks + +from src.json_export_service import ( + load_config, save_config, generate_all, + generate_servers_json, generate_members_json, + preview_servers, preview_members, + get_history, log_history, + SERVERS_FILE, MEMBERS_FILE, OUTPUT_DIR, + DEFAULT_CONFIG, +) + +logger = logging.getLogger("kuby.website_sync") + +# Listes des champs disponibles (pour les choix des commandes) +SERVER_FIELDS = [ + "id", "name", "icon", "banner", "owner", + "memberCount", "description", "createdAt", + "boostLevel", "boostCount", "verificationLevel", +] +MEMBER_FIELDS = [ + "id", "username", "displayName", "globalName", + "avatar", "bio", "roles", "joinedAt", "createdAt", + "badges", "accentColor", "isBot", +] + + +class WebsiteSync(commands.Cog, name="Website Sync"): + """Synchronisation JSON pour le site web.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self._scheduler_task: Optional[asyncio.Task] = None + self.daily_export.start() + + def cog_unload(self): + self.daily_export.cancel() + + # ── Tâche de fond : génération quotidienne ──────────────────────────────── + @tasks.loop(minutes=1) + async def daily_export(self): + """Vérifie chaque minute si c'est l'heure de générer les JSON.""" + config = load_config() + now = datetime.now() + if now.hour == config["autoHour"] and now.minute == config["autoMinute"]: + logger.info("[JSON Export] Génération automatique quotidienne déclenchée.") + await generate_all(self.bot) + + @daily_export.before_loop + async def before_daily_export(self): + await self.bot.wait_until_ready() + config = load_config() + logger.info( + f"[JSON Export] Scheduler actif → génération auto à " + f"{config['autoHour']:02d}:{config['autoMinute']:02d} chaque jour." + ) + + # ── Helper : embed de réponse ───────────────────────────────────────────── + @staticmethod + def _embed(title: str, color: discord.Color, description: str = "") -> discord.Embed: + e = discord.Embed(title=title, color=color, timestamp=datetime.now(timezone.utc)) + if description: + e.description = description + return e + + # ══════════════════════════════════════════════════════════════════════════ + # /json-generate + # ══════════════════════════════════════════════════════════════════════════ + @app_commands.command(name="json-generate", description="Génère manuellement les fichiers JSON du site web") + @app_commands.describe(cible="Quoi générer ? (défaut : tout)") + @app_commands.choices(cible=[ + app_commands.Choice(name="Tout (servers + members)", value="all"), + app_commands.Choice(name="Serveurs uniquement", value="servers"), + app_commands.Choice(name="Membres uniquement", value="members"), + ]) + @app_commands.default_permissions(administrator=True) + async def json_generate(self, interaction: discord.Interaction, cible: str = "all"): + await interaction.response.defer(ephemeral=True) + start = datetime.now() + embed = self._embed("⏳ Génération en cours...", discord.Color.yellow()) + await interaction.followup.send(embed=embed, ephemeral=True) + + try: + if cible == "servers": + res = await generate_servers_json(self.bot) + embed = self._embed("✅ servers.json généré", discord.Color.green()) + embed.add_field(name="Serveurs", value=str(res["count"]), inline=True) + embed.add_field(name="Durée", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True) + embed.add_field(name="Fichier", value=f"`{SERVERS_FILE}`", inline=False) + + elif cible == "members": + res = await generate_members_json(self.bot) + embed = self._embed("✅ members.json généré", discord.Color.green()) + embed.add_field(name="Membres", value=str(res["count"]), inline=True) + embed.add_field(name="Serveur", value=res["guildName"], inline=True) + embed.add_field(name="Durée", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True) + embed.add_field(name="Fichier", value=f"`{MEMBERS_FILE}`", inline=False) + + else: # all + res = await generate_all(self.bot, trigger="manual") + has_errors = bool(res["errors"]) + embed = self._embed( + "⚠️ Génération partielle" if has_errors else "✅ Génération complète", + discord.Color.orange() if has_errors else discord.Color.green(), + ) + if res["servers"]: + embed.add_field(name="📋 Serveurs", value=f"{res['servers']['count']} serveurs", inline=True) + if res["members"]: + embed.add_field(name="👥 Membres", value=f"{res['members']['count']} membres", inline=True) + embed.add_field(name="Durée totale", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True) + if has_errors: + embed.add_field(name="❌ Erreurs", value="\n".join(res["errors"]), inline=False) + embed.add_field(name="📁 Dossier", value=f"`{OUTPUT_DIR}`", inline=False) + + except Exception as e: + embed = self._embed("❌ Erreur", discord.Color.red(), f"```{e}```") + + await interaction.edit_original_response(embed=embed) + + # ══════════════════════════════════════════════════════════════════════════ + # /json-status + # ══════════════════════════════════════════════════════════════════════════ + @app_commands.command(name="json-status", description="État des fichiers JSON et prochaine génération auto") + @app_commands.default_permissions(administrator=True) + async def json_status(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + config = load_config() + embed = self._embed("📊 Statut JSON Export", discord.Color.blurple()) + + # servers.json + if SERVERS_FILE.exists(): + try: + data = json.loads(SERVERS_FILE.read_text()) + size = SERVERS_FILE.stat().st_size / 1024 + gen_at = datetime.fromisoformat(data["generatedAt"]).strftime("%d/%m/%Y %H:%M") + embed.add_field( + name="🌐 servers.json", + value=f"✅ {data['count']} serveurs — {size:.1f} KB\n📅 {gen_at}", + inline=False, + ) + except Exception: + embed.add_field(name="🌐 servers.json", value="⚠️ Fichier corrompu", inline=False) + else: + embed.add_field(name="🌐 servers.json", value="❌ Non généré", inline=False) + + # members.json + if MEMBERS_FILE.exists(): + try: + data = json.loads(MEMBERS_FILE.read_text()) + size = MEMBERS_FILE.stat().st_size / 1024 + gen_at = datetime.fromisoformat(data["generatedAt"]).strftime("%d/%m/%Y %H:%M") + embed.add_field( + name="👥 members.json", + value=f"✅ {data['count']} membres ({data['guildName']}) — {size:.1f} KB\n📅 {gen_at}", + inline=False, + ) + except Exception: + embed.add_field(name="👥 members.json", value="⚠️ Fichier corrompu", inline=False) + else: + embed.add_field(name="👥 members.json", value="❌ Non généré", inline=False) + + # Prochain run + now = datetime.now() + next_run = now.replace(hour=config["autoHour"], minute=config["autoMinute"], second=0, microsecond=0) + if next_run <= now: + next_run += timedelta(days=1) + diff = next_run - now + h, m = divmod(int(diff.total_seconds() // 60), 60) + in_text = f"dans {h}h {m}min" if h else f"dans {m}min" + embed.add_field( + name="⏰ Prochaine génération auto", + value=f"**{config['autoHour']:02d}:{config['autoMinute']:02d}** — {in_text}\n({next_run.strftime('%d/%m/%Y %H:%M')})", + inline=False, + ) + embed.add_field(name="📁 Dossier", value=f"`{OUTPUT_DIR}`", inline=False) + + await interaction.followup.send(embed=embed, ephemeral=True) + + # ══════════════════════════════════════════════════════════════════════════ + # /json-config (groupe) + # ══════════════════════════════════════════════════════════════════════════ + json_config_group = app_commands.Group( + name="json-config", + description="Configure la génération automatique des JSON", + default_permissions=discord.Permissions(administrator=True), + ) + + # ── show ────────────────────────────────────────────────────────────────── + @json_config_group.command(name="show", description="Affiche la configuration actuelle") + async def config_show(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + config = load_config() + + srv_on = ", ".join(k for k, v in config["serverFields"].items() if v) or "_Aucun_" + srv_off = ", ".join(k for k, v in config["serverFields"].items() if not v) or "_Aucun_" + mbr_on = ", ".join(k for k, v in config["memberFields"].items() if v) or "_Aucun_" + mbr_off = ", ".join(k for k, v in config["memberFields"].items() if not v) or "_Aucun_" + + embed = self._embed("⚙️ Configuration JSON Export", discord.Color.blurple()) + embed.add_field(name="🕐 Génération auto", value=f"{config['autoHour']:02d}:{config['autoMinute']:02d} (heure serveur)", inline=True) + embed.add_field(name="🎯 Serveur cible (members)", value=config["targetGuildId"] or "_Non défini_", inline=True) + embed.add_field(name="\u200b", value="\u200b", inline=False) + embed.add_field(name="✅ servers.json actifs", value=srv_on, inline=False) + embed.add_field(name="❌ servers.json inactifs", value=srv_off, inline=False) + embed.add_field(name="✅ members.json actifs", value=mbr_on, inline=False) + embed.add_field(name="❌ members.json inactifs", value=mbr_off, inline=False) + embed.add_field(name="📁 Dossier de sortie", value=f"`{OUTPUT_DIR}`", inline=False) + + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── set-guild ──────────────────────────────────────────────────────────── + @json_config_group.command(name="set-guild", description="Définit l'ID du serveur cible pour members.json") + @app_commands.describe(guild_id="ID du serveur Discord") + async def config_set_guild(self, interaction: discord.Interaction, guild_id: str): + await interaction.response.defer(ephemeral=True) + try: + guild = self.bot.get_guild(int(guild_id)) or await self.bot.fetch_guild(int(guild_id)) + config = load_config() + config["targetGuildId"] = guild_id + save_config(config) + embed = self._embed( + "✅ Serveur cible défini", discord.Color.green(), + f"**{guild.name}** (`{guild_id}`) sera utilisé pour members.json.", + ) + except Exception: + embed = self._embed( + "❌ Serveur introuvable", discord.Color.red(), + f"Le bot n'est pas dans le serveur `{guild_id}` ou l'ID est invalide.", + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── set-schedule ───────────────────────────────────────────────────────── + @json_config_group.command(name="set-schedule", description="Définit l'heure de génération automatique quotidienne") + @app_commands.describe(heure="Heure (0-23)", minute="Minute (0-59, défaut: 0)") + async def config_set_schedule(self, interaction: discord.Interaction, heure: int, minute: int = 0): + await interaction.response.defer(ephemeral=True) + if not (0 <= heure <= 23) or not (0 <= minute <= 59): + await interaction.followup.send("❌ Heure invalide.", ephemeral=True) + return + config = load_config() + config["autoHour"] = heure + config["autoMinute"] = minute + save_config(config) + embed = self._embed( + "✅ Horaire mis à jour", discord.Color.green(), + f"Prochaine génération auto : **{heure:02d}:{minute:02d}** chaque jour.", + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── server-field ───────────────────────────────────────────────────────── + @json_config_group.command(name="server-field", description="Active/désactive un champ dans servers.json") + @app_commands.describe(champ="Champ à modifier", actif="true = inclus, false = exclu") + @app_commands.choices(champ=[app_commands.Choice(name=f, value=f) for f in SERVER_FIELDS]) + async def config_server_field(self, interaction: discord.Interaction, champ: str, actif: bool): + await interaction.response.defer(ephemeral=True) + config = load_config() + config["serverFields"][champ] = actif + save_config(config) + state = "inclus ✅" if actif else "exclu ❌" + embed = self._embed("✅ Champ servers.json mis à jour", discord.Color.green(), + f"`{champ}` est maintenant **{state}** de servers.json.") + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── member-field ───────────────────────────────────────────────────────── + @json_config_group.command(name="member-field", description="Active/désactive un champ dans members.json") + @app_commands.describe(champ="Champ à modifier", actif="true = inclus, false = exclu") + @app_commands.choices(champ=[app_commands.Choice(name=f, value=f) for f in MEMBER_FIELDS]) + async def config_member_field(self, interaction: discord.Interaction, champ: str, actif: bool): + await interaction.response.defer(ephemeral=True) + config = load_config() + config["memberFields"][champ] = actif + save_config(config) + state = "inclus ✅" if actif else "exclu ❌" + embed = self._embed("✅ Champ members.json mis à jour", discord.Color.green(), + f"`{champ}` est maintenant **{state}** de members.json.") + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── reset ───────────────────────────────────────────────────────────────── + @json_config_group.command(name="reset", description="Remet la configuration par défaut") + async def config_reset(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + from src.json_export_service import CONFIG_FILE + try: + CONFIG_FILE.unlink(missing_ok=True) + except Exception: + pass + load_config() # recrée depuis DEFAULT_CONFIG + embed = self._embed("✅ Configuration réinitialisée", discord.Color.green(), + "Tous les paramètres ont été remis à leurs valeurs par défaut.") + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── set-output-dir ──────────────────────────────────────────────────────── + @json_config_group.command(name="set-output-dir", description="Change le dossier de sortie des fichiers JSON") + @app_commands.describe(chemin="Chemin absolu ou relatif vers le dossier (ex: /var/www/site/data)") + async def config_set_output_dir(self, interaction: discord.Interaction, chemin: str): + await interaction.response.defer(ephemeral=True) + from pathlib import Path as _Path + p = _Path(chemin) + try: + p.mkdir(parents=True, exist_ok=True) + config = load_config() + config["outputDir"] = str(p) + save_config(config) + embed = self._embed( + "✅ Dossier de sortie mis à jour", discord.Color.green(), + f"Les JSON seront désormais écrits dans :\n`{p.resolve()}`", + ) + except Exception as e: + embed = self._embed("❌ Erreur", discord.Color.red(), f"Impossible d'utiliser ce chemin :\n```{e}```") + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── add-founder-role ────────────────────────────────────────────────────── + @json_config_group.command(name="add-founder-role", description="Ajoute un nom de rôle reconnu comme 'fondateur'") + @app_commands.describe(nom="Nom du rôle (insensible à la casse, correspondance partielle)") + async def config_add_founder_role(self, interaction: discord.Interaction, nom: str): + await interaction.response.defer(ephemeral=True) + config = load_config() + roles = config.get("founderRoles", []) + nom_l = nom.lower().strip() + if nom_l in [r.lower() for r in roles]: + embed = self._embed("ℹ️ Déjà présent", discord.Color.yellow(), + f"`{nom}` est déjà dans la liste des rôles fondateurs.") + else: + roles.append(nom_l) + config["founderRoles"] = roles + save_config(config) + embed = self._embed( + "✅ Rôle fondateur ajouté", discord.Color.green(), + f"`{nom_l}` ajouté. Liste actuelle : {', '.join(f'`{r}`' for r in roles)}", + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + # ── remove-founder-role ─────────────────────────────────────────────────── + @json_config_group.command(name="remove-founder-role", description="Retire un nom de rôle fondateur") + @app_commands.describe(nom="Nom du rôle à retirer") + async def config_remove_founder_role(self, interaction: discord.Interaction, nom: str): + await interaction.response.defer(ephemeral=True) + config = load_config() + roles = config.get("founderRoles", []) + nom_l = nom.lower().strip() + updated = [r for r in roles if r.lower() != nom_l] + if len(updated) == len(roles): + embed = self._embed("ℹ️ Introuvable", discord.Color.yellow(), + f"`{nom}` n'est pas dans la liste des rôles fondateurs.") + else: + config["founderRoles"] = updated + save_config(config) + reste = ', '.join(f'`{r}`' for r in updated) or "_Aucun_" + embed = self._embed( + "✅ Rôle fondateur retiré", discord.Color.green(), + f"`{nom_l}` retiré. Liste actuelle : {reste}", + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + # ══════════════════════════════════════════════════════════════════════════ + # /json-preview + # ══════════════════════════════════════════════════════════════════════════ + @app_commands.command(name="json-preview", description="Aperçu des données sans écrire les fichiers") + @app_commands.describe( + cible="Quoi prévisualiser ?", + limite="Nombre d'éléments à afficher (défaut: 3)", + ) + @app_commands.choices(cible=[ + app_commands.Choice(name="Serveurs", value="servers"), + app_commands.Choice(name="Membres", value="members"), + ]) + @app_commands.default_permissions(administrator=True) + async def json_preview(self, interaction: discord.Interaction, cible: str = "servers", limite: int = 3): + await interaction.response.defer(ephemeral=True) + limite = max(1, min(limite, 10)) # entre 1 et 10 + + try: + embed = self._embed(f"🔍 Aperçu — {cible}", discord.Color.blurple()) + embed.set_footer(text="Aperçu uniquement — aucun fichier écrit") + + if cible == "servers": + data = await preview_servers(self.bot, limit=limite) + for s in data: + val = f"ID: `{s['id']}`" + if "memberCount" in s: + val += f"\nMembres: {s['memberCount']}" + if s.get("icon"): + val += f"\nIcon: {s['icon']}" + embed.add_field(name=f"🌐 {s['name']}", value=val, inline=False) + + else: # members + data = await preview_members(self.bot, limit=limite) + embed.description = f"Serveur : **{data['guildName']}**" + for m in data["members"]: + roles_str = ", ".join(m["roles"][:5]) or "_Aucun_" + if len(m["roles"]) > 5: + roles_str += f" +{len(m['roles'])-5}" + embed.add_field( + name=f"👤 {m['displayName']} ({m['username']})", + value=f"ID: `{m['id']}`\nRôles: {roles_str}", + inline=False, + ) + + except Exception as e: + embed = self._embed("❌ Erreur", discord.Color.red(), f"```{e}```") + + await interaction.followup.send(embed=embed, ephemeral=True) + + # ══════════════════════════════════════════════════════════════════════════ + # /json-history + # ══════════════════════════════════════════════════════════════════════════ + @app_commands.command(name="json-history", description="Historique des dernières générations automatiques et manuelles") + @app_commands.describe(limite="Nombre d'entrées à afficher (défaut: 5, max: 10)") + @app_commands.default_permissions(administrator=True) + async def json_history(self, interaction: discord.Interaction, limite: int = 5): + await interaction.response.defer(ephemeral=True) + limite = max(1, min(limite, 10)) + history = get_history() + + embed = self._embed("📜 Historique des générations JSON", discord.Color.blurple()) + + if not history: + embed.description = "_Aucune génération enregistrée pour l'instant._" + else: + for entry in history[:limite]: + date_str = datetime.fromisoformat(entry["date"]).strftime("%d/%m/%Y %H:%M") + trigger = "🤖 Auto" if entry.get("trigger") == "auto" else "👤 Manuel" + status = "✅" if entry.get("success") else "⚠️" + + lines = [f"{trigger} — {date_str}"] + if entry.get("serversCount") is not None: + lines.append(f"📋 {entry['serversCount']} serveurs") + if entry.get("membersCount") is not None: + lines.append(f"👥 {entry['membersCount']} membres ({entry.get('guildName', '?')})") + if entry.get("errors"): + lines.append(f"❌ Erreurs: {'; '.join(entry['errors'])}") + + embed.add_field( + name=f"{status} Génération du {date_str}", + value="\n".join(lines), + inline=False, + ) + + await interaction.followup.send(embed=embed, ephemeral=True) + + +# ─── Setup ─────────────────────────────────────────────────────────────────── +async def setup(bot: commands.Bot): + cog = WebsiteSync(bot) + await bot.add_cog(cog) + # Enregistre le groupe de commandes json-config + bot.tree.add_command(cog.json_config_group) + logger.info("Cog WebsiteSync chargé.") diff --git a/src/json_export_service.py b/src/json_export_service.py new file mode 100644 index 0000000..d25fd38 --- /dev/null +++ b/src/json_export_service.py @@ -0,0 +1,380 @@ +""" +Service d'export JSON pour le site web. +Génère deux fichiers : + - servers.json : tous les serveurs où le bot est présent + - members.json : tous les membres (sans bots) d'un serveur cible +""" + +import json +import os +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import discord + +logger = logging.getLogger("kuby.json_export") + +# ─── Chemins ──────────────────────────────────────────────────────────────── +CONFIG_FILE = Path("data/json-export-config.json") +HISTORY_FILE = Path("data/json-export-history.json") + +def _get_output_dir() -> Path: + """Lit le dossier de sortie depuis la config (ou .env en fallback).""" + if CONFIG_FILE.exists(): + try: + saved = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + if saved.get("outputDir"): + return Path(saved["outputDir"]) + except Exception: + pass + return Path(os.getenv("JSON_OUTPUT_DIR", "data/website-json")) + +@property +def OUTPUT_DIR() -> Path: + return _get_output_dir() + +# Propriétés dynamiques — recalculées à chaque accès +def get_output_dir() -> Path: + return _get_output_dir() + +def get_servers_file() -> Path: + return _get_output_dir() / "servers.json" + +def get_members_file() -> Path: + return _get_output_dir() / "members.json" + +# Aliases statiques utilisés dans les imports existants (valeur initiale) +OUTPUT_DIR = _get_output_dir() +SERVERS_FILE = OUTPUT_DIR / "servers.json" +MEMBERS_FILE = OUTPUT_DIR / "members.json" + +# ─── Config par défaut ─────────────────────────────────────────────────────── +DEFAULT_CONFIG: dict = { + # ID du serveur ciblé pour members.json (ne change jamais) + "targetGuildId": os.getenv("TARGET_GUILD_ID", ""), + + # Heure de génération automatique quotidienne (format 24h) + "autoHour": 3, + "autoMinute": 0, + + # Dossier de sortie (vide = utilise JSON_OUTPUT_DIR du .env) + "outputDir": os.getenv("JSON_OUTPUT_DIR", "data/website-json"), + + # Noms de rôles reconnus comme "fondateur" (insensible à la casse) + "founderRoles": ["fondateur", "founder"], + + # Champs inclus dans servers.json + "serverFields": { + "id": True, + "name": True, + "icon": True, # URL pp du serveur + "banner": True, # URL bannière + "owner": True, # Propriétaire + rôle fondateur + "memberCount": True, # Membres humains uniquement + "description": True, + "createdAt": True, + "boostLevel": False, + "boostCount": False, + "verificationLevel": False, + }, + + # Champs inclus dans members.json + "memberFields": { + "id": True, + "username": True, + "displayName": True, # Pseudo sur le serveur + "globalName": True, # Nom d'affichage global + "avatar": True, # URL avatar (serveur + global) + "bio": True, # Description (toujours null, limite API Discord) + "roles": True, # Tous les rôles avec couleur + "joinedAt": True, # Date d'arrivée sur le serveur + "createdAt": True, # Date de création du compte + "badges": False, # Badges du compte + "accentColor": False, # Couleur de profil + "isBot": False, # Flag bot (les bots sont toujours exclus de la liste) + }, +} + + +# ─── Helpers config ────────────────────────────────────────────────────────── + +def load_config() -> dict: + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + if not CONFIG_FILE.exists(): + save_config(DEFAULT_CONFIG) + return dict(DEFAULT_CONFIG) + try: + saved = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + # Merge avec les valeurs par défaut pour les nouvelles clés + merged = {**DEFAULT_CONFIG, **saved} + merged["serverFields"] = {**DEFAULT_CONFIG["serverFields"], **saved.get("serverFields", {})} + merged["memberFields"] = {**DEFAULT_CONFIG["memberFields"], **saved.get("memberFields", {})} + merged["founderRoles"] = saved.get("founderRoles", DEFAULT_CONFIG["founderRoles"]) + merged["outputDir"] = saved.get("outputDir", DEFAULT_CONFIG["outputDir"]) + return merged + except Exception as e: + logger.error(f"Erreur lecture config : {e}") + return dict(DEFAULT_CONFIG) + + +def save_config(config: dict) -> None: + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8") + + +# ─── Historique des générations ────────────────────────────────────────────── +MAX_HISTORY = 20 # nombre max d'entrées conservées + +def _load_history() -> list: + if not HISTORY_FILE.exists(): + return [] + try: + return json.loads(HISTORY_FILE.read_text(encoding="utf-8")) + except Exception: + return [] + +def _save_history(history: list) -> None: + HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + HISTORY_FILE.write_text(json.dumps(history, indent=2, ensure_ascii=False), encoding="utf-8") + +def log_history(result: dict) -> None: + """Ajoute une entrée dans l'historique après une génération.""" + history = _load_history() + entry = { + "date": datetime.now(timezone.utc).isoformat(), + "trigger": result.get("trigger", "auto"), # "auto" ou "manual" + "serversCount": result["servers"]["count"] if result.get("servers") else None, + "membersCount": result["members"]["count"] if result.get("members") else None, + "guildName": result["members"]["guildName"] if result.get("members") else None, + "errors": result.get("errors", []), + "success": len(result.get("errors", [])) == 0, + } + history.insert(0, entry) + _save_history(history[:MAX_HISTORY]) + +def get_history() -> list: + return _load_history() + + +# ─── Génération servers.json ───────────────────────────────────────────────── + +async def generate_servers_json(bot: discord.Client) -> dict: + config = load_config() + fields = config["serverFields"] + founder_keys = [r.lower() for r in config.get("founderRoles", ["fondateur", "founder"])] + out_dir = Path(config["outputDir"]) + servers_file = out_dir / "servers.json" + servers = [] + + for guild in bot.guilds: + try: + guild = await bot.fetch_guild(guild.id, with_counts=False) + except Exception: + pass # on travaille avec la version cache + + entry = {} + + if fields.get("id"): entry["id"] = str(guild.id) + if fields.get("name"): entry["name"] = guild.name + if fields.get("icon"): entry["icon"] = str(guild.icon.url) if guild.icon else None + if fields.get("banner"): entry["banner"] = str(guild.banner.url) if guild.banner else None + if fields.get("description"): entry["description"] = guild.description + if fields.get("createdAt"): entry["createdAt"] = guild.created_at.isoformat() + if fields.get("boostLevel"): entry["boostLevel"] = guild.premium_tier + if fields.get("boostCount"): entry["boostCount"] = guild.premium_subscription_count + if fields.get("verificationLevel"): + entry["verificationLevel"] = str(guild.verification_level) + + if fields.get("memberCount"): + try: + members = [m async for m in guild.fetch_members(limit=None)] + entry["memberCount"] = sum(1 for m in members if not m.bot) + except Exception: + entry["memberCount"] = guild.member_count + + if fields.get("owner"): + founders = [] + try: + owner = await guild.fetch_member(guild.owner_id) + founders.append({ + "id": str(owner.id), + "username": owner.name, + "displayName": owner.display_name, + "avatar": str(owner.display_avatar.url), + "isOwner": True, + }) + except Exception: + pass + + # Membres avec un rôle dont le nom correspond à founderRoles + try: + async for member in guild.fetch_members(limit=None): + if member.bot or member.id == guild.owner_id: + continue + for role in member.roles: + if any(key in role.name.lower() for key in founder_keys): + founders.append({ + "id": str(member.id), + "username": member.name, + "displayName": member.display_name, + "avatar": str(member.display_avatar.url), + "isOwner": False, + }) + break + except Exception: + pass + + entry["founders"] = founders + + servers.append(entry) + + output = { + "generatedAt": datetime.now(timezone.utc).isoformat(), + "count": len(servers), + "servers": servers, + } + + out_dir.mkdir(parents=True, exist_ok=True) + servers_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8") + logger.info(f"servers.json généré → {servers_file} ({len(servers)} serveurs)") + return output + + +# ─── Génération members.json ───────────────────────────────────────────────── + +async def generate_members_json(bot: discord.Client, guild_id: Optional[int] = None) -> dict: + config = load_config() + fields = config["memberFields"] + target_id = guild_id or (int(config["targetGuildId"]) if config["targetGuildId"] else None) + out_dir = Path(config["outputDir"]) + members_file = out_dir / "members.json" + + if not target_id: + raise ValueError("Aucun targetGuildId configuré. Utilise /json-config set-guild ") + + guild = bot.get_guild(target_id) or await bot.fetch_guild(target_id) + if not guild: + raise ValueError(f"Serveur introuvable : {target_id}") + + members_list = [] + async for member in guild.fetch_members(limit=None): + if member.bot: + continue + + entry = {} + + if fields.get("id"): entry["id"] = str(member.id) + if fields.get("username"): entry["username"] = member.name + if fields.get("displayName"): entry["displayName"] = member.display_name + if fields.get("globalName"): entry["globalName"] = member.global_name or member.name + if fields.get("avatar"): + entry["avatar"] = str(member.display_avatar.url) + entry["globalAvatar"] = str(member.avatar.url) if member.avatar else str(member.default_avatar.url) + if fields.get("joinedAt"): entry["joinedAt"] = member.joined_at.isoformat() if member.joined_at else None + if fields.get("createdAt"): entry["createdAt"] = member.created_at.isoformat() + if fields.get("isBot"): entry["isBot"] = member.bot + if fields.get("bio"): entry["bio"] = None # Non accessible via l'API Bot officielle + if fields.get("accentColor"): entry["accentColor"] = None # Nécessite fetch user OAuth2 + + if fields.get("roles"): + entry["roles"] = [ + { + "id": str(r.id), + "name": r.name, + "color": str(r.color), + "position": r.position, + "hoist": r.hoist, + } + for r in sorted(member.roles, key=lambda r: -r.position) + if r.id != guild.id # exclut @everyone + ] + + if fields.get("badges"): + entry["badges"] = [flag.name for flag in member.public_flags.all()] + + members_list.append(entry) + + output = { + "generatedAt": datetime.now(timezone.utc).isoformat(), + "guildId": str(guild.id), + "guildName": guild.name, + "count": len(members_list), + "members": members_list, + } + + out_dir.mkdir(parents=True, exist_ok=True) + members_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8") + logger.info(f"members.json généré → {members_file} ({len(members_list)} membres)") + return output + + +# ─── Génération complète ───────────────────────────────────────────────────── + +async def generate_all(bot: discord.Client, trigger: str = "auto") -> dict: + results = {"servers": None, "members": None, "errors": [], "trigger": trigger} + try: + results["servers"] = await generate_servers_json(bot) + except Exception as e: + results["errors"].append(f"servers: {e}") + logger.error(f"Erreur génération servers.json : {e}") + try: + results["members"] = await generate_members_json(bot) + except Exception as e: + results["errors"].append(f"members: {e}") + logger.error(f"Erreur génération members.json : {e}") + + log_history(results) + return results + + +# ─── Prévisualisation (sans écriture fichier) ──────────────────────────────── + +async def preview_servers(bot: discord.Client, limit: int = 3) -> list: + """Retourne un aperçu de N serveurs sans écrire le fichier.""" + config = load_config() + fields = config["serverFields"] + founder_keys = [r.lower() for r in config.get("founderRoles", ["fondateur"])] + results = [] + + for guild in list(bot.guilds)[:limit]: + entry = { + "id": str(guild.id), + "name": guild.name, + "icon": str(guild.icon.url) if guild.icon else None, + } + if fields.get("memberCount"): + try: + members = [m async for m in guild.fetch_members(limit=None)] + entry["memberCount"] = sum(1 for m in members if not m.bot) + except Exception: + entry["memberCount"] = guild.member_count + results.append(entry) + return results + + +async def preview_members(bot: discord.Client, limit: int = 5) -> dict: + """Retourne un aperçu de N membres sans écrire le fichier.""" + config = load_config() + target_id = int(config["targetGuildId"]) if config["targetGuildId"] else None + if not target_id: + raise ValueError("Aucun targetGuildId configuré.") + + guild = bot.get_guild(target_id) or await bot.fetch_guild(target_id) + preview = [] + count = 0 + async for member in guild.fetch_members(limit=None): + if member.bot: + continue + preview.append({ + "id": str(member.id), + "username": member.name, + "displayName": member.display_name, + "avatar": str(member.display_avatar.url), + "roles": [r.name for r in member.roles if r.id != guild.id], + }) + count += 1 + if count >= limit: + break + return {"guildName": guild.name, "totalFetched": limit, "members": preview}