""" 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é.")