""" 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 disnake from disnake.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 (1 heure) ──────────────────────────── @tasks.loop(hours=1) async def daily_export(self): """Génère les JSON toutes les heures.""" logger.info("[JSON Export] Génération automatique 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] Mode Test actif → génération automatique toutes les 30 secondes." ) # ── Helper : embed de réponse ───────────────────────────────────────────── @staticmethod def _embed(title: str, color: disnake.Color, description: str = "") -> disnake.Embed: e = disnake.Embed(title=title, color=color, timestamp=datetime.now(timezone.utc)) if description: e.description = description return e # ══════════════════════════════════════════════════════════════════════════ # /json-generate # ══════════════════════════════════════════════════════════════════════════ @commands.slash_command(name="json-generate", description="Génère manuellement les fichiers JSON du site web") @commands.has_permissions(administrator=True) async def json_generate(self, interaction: disnake.ApplicationCommandInteraction, cible: str = commands.Param("all", description="Quoi générer ?", choices={ "Tout (servers + members)": "all", "Serveurs uniquement": "servers", "Membres uniquement": "members" })): await interaction.response.defer(ephemeral=True) start = datetime.now() embed = self._embed("⏳ Génération en cours...", disnake.Color.yellow()) await interaction.followup.send(embed=embed) try: if cible == "servers": res = await generate_servers_json(self.bot) embed = self._embed("✅ servers.json généré", disnake.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é", disnake.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", disnake.Color.orange() if has_errors else disnake.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", disnake.Color.red(), f"```{e}```") await interaction.edit_original_response(embed=embed) # ══════════════════════════════════════════════════════════════════════════ # /json-status # ══════════════════════════════════════════════════════════════════════════ @commands.slash_command(name="json-status", description="État des fichiers JSON et prochaine génération auto") @commands.has_permissions(administrator=True) async def json_status(self, interaction: disnake.ApplicationCommandInteraction): await interaction.response.defer(ephemeral=True) config = load_config() embed = self._embed("📊 Statut JSON Export", disnake.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) # ══════════════════════════════════════════════════════════════════════════ @commands.slash_command(name="json-config", description="Configure la génération automatique des JSON") @commands.has_permissions(administrator=True) async def json_config_group(self, interaction: disnake.ApplicationCommandInteraction): pass # ── show ────────────────────────────────────────────────────────────────── @json_config_group.sub_command(name="show", description="Affiche la configuration actuelle") async def config_show(self, interaction: disnake.ApplicationCommandInteraction): 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", disnake.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.sub_command(name="set-guild", description="Définit l'ID du serveur cible pour members.json") async def config_set_guild(self, interaction: disnake.ApplicationCommandInteraction, guild_id: str = commands.Param(description="ID du serveur Discord")): 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", disnake.Color.green(), f"**{guild.name}** (`{guild_id}`) sera utilisé pour members.json.", ) except Exception: embed = self._embed( "❌ Serveur introuvable", disnake.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.sub_command(name="set-schedule", description="Définit l'heure de génération automatique quotidienne") async def config_set_schedule(self, interaction: disnake.ApplicationCommandInteraction, heure: int = commands.Param(description="Heure (0-23)"), minute: int = commands.Param(0, description="Minute (0-59, défaut: 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", disnake.Color.green(), f"Prochaine génération auto : **{heure:02d}:{minute:02d}** chaque jour.", ) await interaction.followup.send(embed=embed) # ── server-field ───────────────────────────────────────────────────────── @json_config_group.sub_command(name="server-field", description="Active/désactive un champ dans servers.json") async def config_server_field(self, interaction: disnake.ApplicationCommandInteraction, champ: str = commands.Param(description="Champ à modifier", choices=SERVER_FIELDS), actif: bool = commands.Param(description="true = inclus, false = exclu")): 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", disnake.Color.green(), f"`{champ}` est maintenant **{state}** de servers.json.") await interaction.followup.send(embed=embed) # ── member-field ───────────────────────────────────────────────────────── @json_config_group.sub_command(name="member-field", description="Active/désactive un champ dans members.json") async def config_member_field(self, interaction: disnake.ApplicationCommandInteraction, champ: str = commands.Param(description="Champ à modifier", choices=MEMBER_FIELDS), actif: bool = commands.Param(description="true = inclus, false = exclu")): 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", disnake.Color.green(), f"`{champ}` est maintenant **{state}** de members.json.") await interaction.followup.send(embed=embed) # ── reset ───────────────────────────────────────────────────────────────── @json_config_group.sub_command(name="reset", description="Remet la configuration par défaut") async def config_reset(self, interaction: disnake.ApplicationCommandInteraction): 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", disnake.Color.green(), "Tous les paramètres ont été remis à leurs valeurs par défaut.") await interaction.followup.send(embed=embed) # ── set-output-dir ──────────────────────────────────────────────────────── @json_config_group.sub_command(name="set-output-dir", description="Change le dossier de sortie des fichiers JSON") async def config_set_output_dir(self, interaction: disnake.ApplicationCommandInteraction, chemin: str = commands.Param(description="Chemin absolu ou relatif vers le dossier (ex: /var/www/site/data)")): 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", disnake.Color.green(), f"Les JSON seront désormais écrits dans :\n`{p.resolve()}`", ) except Exception as e: embed = self._embed("❌ Erreur", disnake.Color.red(), f"Impossible d'utiliser ce chemin :\n```{e}```") await interaction.followup.send(embed=embed) # ── add-founder-role ────────────────────────────────────────────────────── @json_config_group.sub_command(name="add-founder-role", description="Ajoute un nom de rôle reconnu comme 'fondateur'") async def config_add_founder_role(self, interaction: disnake.ApplicationCommandInteraction, nom: str = commands.Param(description="Nom du rôle (insensible à la casse, correspondance partielle)")): 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", disnake.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é", disnake.Color.green(), f"`{nom_l}` ajouté. Liste actuelle : {', '.join(f'`{r}`' for r in roles)}", ) await interaction.followup.send(embed=embed) # ── remove-founder-role ─────────────────────────────────────────────────── @json_config_group.sub_command(name="remove-founder-role", description="Retire un nom de rôle fondateur") async def config_remove_founder_role(self, interaction: disnake.ApplicationCommandInteraction, nom: str = commands.Param(description="Nom du rôle à retirer")): 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", disnake.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é", disnake.Color.green(), f"`{nom_l}` retiré. Liste actuelle : {reste}", ) await interaction.followup.send(embed=embed) # ══════════════════════════════════════════════════════════════════════════ # /json-preview # ══════════════════════════════════════════════════════════════════════════ @commands.slash_command(name="json-preview", description="Aperçu des données sans écrire les fichiers") @commands.has_permissions(administrator=True) async def json_preview(self, interaction: disnake.ApplicationCommandInteraction, cible: str = commands.Param("servers", description="Quoi prévisualiser ?", choices={"Serveurs": "servers", "Membres": "members"}), limite: int = commands.Param(3, description="Nombre d'éléments à afficher (1-10, défaut: 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}", disnake.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", disnake.Color.red(), f"```{e}```") await interaction.followup.send(embed=embed, ephemeral=True) # ══════════════════════════════════════════════════════════════════════════ # /json-history # ══════════════════════════════════════════════════════════════════════════ @commands.slash_command(name="json-history", description="Historique des dernières générations automatiques et manuelles") @commands.has_permissions(administrator=True) async def json_history(self, interaction: disnake.ApplicationCommandInteraction, limite: int = commands.Param(5, description="Nombre d'entrées à afficher (défaut: 5, max: 10)")): await interaction.response.defer(ephemeral=True) limite = max(1, min(limite, 10)) history = get_history() embed = self._embed("📜 Historique des générations JSON", disnake.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): await bot.add_cog(WebsiteSync(bot)) logger.info("Cog WebsiteSync chargé.")