V1.0 de l'intégration de la feature de récupération automatique des informations des agents O.K sur le siteweb

This commit is contained in:
Mathis 2026-03-01 01:01:15 +01:00
parent d116bb4141
commit fe05572549
3 changed files with 858 additions and 1 deletions

3
bot.py
View file

@ -67,7 +67,8 @@ class MyBot(commands.Bot):
"commandes.ticket_whitelist", "commandes.ticket_whitelist",
"commandes.bug_report", "commandes.bug_report",
"commandes.convocation", "commandes.convocation",
"commandes.absence_staff" "commandes.absence_staff",
"commandes.website_sync",
] ]
for extension in extensions: for extension in extensions:

476
commandes/website_sync.py Normal file
View file

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

380
src/json_export_service.py Normal file
View file

@ -0,0 +1,380 @@
"""
Service d'export JSON pour le site web.
Génère deux fichiers :
- servers.json : tous les serveurs 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 <id>")
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}