Kuby/commandes/website_sync.py

461 lines
27 KiB
Python
Raw Permalink 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
2026-05-06 17:07:09 +02:00
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
2026-05-06 17:07:09 +02:00
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
# ══════════════════════════════════════════════════════════════════════════
2026-05-06 17:07:09 +02:00
@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()
2026-05-06 17:07:09 +02:00
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)
2026-05-06 17:07:09 +02:00
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)
2026-05-06 17:07:09 +02:00
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",
2026-05-06 17:07:09 +02:00
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:
2026-05-06 17:07:09 +02:00
embed = self._embed("❌ Erreur", disnake.Color.red(), f"```{e}```")
await interaction.edit_original_response(embed=embed)
# ══════════════════════════════════════════════════════════════════════════
# /json-status
# ══════════════════════════════════════════════════════════════════════════
2026-05-06 17:07:09 +02:00
@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()
2026-05-06 17:07:09 +02:00
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)
# ══════════════════════════════════════════════════════════════════════════
2026-05-06 17:07:09 +02:00
@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 ──────────────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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_"
2026-05-06 17:07:09 +02:00
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 ────────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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(
2026-05-06 17:07:09 +02:00
"✅ Serveur cible défini", disnake.Color.green(),
f"**{guild.name}** (`{guild_id}`) sera utilisé pour members.json.",
)
except Exception:
embed = self._embed(
2026-05-06 17:07:09 +02:00
"❌ 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 ─────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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(
2026-05-06 17:07:09 +02:00
"✅ Horaire mis à jour", disnake.Color.green(),
f"Prochaine génération auto : **{heure:02d}:{minute:02d}** chaque jour.",
)
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed)
# ── server-field ─────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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 ❌"
2026-05-06 17:07:09 +02:00
embed = self._embed("✅ Champ servers.json mis à jour", disnake.Color.green(),
f"`{champ}` est maintenant **{state}** de servers.json.")
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed)
# ── member-field ─────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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 ❌"
2026-05-06 17:07:09 +02:00
embed = self._embed("✅ Champ members.json mis à jour", disnake.Color.green(),
f"`{champ}` est maintenant **{state}** de members.json.")
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed)
# ── reset ─────────────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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
2026-05-06 17:07:09 +02:00
embed = self._embed("✅ Configuration réinitialisée", disnake.Color.green(),
"Tous les paramètres ont été remis à leurs valeurs par défaut.")
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed)
# ── set-output-dir ────────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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(
2026-05-06 17:07:09 +02:00
"✅ Dossier de sortie mis à jour", disnake.Color.green(),
f"Les JSON seront désormais écrits dans :\n`{p.resolve()}`",
)
except Exception as e:
2026-05-06 17:07:09 +02:00
embed = self._embed("❌ Erreur", disnake.Color.red(), f"Impossible d'utiliser ce chemin :\n```{e}```")
await interaction.followup.send(embed=embed)
# ── add-founder-role ──────────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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]:
2026-05-06 17:07:09 +02:00
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(
2026-05-06 17:07:09 +02:00
"✅ Rôle fondateur ajouté", disnake.Color.green(),
f"`{nom_l}` ajouté. Liste actuelle : {', '.join(f'`{r}`' for r in roles)}",
)
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed)
# ── remove-founder-role ───────────────────────────────────────────────────
2026-05-06 17:07:09 +02:00
@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):
2026-05-06 17:07:09 +02:00
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(
2026-05-06 17:07:09 +02:00
"✅ Rôle fondateur retiré", disnake.Color.green(),
f"`{nom_l}` retiré. Liste actuelle : {reste}",
)
2026-05-06 17:07:09 +02:00
await interaction.followup.send(embed=embed)
# ══════════════════════════════════════════════════════════════════════════
# /json-preview
# ══════════════════════════════════════════════════════════════════════════
2026-05-06 17:07:09 +02:00
@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:
2026-05-06 17:07:09 +02:00
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:
2026-05-06 17:07:09 +02:00
embed = self._embed("❌ Erreur", disnake.Color.red(), f"```{e}```")
await interaction.followup.send(embed=embed, ephemeral=True)
# ══════════════════════════════════════════════════════════════════════════
# /json-history
# ══════════════════════════════════════════════════════════════════════════
2026-05-06 17:07:09 +02:00
@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()
2026-05-06 17:07:09 +02:00
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 ───────────────────────────────────────────────────────────────────
def setup(bot):
bot.add_cog(WebsiteSync(bot))
logger.info("Cog WebsiteSync chargé.")