Kuby/commandes/website_sync.py

474 lines
27 KiB
Python
Raw Normal View History

"""
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 <id> Définit le serveur cible (members.json)
/json-config set-schedule <h> [m] Heure de génération auto
/json-config set-output-dir <chemin> Changer le dossier de sortie
/json-config add-founder-role <nom> Ajouter un nom de rôle fondateur
/json-config remove-founder-role <nom> Retirer un nom de rôle fondateur
/json-config server-field <champ> <actif> Active/désactive un champ servers.json
/json-config member-field <champ> <actif> 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 (30s pour test) ────────────────────────────
@tasks.loop(seconds=30)
async def daily_export(self):
"""Génère les JSON toutes les 30 secondes (Mode Test)."""
logger.info("[JSON Export] Génération automatique (toutes les 30s) 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: 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 s'il n'est pas déjà présent
if not bot.tree.get_command("json-config"):
bot.tree.add_command(cog.json_config_group)
logger.info("Cog WebsiteSync chargé.")