From 6372014c995acd4192f660cdb8dc9f5ff73a3cc2 Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 27 Feb 2026 12:26:52 +0100 Subject: [PATCH 1/6] Correction du bug "wl is not found" --- commandes/convocation.py | 104 +++++++++++++++++++++++++++++++++++++++ commandes/whitelist.py | 71 +++++++++++++++++++------- 2 files changed, 158 insertions(+), 17 deletions(-) create mode 100755 commandes/convocation.py diff --git a/commandes/convocation.py b/commandes/convocation.py new file mode 100755 index 0000000..3e38c73 --- /dev/null +++ b/commandes/convocation.py @@ -0,0 +1,104 @@ +import discord +from discord.ext import commands +from discord import app_commands + +class Convocation(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @app_commands.command( + name="convocation", + description="Convocation d’un membre" + ) + @app_commands.describe( + membre="Le membre à convoquer", + raison="La raison de la convocation", + heure="L’heure de la convocation" + ) + @app_commands.checks.has_permissions(manage_roles=True) + async def convocation( + self, + interaction: discord.Interaction, + membre: discord.Member, + raison: str, + heure: str + ): + role_convocation_id = 1388607106558726204 + log_user_ids = [ + 1195440742307467377, + 689550504963080328, + 1359253046055534775 + ] + log_channel_id = 1389747212837322852 + + await interaction.response.defer(ephemeral=True) + + try: + roles_to_remove = [ + r for r in membre.roles if r.id != interaction.guild.id + ] + await membre.remove_roles(*roles_to_remove) + role_convocation = interaction.guild.get_role(role_convocation_id) + if role_convocation: + await membre.add_roles(role_convocation) + + embed_convocation = discord.Embed( + title="CONVOCATION | 🔖", + description=f"> Bonjour <@{membre.id}>, vous êtes **convoqué** par <@{interaction.user.id}>. Voici les **détails** :", + color=0x0099ff + ) + embed_convocation.add_field(name="**Raison de la convocation :**", value=f"➜ {raison}", inline=False) + embed_convocation.add_field(name="**Heure de la convocation :**", value=f"➜ {heure}", inline=False) + embed_convocation.add_field(name="\u200B", value=( + "> Merci de vous présenter dans le vocal attente moov **5 minutes** avant l’heure. " + "Soyez **professionnel**, et en cas d’absence, merci de contacter la personne vous ayant convoqué.\n\n" + "> La **levée** de suspension se fera uniquement à la **fin** de la convocation." + ), inline=False) + embed_convocation.add_field(name="**__Bien cordialement__**", value="_La Gestion de Alésia | 📑_", inline=False) + + embed_log = discord.Embed( + title="📋 CONVOCATION ENVOYÉE", + color=0xff9900 + ) + embed_log.add_field(name="👤 Membre convoqué", value=f"<@{membre.id}> ({membre.name}#{membre.discriminator})", inline=True) + embed_log.add_field(name="👮 Convoqué par", value=f"<@{interaction.user.id}> ({interaction.user.name}#{interaction.user.discriminator})", inline=True) + embed_log.add_field(name="📄 Raison", value=raison, inline=False) + embed_log.add_field(name="🕒 Heure", value=heure, inline=False) + embed_log.add_field(name="📆 Date", value=f"", inline=False) + embed_log.set_footer(text="Gestion des convocations", icon_url=self.bot.user.display_avatar.url) + + # MP au membre + dm_sent = False + try: + await membre.send(embed=embed_convocation) + dm_sent = True + except: + dm_sent = False + + await interaction.edit_original_response(content=( + f"✅ Convocation envoyée à {membre.mention}." if dm_sent else + f"⚠️ Convocation enregistrée, mais impossible d'envoyer un MP à {membre.mention}." + )) + + # Log aux utilisateurs + for user_id in log_user_ids: + try: + user = await self.bot.fetch_user(user_id) + await user.send(embed=embed_log) + except Exception as e: + print(f"Erreur lors de l'envoi du log à {user_id} : {e}") + + # Log dans le salon + try: + log_channel = await self.bot.fetch_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + await log_channel.send(embed=embed_log) + except Exception as e: + print(f"Erreur lors de l'envoi du log dans le salon : {e}") + + except Exception as e: + print(f"Erreur dans la commande /convocation : {e}") + await interaction.edit_original_response(content="❌ Une erreur est survenue.") + +async def setup(bot): + await bot.add_cog(Convocation(bot)) diff --git a/commandes/whitelist.py b/commandes/whitelist.py index 0c33327..5d7bcc2 100755 --- a/commandes/whitelist.py +++ b/commandes/whitelist.py @@ -7,7 +7,7 @@ from typing import List WHITELIST_FILE = "data/whitelist.json" -class Whitelist(commands.GroupCog, name="whitelist"): +class Whitelist(commands.Cog): def __init__(self, bot): self.bot = bot self.whitelist = self.load_whitelist() @@ -32,58 +32,95 @@ class Whitelist(commands.GroupCog, name="whitelist"): self.whitelist[str(guild_id)] = users self.save_whitelist() + @commands.hybrid_group(name="whitelist", aliases=["wl"], description="Gère la whitelist du serveur") + async def whitelist_group(self, ctx): + """Groupe de commandes pour gérer la whitelist""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + # ✅ Ajouter un utilisateur - @app_commands.command(name="ajouter", description="Ajoute un utilisateur à la whitelist") + @whitelist_group.command(name="ajouter", description="Ajoute un utilisateur à la whitelist") @app_commands.describe(utilisateur="L'utilisateur à ajouter") - async def ajouter(self, interaction: discord.Interaction, utilisateur: discord.User): - guild_id = interaction.guild.id + async def ajouter(self, ctx, utilisateur: discord.User): + interaction = ctx.interaction + guild_id = ctx.guild.id users = self._get_guild_whitelist(guild_id) user_id = str(utilisateur.id) if user_id in users: - await interaction.response.send_message(f"🔒 {utilisateur.mention} est déjà dans la whitelist.", ephemeral=True) + msg = f"🔒 {utilisateur.mention} est déjà dans la whitelist." + if interaction: + await interaction.response.send_message(msg, ephemeral=True) + else: + await ctx.send(msg) return users.append(user_id) self._set_guild_whitelist(guild_id, users) - await interaction.response.send_message(f"✅ {utilisateur.mention} a été ajouté à la whitelist.", ephemeral=True) + msg = f"✅ {utilisateur.mention} a été ajouté à la whitelist." + if interaction: + await interaction.response.send_message(msg, ephemeral=True) + else: + await ctx.send(msg) # ❌ Retirer un utilisateur - @app_commands.command(name="retirer", description="Retire un utilisateur de la whitelist") + @whitelist_group.command(name="retirer", description="Retire un utilisateur de la whitelist") @app_commands.describe(utilisateur="L'utilisateur à retirer") - async def retirer(self, interaction: discord.Interaction, utilisateur: discord.User): - guild_id = interaction.guild.id + async def retirer(self, ctx, utilisateur: discord.User): + interaction = ctx.interaction + guild_id = ctx.guild.id users = self._get_guild_whitelist(guild_id) user_id = str(utilisateur.id) if user_id not in users: - await interaction.response.send_message(f"⚠️ {utilisateur.mention} n'est pas dans la whitelist.", ephemeral=True) + msg = f"⚠️ {utilisateur.mention} n'est pas dans la whitelist." + if interaction: + await interaction.response.send_message(msg, ephemeral=True) + else: + await ctx.send(msg) return users.remove(user_id) self._set_guild_whitelist(guild_id, users) - await interaction.response.send_message(f"✅ {utilisateur.mention} a été retiré de la whitelist.", ephemeral=True) + msg = f"✅ {utilisateur.mention} a été retiré de la whitelist." + if interaction: + await interaction.response.send_message(msg, ephemeral=True) + else: + await ctx.send(msg) # 📋 Voir la whitelist - @app_commands.command(name="liste", description="Affiche les utilisateurs de la whitelist") - async def liste(self, interaction: discord.Interaction): - guild_id = interaction.guild.id + @whitelist_group.command(name="liste", description="Affiche les utilisateurs de la whitelist") + async def liste(self, ctx): + interaction = ctx.interaction + guild_id = ctx.guild.id users = self._get_guild_whitelist(guild_id) if not users: - await interaction.response.send_message("📭 La whitelist est vide pour ce serveur.", ephemeral=True) + msg = "📭 La whitelist est vide pour ce serveur." + if interaction: + await interaction.response.send_message(msg, ephemeral=True) + else: + await ctx.send(msg) return + # Defer if interaction because fetch_user might take time + if interaction: + await interaction.response.defer(ephemeral=True) + membres = [] for user_id in users: try: utilisateur = await self.bot.fetch_user(int(user_id)) - membres.append(f"- {utilisateur.mention} ({utilisateur.name}#{utilisateur.discriminator})") + membres.append(f"- {utilisateur.mention} ({utilisateur.name})") except discord.NotFound: membres.append(f"- <@{user_id}> (Utilisateur introuvable)") texte = "\n".join(membres) - await interaction.response.send_message(f"📃 **Utilisateurs whitelistés pour ce serveur :**\n{texte}", ephemeral=True) + msg = f"📃 **Utilisateurs whitelistés pour ce serveur :**\n{texte}" + if interaction: + await interaction.followup.send(msg, ephemeral=True) + else: + await ctx.send(msg) def is_whitelisted(self, guild_id: int, user_id: int) -> bool: """Check if a user is whitelisted for a specific guild.""" From cc94e85105138846a6c69a73c2ca22f2d119abfb Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 27 Feb 2026 14:18:01 +0100 Subject: [PATCH 2/6] =?UTF-8?q?Ajout=20du=20syst=C3=A8me=20de=20convocatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 3 +- commandes/convocation.py | 311 ++++++++++++++++++++++++++------- data/convocation_backups.json | 5 + data/convocation_settings.json | 11 ++ 4 files changed, 264 insertions(+), 66 deletions(-) create mode 100644 data/convocation_backups.json create mode 100644 data/convocation_settings.json diff --git a/bot.py b/bot.py index 73f338c..ba73ea8 100755 --- a/bot.py +++ b/bot.py @@ -65,7 +65,8 @@ class MyBot(commands.Bot): "commandes.modules_security.logs_manager", "commandes.modules_security.rules", "commandes.ticket_whitelist", - "commandes.bug_report" + "commandes.bug_report", + "commandes.convocation" ] for extension in extensions: diff --git a/commandes/convocation.py b/commandes/convocation.py index 3e38c73..d38f884 100755 --- a/commandes/convocation.py +++ b/commandes/convocation.py @@ -1,104 +1,285 @@ import discord from discord.ext import commands from discord import app_commands +import json +import os +from typing import List, Optional, Dict +import datetime + +# --- CONFIGURATION --- +CONV_SETTINGS_FILE = "data/convocation_settings.json" +CONV_BACKUPS_FILE = "data/convocation_backups.json" + +def load_json(filepath: str) -> Dict: + if not os.path.exists(filepath): + return {} + try: + with open(filepath, "r", encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return {} + +def save_json(filepath: str, data: Dict): + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, "w", encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) class Convocation(commands.Cog): def __init__(self, bot): self.bot = bot + self.settings = load_json(CONV_SETTINGS_FILE) + self.backups = load_json(CONV_BACKUPS_FILE) - @app_commands.command( - name="convocation", - description="Convocation d’un membre" - ) + def get_guild_settings(self, guild_id: int) -> Dict: + return self.settings.get(str(guild_id), {}) + + def save_guild_settings(self, guild_id: int, guild_settings: Dict): + self.settings[str(guild_id)] = guild_settings + save_json(CONV_SETTINGS_FILE, self.settings) + + def get_backup(self, guild_id: int, user_id: int) -> Optional[Dict]: + key = f"{guild_id}_{user_id}" + return self.backups.get(key) + + def save_backup(self, guild_id: int, user_id: int, backup_data: Dict): + key = f"{guild_id}_{user_id}" + self.backups[key] = backup_data + save_json(CONV_BACKUPS_FILE, self.backups) + + def clear_backup(self, guild_id: int, user_id: int): + key = f"{guild_id}_{user_id}" + if key in self.backups: + del self.backups[key] + save_json(CONV_BACKUPS_FILE, self.backups) + + @commands.hybrid_group(name="convocation", description="Gère les convocations de membres") + @commands.has_permissions(manage_roles=True) + async def convocation_group(self, ctx): + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @convocation_group.command(name="convoquer", description="Convoque un membre et verrouille ses accès") @app_commands.describe( membre="Le membre à convoquer", raison="La raison de la convocation", - heure="L’heure de la convocation" + heure="L'heure de la convocation (ex: 14:30)" ) - @app_commands.checks.has_permissions(manage_roles=True) - async def convocation( - self, - interaction: discord.Interaction, - membre: discord.Member, - raison: str, - heure: str - ): - role_convocation_id = 1388607106558726204 - log_user_ids = [ - 1195440742307467377, - 689550504963080328, - 1359253046055534775 - ] - log_channel_id = 1389747212837322852 + async def convoquer(self, ctx, membre: discord.Member, raison: str, heure: str): + guild_id = ctx.guild.id + settings = self.get_guild_settings(guild_id) + role_conv_id = settings.get("role_id") + log_channel_id = settings.get("log_channel_id") + attente_id = settings.get("attente_id") # Salon d'attente à ignorer + admin_ids = settings.get("admin_ids", []) - await interaction.response.defer(ephemeral=True) + if not role_conv_id: + await ctx.send("❌ Le rôle de convocation n'est pas configuré. Utilisez `/convocation config`.", ephemeral=True) + return + + await ctx.defer(ephemeral=True) try: - roles_to_remove = [ - r for r in membre.roles if r.id != interaction.guild.id - ] - await membre.remove_roles(*roles_to_remove) - role_convocation = interaction.guild.get_role(role_convocation_id) - if role_convocation: - await membre.add_roles(role_convocation) + # --- BACKUP --- + bot_top_role = ctx.guild.me.top_role + backup_data = { + "roles": [r.id for r in membre.roles if not r.is_default() and r.id != role_conv_id and r.position < bot_top_role.position], + "overrides": {} + } - embed_convocation = discord.Embed( + # 1. Backup and Remove current roles + current_roles = [ctx.guild.get_role(rid) for rid in backup_data["roles"] if ctx.guild.get_role(rid)] + if current_roles: + try: + await membre.remove_roles(*current_roles, reason=f"Convocation par {ctx.author}") + except discord.Forbidden: + await ctx.send("⚠️ Je n'ai pas pu retirer certains rôles (Permission refusée).", ephemeral=True) + + # 2. Dynamic Lockdown: Loop through ALL channels + lockdown_count = 0 + for channel in ctx.guild.channels: + # On ignore le salon d'attente et ses parents s'il y en a + if attente_id and (channel.id == attente_id or (hasattr(channel, 'category') and channel.category and channel.category.id == attente_id)): + continue + + # Check existing override for the member + overwrites = channel.overwrites_for(membre) + if not overwrites.is_empty(): + # Save existing override (as dict of pair of permission name and boolean/None) + backup_data["overrides"][str(channel.id)] = {k: v for k, v in dict(overwrites).items() if v is not None} + + # Apply Lockdown override for the member + try: + # Specific override for this member: View Channel = False + await channel.set_permissions(membre, view_channel=False, reason="Convocation Lockdown") + lockdown_count += 1 + except discord.Forbidden: + pass # Skip if we can't touch this channel + + self.save_backup(guild_id, membre.id, backup_data) + + # 3. Add Convocation role + role_conv = ctx.guild.get_role(role_conv_id) + if role_conv: + if role_conv.position >= bot_top_role.position: + await ctx.send(f"⚠️ Je ne peux pas donner le rôle {role_conv.mention} car il est plus haut que moi !", ephemeral=True) + else: + try: + await membre.add_roles(role_conv, reason="Début de convocation") + except discord.Forbidden: + await ctx.send(f"⚠️ Impossible de donner le rôle {role_conv.name} (Permission refusée).", ephemeral=True) + + # 4. Embeds & Logs (Inchangés mais inclus pour la cohérence) + embed_conv = discord.Embed( title="CONVOCATION | 🔖", - description=f"> Bonjour <@{membre.id}>, vous êtes **convoqué** par <@{interaction.user.id}>. Voici les **détails** :", - color=0x0099ff + description=f"> Bonjour {membre.mention}, vous êtes **convoqué** par {ctx.author.mention}. Voici les **détails** :", + color=0x0099ff, + timestamp=discord.utils.utcnow() ) - embed_convocation.add_field(name="**Raison de la convocation :**", value=f"➜ {raison}", inline=False) - embed_convocation.add_field(name="**Heure de la convocation :**", value=f"➜ {heure}", inline=False) - embed_convocation.add_field(name="\u200B", value=( - "> Merci de vous présenter dans le vocal attente moov **5 minutes** avant l’heure. " - "Soyez **professionnel**, et en cas d’absence, merci de contacter la personne vous ayant convoqué.\n\n" + embed_conv.add_field(name="**Raison de la convocation :**", value=f"➜ {raison}", inline=False) + embed_conv.add_field(name="**Heure de la convocation :**", value=f"➜ {heure}", inline=False) + embed_conv.add_field(name="\u200B", value=( + "> Merci de vous présenter dans le vocal attente **5 minutes** avant l'heure.\n\n" "> La **levée** de suspension se fera uniquement à la **fin** de la convocation." ), inline=False) - embed_convocation.add_field(name="**__Bien cordialement__**", value="_La Gestion de Alésia | 📑_", inline=False) + embed_conv.set_footer(text=f"Gestion de {ctx.guild.name}") embed_log = discord.Embed( title="📋 CONVOCATION ENVOYÉE", - color=0xff9900 + color=0xff9900, + timestamp=discord.utils.utcnow() ) - embed_log.add_field(name="👤 Membre convoqué", value=f"<@{membre.id}> ({membre.name}#{membre.discriminator})", inline=True) - embed_log.add_field(name="👮 Convoqué par", value=f"<@{interaction.user.id}> ({interaction.user.name}#{interaction.user.discriminator})", inline=True) + embed_log.add_field(name="👤 Membre convoqué", value=f"{membre.mention} ({membre.id})", inline=True) + embed_log.add_field(name="👮 Convoqué par", value=f"{ctx.author.mention}", inline=True) embed_log.add_field(name="📄 Raison", value=raison, inline=False) embed_log.add_field(name="🕒 Heure", value=heure, inline=False) - embed_log.add_field(name="📆 Date", value=f"", inline=False) - embed_log.set_footer(text="Gestion des convocations", icon_url=self.bot.user.display_avatar.url) - # MP au membre + # DM dm_sent = False try: - await membre.send(embed=embed_convocation) + await membre.send(embed=embed_conv) dm_sent = True - except: + except discord.Forbidden: dm_sent = False - await interaction.edit_original_response(content=( - f"✅ Convocation envoyée à {membre.mention}." if dm_sent else - f"⚠️ Convocation enregistrée, mais impossible d'envoyer un MP à {membre.mention}." - )) - - # Log aux utilisateurs - for user_id in log_user_ids: + # Admin & Channel Logs + for admin_id in admin_ids: try: - user = await self.bot.fetch_user(user_id) - await user.send(embed=embed_log) - except Exception as e: - print(f"Erreur lors de l'envoi du log à {user_id} : {e}") + admin = await self.bot.fetch_user(admin_id) + await admin.send(embed=embed_log) + except: pass - # Log dans le salon - try: - log_channel = await self.bot.fetch_channel(log_channel_id) - if isinstance(log_channel, discord.TextChannel): - await log_channel.send(embed=embed_log) - except Exception as e: - print(f"Erreur lors de l'envoi du log dans le salon : {e}") + if log_channel_id: + chan = ctx.guild.get_channel(log_channel_id) + if chan: await chan.send(embed=embed_log) + + await ctx.send(f"✅ Convocation envoyée à {membre.mention}. ({lockdown_count} salons verrouillés) " + + ("" if dm_sent else "(⚠️ DM impossible)"), ephemeral=True) except Exception as e: - print(f"Erreur dans la commande /convocation : {e}") - await interaction.edit_original_response(content="❌ Une erreur est survenue.") + import traceback + traceback.print_exc() + await ctx.send(f"❌ Une erreur critique est survenue : {e}", ephemeral=True) + + @convocation_group.command(name="lever", description="Lève la convocation d'un membre et restaure ses accès") + @app_commands.describe(membre="Le membre à libérer") + async def lever(self, ctx, membre: discord.Member): + guild_id = ctx.guild.id + settings = self.get_guild_settings(guild_id) + role_conv_id = settings.get("role_id") + + await ctx.defer(ephemeral=True) + + backup_data = self.get_backup(guild_id, membre.id) + if not backup_data: + await ctx.send("⚠️ Aucune donnée de convocation trouvée pour ce membre.", ephemeral=True) + return + + # 1. Remove Convocation role + if role_conv_id: + role_conv = ctx.guild.get_role(role_conv_id) + if role_conv and role_conv in membre.roles: + await membre.remove_roles(role_conv, reason="Fin de convocation") + + # 2. Restore channel overrides + restore_count = 0 + overrides = backup_data.get("overrides", {}) + for channel in ctx.guild.channels: + chan_id_str = str(channel.id) + try: + if chan_id_str in overrides: + # Restore previous override + ov_data = overrides[chan_id_str] + overwrite = discord.PermissionOverwrite(**ov_data) + await channel.set_permissions(membre, overwrite=overwrite, reason="Restauration fin de convocation") + restore_count += 1 + else: + # Clear our lockdown override + # On ne vide que si un override membre existait (sinon on risque de supprimer des perms rôles ?) + # En fait set_permissions(member, overwrite=None) supprime l'entrée PERSONNELLE du membre. + await channel.set_permissions(membre, overwrite=None, reason="Fin de convocation - Cleanup") + except discord.Forbidden: + pass + + # 3. Restore roles + role_ids = backup_data.get("roles", []) + restored_roles = [] + if role_ids: + for rid in role_ids: + role = ctx.guild.get_role(rid) + if role and role.position < ctx.guild.me.top_role.position: + restored_roles.append(role) + + if restored_roles: + await membre.add_roles(*restored_roles, reason="Restauration fin de convocation") + + self.clear_backup(guild_id, membre.id) + + # 4. Notification + embed = discord.Embed( + title="🔓 CONVOCATION TERMINÉE", + description=f"Votre convocation sur **{ctx.guild.name}** est terminée. Vos accès ont été restaurés.", + color=discord.Color.green() + ) + try: + await membre.send(embed=embed) + except: pass + + await ctx.send(f"✅ Convocation levée pour {membre.mention}. Access restaurés.", ephemeral=True) + + @convocation_group.command(name="config", description="Configure le système de convocation") + @app_commands.describe( + role="Le rôle de convocation (Muet/Convocation)", + channel="Le salon de logs", + attente="Salon (ou catégorie) d'attente à laisser visible", + admins="Liste d'IDs d'admins à notifier (séparés par des espaces)" + ) + @commands.has_permissions(administrator=True) + async def config(self, ctx, role: discord.Role, channel: discord.TextChannel = None, attente: discord.abc.GuildChannel = None, admins: str = None): + guild_id = ctx.guild.id + settings = self.get_guild_settings(guild_id) + + settings["role_id"] = role.id + if channel: + settings["log_channel_id"] = channel.id + if attente: + settings["attente_id"] = attente.id + + if admins: + admin_list = [] + for uid in admins.split(): + if uid.isdigit(): + admin_list.append(int(uid)) + settings["admin_ids"] = admin_list + + self.save_guild_settings(guild_id, settings) + + embed = discord.Embed(title="⚙️ Configuration Convocation Mise à jour", color=discord.Color.blue()) + embed.add_field(name="Rôle", value=role.mention) + embed.add_field(name="Salon Logs", value=channel.mention if channel else "Non défini") + embed.add_field(name="Salon/Cat d'attente", value=attente.mention if attente else "Non défini") + embed.add_field(name="Admins notifiés", value=str(len(settings.get("admin_ids", [])))) + + await ctx.send(embed=embed, ephemeral=True) async def setup(bot): await bot.add_cog(Convocation(bot)) diff --git a/data/convocation_backups.json b/data/convocation_backups.json new file mode 100644 index 0000000..b1f3572 --- /dev/null +++ b/data/convocation_backups.json @@ -0,0 +1,5 @@ +{ + "1369669999345537145_1337733859814543410": [ + 1370452086193655840 + ] +} \ No newline at end of file diff --git a/data/convocation_settings.json b/data/convocation_settings.json new file mode 100644 index 0000000..cce21c0 --- /dev/null +++ b/data/convocation_settings.json @@ -0,0 +1,11 @@ +{ + "1369669999345537145": { + "role_id": 1369669999366770723, + "log_channel_id": 1369669999677145103, + "admin_ids": [ + 353942721997832195, + 971446412690722826 + ], + "attente_id": 1369669999677145099 + } +} \ No newline at end of file From d116bb41418b06fc454deae9c86bdf102ca0bd69 Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 27 Feb 2026 17:22:45 +0100 Subject: [PATCH 3/6] =?UTF-8?q?Ajout=20du=20syst=C3=A8me=20d'absence=20sta?= =?UTF-8?q?ff=20(modulaire)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 3 +- commandes/absence_staff.py | 512 +++++++++++++++++++++++++++++ data/absence_settings.json | 6 + data/absences.json | 3 + data/logs/messages_2026-02-27.json | 38 +++ 5 files changed, 561 insertions(+), 1 deletion(-) create mode 100755 commandes/absence_staff.py create mode 100644 data/absence_settings.json create mode 100644 data/absences.json create mode 100644 data/logs/messages_2026-02-27.json diff --git a/bot.py b/bot.py index ba73ea8..a62e47a 100755 --- a/bot.py +++ b/bot.py @@ -66,7 +66,8 @@ class MyBot(commands.Bot): "commandes.modules_security.rules", "commandes.ticket_whitelist", "commandes.bug_report", - "commandes.convocation" + "commandes.convocation", + "commandes.absence_staff" ] for extension in extensions: diff --git a/commandes/absence_staff.py b/commandes/absence_staff.py new file mode 100755 index 0000000..7d83cbf --- /dev/null +++ b/commandes/absence_staff.py @@ -0,0 +1,512 @@ +import discord +from discord.ext import commands, tasks +from discord import app_commands, ui +import json +import asyncio +import os +from datetime import datetime, timezone +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo +from typing import Optional, Dict, Any + +PARIS_TZ = ZoneInfo("Europe/Paris") +DATE_FORMAT = "%d/%m/%Y %H:%M" + +ABSENCE_SETTINGS_FILE = "data/absence_settings.json" +ABSENCE_DATA_FILE = "data/absences.json" +file_lock = asyncio.Lock() + +def load_settings(guild_id: int) -> dict: + """Charge les paramètres d'absence pour un serveur""" + if not os.path.exists(ABSENCE_SETTINGS_FILE): + return {} + try: + with open(ABSENCE_SETTINGS_FILE, "r", encoding='utf-8') as f: + return json.load(f).get(str(guild_id), {}) + except (json.JSONDecodeError, FileNotFoundError): + return {} + +def save_settings(guild_id: int, settings: dict) -> None: + """Sauvegarde les paramètres d'absence""" + data = {} + if os.path.exists(ABSENCE_SETTINGS_FILE): + try: + with open(ABSENCE_SETTINGS_FILE, "r", encoding='utf-8') as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + pass + + data[str(guild_id)] = settings + os.makedirs(os.path.dirname(ABSENCE_SETTINGS_FILE), exist_ok=True) + with open(ABSENCE_SETTINGS_FILE, "w", encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + +async def load_absences() -> dict: + """Charge toutes les absences (tous serveurs confondus)""" + async with file_lock: + if not os.path.exists(ABSENCE_DATA_FILE): + return {} + try: + with open(ABSENCE_DATA_FILE, "r", encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return {} + +async def save_absences(data: dict) -> None: + """Sauvegarde toutes les absences""" + async with file_lock: + os.makedirs(os.path.dirname(ABSENCE_DATA_FILE), exist_ok=True) + temp_file = ABSENCE_DATA_FILE + ".tmp" + with open(temp_file, "w", encoding='utf-8') as f: + json.dump(data, f, indent=4, ensure_ascii=False) + os.replace(temp_file, ABSENCE_DATA_FILE) + +def get_guild_absences(all_absences: dict, guild_id: int) -> dict: + """Filtre les absences pour un serveur spécifique""" + return all_absences.get(str(guild_id), {}) + +def update_guild_absences(all_absences: dict, guild_id: int, guild_data: dict) -> dict: + """Met à jour les absences pour un serveur spécifique""" + all_absences[str(guild_id)] = guild_data + return all_absences + +def get_paris_now(): + """Récupère l'heure actuelle à Paris""" + return datetime.now(PARIS_TZ) + +def parse_date(date_str: str): + """Parse une date au format JJ/MM/AAAA HH:MM en timezone Paris""" + return datetime.strptime(date_str, DATE_FORMAT).replace(tzinfo=PARIS_TZ) + +# --- MODALS & VIEWS --- + +class AbsenceModal(ui.Modal, title="Déclarer une Absence Staff"): + motif = ui.TextInput( + label="Motif de l'absence", + style=discord.TextStyle.paragraph, + placeholder="Ex: Vacances, Raisons personnelles, Travail...", + required=True, + max_length=500 + ) + date_debut = ui.TextInput( + label="Début (JJ/MM/AAAA HH:MM)", + placeholder="Ex: 15/05/2024 08:00", + min_length=16, + max_length=16, + required=True + ) + date_fin = ui.TextInput( + label="Fin (JJ/MM/AAAA HH:MM)", + placeholder="Ex: 20/05/2024 18:00", + min_length=16, + max_length=16, + required=True + ) + + def __init__(self, superieur: Optional[discord.Member] = None): + super().__init__() + self.superieur = superieur + + async def on_submit(self, interaction: discord.Interaction): + try: + debut = parse_date(self.date_debut.value) + fin = parse_date(self.date_fin.value) + except ValueError: + return await interaction.response.send_message(f"❌ Format de date invalide. Utilise le format: `{DATE_FORMAT.replace('%d', 'JJ').replace('%m', 'MM').replace('%Y', 'AAAA').replace('%H', 'HH').replace('%M', 'MM')}`", ephemeral=True) + + if debut > fin: + return await interaction.response.send_message("❌ La date de début doit précéder la date de fin.", ephemeral=True) + + # Vérification si déjà absent + all_abs = await load_absences() + guild_abs = get_guild_absences(all_abs, interaction.guild.id) + + if str(interaction.user.id) in guild_abs: + return await interaction.response.send_message("❌ Tu as déjà déclaré une absence sur ce serveur.", ephemeral=True) + + settings = load_settings(interaction.guild.id) + channel_id = settings.get("absence_channel_id") + + if not channel_id: + return await interaction.response.send_message("❌ Le salon d'absence n'est pas configuré sur ce serveur. Contacte un administrateur.", ephemeral=True) + + channel = interaction.guild.get_channel(int(channel_id)) + if not channel: + return await interaction.response.send_message("❌ Salon d'absence introuvable. Vérifie la configuration.", ephemeral=True) + + duree_delta = fin - debut + duree_jours = duree_delta.days + duree_heures = duree_delta.seconds // 3600 + + duree_str = f"{duree_jours}j {duree_heures}h" if duree_jours > 0 else f"{duree_heures}h" + + now = get_paris_now() + etat = "à venir" if debut > now else "en cours" + + # Message dans le salon + absence_message = await channel.send( + f"# ABSENCE STAFF :\n" + f"- **Pseudo Discord** : {interaction.user.mention}\n" + f"- **Durée prévue** : {duree_str}\n" + f"- **Débute le** : {self.date_debut.value} (Paris)\n" + f"- **Termine le** : {self.date_fin.value} (Paris)\n" + f"- **Motif** : {self.motif.value}\n" + f"- **Mention du supérieur hiérarchique** : {self.superieur.mention if self.superieur else 'Aucun'}" + ) + + # Enregistrement + guild_abs[str(interaction.user.id)] = { + "pseudo": interaction.user.name, + "debut": self.date_debut.value, + "fin": self.date_fin.value, + "motif": self.motif.value, + "superieur": self.superieur.mention if self.superieur else "Aucun", + "message_id": absence_message.id, + "etat": etat + } + + all_abs = update_guild_absences(all_abs, interaction.guild.id, guild_abs) + await save_absences(all_abs) + + # Attribution du rôle si déjà en cours + if etat == "en cours": + role_id = settings.get("absence_role_id") + if role_id: + role = interaction.guild.get_role(int(role_id)) + if role: + try: + await interaction.user.add_roles(role, reason="Début d'absence staff") + except: pass + + await interaction.response.send_message("✅ Absence enregistrée avec succès !", ephemeral=True) + +class AbsenceConfigView(ui.View): + def __init__(self, guild_id: int): + super().__init__(timeout=60) + self.guild_id = guild_id + + @ui.button(label="Définir le Salon", style=discord.ButtonStyle.primary, emoji="📁") + async def set_channel(self, interaction: discord.Interaction, button: ui.Button): + await interaction.response.send_message("Mentionne le salon ici (ou envoie son ID) :", ephemeral=True) + + def check(m): + return m.author == interaction.user and m.channel == interaction.channel + + try: + msg = await interaction.client.wait_for('message', check=check, timeout=30) + channel = None + if msg.channel_mentions: + channel = msg.channel_mentions[0] + elif msg.content.isdigit(): + channel = interaction.guild.get_channel(int(msg.content)) + + if not channel: + return await interaction.followup.send("❌ Salon invalide.", ephemeral=True) + + settings = load_settings(self.guild_id) + settings["absence_channel_id"] = channel.id + save_settings(self.guild_id, settings) + await interaction.followup.send(f"✅ Salon d'absence configuré sur {channel.mention}", ephemeral=True) + try: await msg.delete() + except: pass + except asyncio.TimeoutError: + await interaction.followup.send("❌ Temps écoulé.", ephemeral=True) + + @ui.button(label="Définir le Rôle", style=discord.ButtonStyle.primary, emoji="🎭") + async def set_role(self, interaction: discord.Interaction, button: ui.Button): + await interaction.response.send_message("Mentionne le rôle ici (ou envoie son ID) :", ephemeral=True) + + def check(m): + return m.author == interaction.user and m.channel == interaction.channel + + try: + msg = await interaction.client.wait_for('message', check=check, timeout=30) + role = None + if msg.role_mentions: + role = msg.role_mentions[0] + elif msg.content.isdigit(): + role = interaction.guild.get_role(int(msg.content)) + + if not role: + return await interaction.followup.send("❌ Rôle invalide.", ephemeral=True) + + settings = load_settings(self.guild_id) + settings["absence_role_id"] = role.id + save_settings(self.guild_id, settings) + await interaction.followup.send(f"✅ Rôle d'absence configuré sur **{role.name}**", ephemeral=True) + try: await msg.delete() + except: pass + except asyncio.TimeoutError: + await interaction.followup.send("❌ Temps écoulé.", ephemeral=True) + +class Absence(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.check_absences.start() + + @app_commands.command(name="absence", description="Déclarer une absence staff") + @app_commands.describe(superieur="Le supérieur hiérarchique à prévenir") + async def absence(self, interaction: discord.Interaction, superieur: Optional[discord.Member] = None): + """Déclare une absence via un formulaire""" + await interaction.response.send_modal(AbsenceModal(superieur)) + + @app_commands.command(name="absence_config", description="Configurer le système d'absence (Admin)") + @app_commands.checks.has_permissions(administrator=True) + async def absence_config(self, interaction: discord.Interaction): + """Ouvre le panneau de configuration des absences""" + settings = load_settings(interaction.guild.id) + + embed = discord.Embed( + title="⚙️ Configuration des Absences Staff", + description="Utilisez les boutons ci-dessous pour configurer le système.", + color=discord.Color.blue() + ) + + channel_id = settings.get("absence_channel_id") + role_id = settings.get("absence_role_id") + + embed.add_field( + name="📁 Salon d'affichage", + value=f"<#{channel_id}>" if channel_id else "❌ Non configuré", + inline=True + ) + embed.add_field( + name="🎭 Rôle d'absence", + value=f"<@&{role_id}>" if role_id else "❌ Non configuré", + inline=True + ) + + await interaction.response.send_message(embed=embed, view=AbsenceConfigView(interaction.guild.id), ephemeral=True) + + async def strike_absence_message(self, guild: discord.Guild, channel_id: int, message_id: int): + """Modifie le message d'absence pour le barrer au lieu de le supprimer""" + channel = guild.get_channel(channel_id) + if not channel: + try: + channel = await guild.fetch_channel(channel_id) + except: + return + + try: + msg = await channel.fetch_message(message_id) + content = msg.content + + if "~~" in content: # Déjà barré + return + + lines = content.split("\n") + new_lines = [] + for line in lines: + if line.startswith("# "): + # Conserver le style header mais barrer le texte + new_lines.append(f"# ~~{line[2:].strip()}~~ [TERMINÉ]") + elif line.strip(): + new_lines.append(f"~~{line}~~") + else: + new_lines.append(line) + + await msg.edit(content="\n".join(new_lines)) + except Exception as e: + print(f"Erreur lors du barrage du message d'absence: {e}") + + @app_commands.command(name="fin_absence", description="Terminer son absence staff") + async def fin_absence(self, interaction: discord.Interaction): + """Met fin à l'absence en cours""" + all_abs = await load_absences() + guild_abs = get_guild_absences(all_abs, interaction.guild.id) + user_id = str(interaction.user.id) + + if user_id not in guild_abs: + return await interaction.response.send_message("❌ Tu n'as pas d'absence enregistrée sur ce serveur.", ephemeral=True) + + info = guild_abs[user_id] + message_id = info.get("message_id") + + settings = load_settings(interaction.guild.id) + channel_id = settings.get("absence_channel_id") + + if channel_id and message_id: + await self.strike_absence_message(interaction.guild, int(channel_id), int(message_id)) + + # Retrait du rôle + role_id = settings.get("absence_role_id") + if role_id: + role = interaction.guild.get_role(int(role_id)) + if role: + try: await interaction.user.remove_roles(role, reason="Fin d'absence staff") + except: pass + + del guild_abs[user_id] + all_abs = update_guild_absences(all_abs, interaction.guild.id, guild_abs) + await save_absences(all_abs) + await interaction.response.send_message("✅ Ton absence a été retirée.", ephemeral=True) + + @app_commands.command(name="liste_absences", description="Afficher la liste des absences staff en cours") + async def liste_absences(self, interaction: discord.Interaction): + """Affiche les absences en cours sur le serveur""" + all_abs = await load_absences() + guild_abs = get_guild_absences(all_abs, interaction.guild.id) + + absences = [ + (user_id, info) for user_id, info in guild_abs.items() + if info.get("etat") == "en cours" + ] + + if not absences: + return await interaction.response.send_message("Aucune absence staff en cours sur ce serveur.", ephemeral=True) + + per_page = 5 + pages = [absences[i:i + per_page] for i in range(0, len(absences), per_page)] + + def create_embed(page_index: int) -> discord.Embed: + embed = discord.Embed( + title=f"📋 Absences staff - {interaction.guild.name} ({page_index + 1}/{len(pages)})", + color=discord.Color.orange() + ) + for user_id, info in pages[page_index]: + embed.add_field( + name=info["pseudo"], + value=( + f"**Période** : du {info['debut']} au {info['fin']}\n" + f"**Motif** : {info['motif']}\n" + f"**Supérieur** : {info['superieur']}" + ), + inline=False + ) + embed.set_footer(text="Gestion des absences du staff") + return embed + + class PaginatorView(ui.View): + def __init__(self): + super().__init__(timeout=60) + self.page = 0 + + @ui.button(label="⬅️ Précédent", style=discord.ButtonStyle.secondary, disabled=True) + async def previous(self, interaction_btn: discord.Interaction, button: discord.ui.Button): + self.page -= 1 + self.update_buttons() + await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self) + + @ui.button(label="➡️ Suivant", style=discord.ButtonStyle.secondary, disabled=len(pages) == 1) + async def next(self, interaction_btn: discord.Interaction, button: discord.ui.Button): + self.page += 1 + self.update_buttons() + await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self) + + def update_buttons(self): + self.children[0].disabled = self.page == 0 + self.children[1].disabled = self.page >= len(pages) - 1 + + await interaction.response.send_message(embed=create_embed(0), view=PaginatorView(), ephemeral=True) + + @tasks.loop(minutes=1) + async def check_absences(self): + all_abs = await load_absences() + now = get_paris_now() + changed = False + + for guild_id_str, guild_data in list(all_abs.items()): + guild_id = int(guild_id_str) + guild = self.bot.get_guild(guild_id) + if not guild: + try: + guild = await self.bot.fetch_guild(guild_id) + except: + continue + + settings = load_settings(guild_id) + role_id = settings.get("absence_role_id") + role = guild.get_role(int(role_id)) if role_id else None + + to_remove = [] + for user_id_str, info in guild_data.items(): + try: + debut = parse_date(info["debut"]) + fin = parse_date(info["fin"]) + except Exception as e: + print(f"Erreur parsing date pour {user_id_str}: {e}") + continue + + user_id = int(user_id_str) + member = guild.get_member(user_id) + if not member: + try: + member = await guild.fetch_member(user_id) + except: + pass + + etat = info.get("etat", "en cours") + + # Activation si l'heure est arrivée + if etat == "à venir" and debut <= now: + print(f"Activation absence pour {info['pseudo']} ({user_id_str})") + info["etat"] = "en cours" + changed = True + if member and role: + try: + await member.add_roles(role, reason="Début d'absence staff") + except Exception as e: + print(f"Erreur ajout rôle absence: {e}") + + # Désactivation si l'heure est passée + if fin < now: + print(f"Désactivation absence pour {info['pseudo']} ({user_id_str})") + if member and role: + try: + await member.remove_roles(role, reason="Fin d'absence staff") + except Exception as e: + print(f"Erreur retrait rôle absence: {e}") + + # On barre aussi le message en automatique + message_id = info.get("message_id") + channel_id = settings.get("absence_channel_id") + if channel_id and message_id: + await self.strike_absence_message(guild, int(channel_id), int(message_id)) + + to_remove.append(user_id_str) + changed = True + + for uid in to_remove: + guild_data.pop(uid, None) + + if changed: + await save_absences(all_abs) + + @commands.Cog.listener() + async def on_message(self, message): + if message.author.bot or not message.guild: + return + + all_abs = await load_absences() + guild_abs = get_guild_absences(all_abs, message.guild.id) + + # 1. Rappel à l'auteur s'il est absent + if str(message.author.id) in guild_abs: + if guild_abs[str(message.author.id)].get("etat") == "en cours": + await message.channel.send( + f"{message.author.mention}, tu es actuellement déclaré absent sur ce serveur. Merci de prévenir si tu es de retour !", + delete_after=10 + ) + + # 2. Notification des mentions de staff absents + for user_id_str, info in guild_abs.items(): + if info.get("etat") != "en cours": + continue + + if f"<@{user_id_str}>" in message.content or f"<@!{user_id_str}>" in message.content: + msg_id = info.get("message_id") + settings = load_settings(message.guild.id) + channel_id = settings.get("absence_channel_id") + + absence_url = f"https://discord.com/channels/{message.guild.id}/{channel_id}/{msg_id}" if channel_id and msg_id else "" + + reply_text = f"⚠️ {message.author.mention}, cet utilisateur est actuellement absent." + if absence_url: + reply_text += f"\nPlus d'infos ici : [Voir la déclaration]({absence_url})" + + await message.reply(reply_text, delete_after=30) + +async def setup(bot): + await bot.add_cog(Absence(bot)) diff --git a/data/absence_settings.json b/data/absence_settings.json new file mode 100644 index 0000000..3239d14 --- /dev/null +++ b/data/absence_settings.json @@ -0,0 +1,6 @@ +{ + "1369669999345537145": { + "absence_channel_id": 1369669999677145103, + "absence_role_id": 1369669999345537154 + } +} \ No newline at end of file diff --git a/data/absences.json b/data/absences.json new file mode 100644 index 0000000..8550aa8 --- /dev/null +++ b/data/absences.json @@ -0,0 +1,3 @@ +{ + "1369669999345537145": {} +} \ No newline at end of file diff --git a/data/logs/messages_2026-02-27.json b/data/logs/messages_2026-02-27.json new file mode 100644 index 0000000..ec16e47 --- /dev/null +++ b/data/logs/messages_2026-02-27.json @@ -0,0 +1,38 @@ +[ + { + "timestamp": "2026-02-27T16:51:53.636507", + "event_type": "deleted", + "message_id": 1476970101038125148, + "channel_id": 1369669999677145103, + "channel_name": "╰📂・rapports", + "author_id": 971446412690722826, + "author_name": "gameurpro12", + "author_roles": [ + 1369669999345537145, + 1369769836028231730, + 1369669999366770726 + ], + "content": "1369669999677145103", + "attachments": [], + "embeds": [], + "details": {} + }, + { + "timestamp": "2026-02-27T16:52:08.494696", + "event_type": "deleted", + "message_id": 1476970163726188639, + "channel_id": 1369669999677145103, + "channel_name": "╰📂・rapports", + "author_id": 971446412690722826, + "author_name": "gameurpro12", + "author_roles": [ + 1369669999345537145, + 1369769836028231730, + 1369669999366770726 + ], + "content": "<@&1369669999345537154>", + "attachments": [], + "embeds": [], + "details": {} + } +] \ No newline at end of file From fe05572549d7d68491bebdfbb710f982b77c9c53 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 1 Mar 2026 01:01:15 +0100 Subject: [PATCH 4/6] =?UTF-8?q?V1.0=20de=20l'int=C3=A9gration=20de=20la=20?= =?UTF-8?q?feature=20de=20r=C3=A9cup=C3=A9ration=20automatique=20des=20inf?= =?UTF-8?q?ormations=20des=20agents=20O.K=20sur=20le=20siteweb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 3 +- commandes/website_sync.py | 476 +++++++++++++++++++++++++++++++++++++ src/json_export_service.py | 380 +++++++++++++++++++++++++++++ 3 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 commandes/website_sync.py create mode 100644 src/json_export_service.py diff --git a/bot.py b/bot.py index a62e47a..45822f7 100755 --- a/bot.py +++ b/bot.py @@ -67,7 +67,8 @@ class MyBot(commands.Bot): "commandes.ticket_whitelist", "commandes.bug_report", "commandes.convocation", - "commandes.absence_staff" + "commandes.absence_staff", + "commandes.website_sync", ] for extension in extensions: diff --git a/commandes/website_sync.py b/commandes/website_sync.py new file mode 100644 index 0000000..71e584a --- /dev/null +++ b/commandes/website_sync.py @@ -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 — 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é.") diff --git a/src/json_export_service.py b/src/json_export_service.py new file mode 100644 index 0000000..d25fd38 --- /dev/null +++ b/src/json_export_service.py @@ -0,0 +1,380 @@ +""" +Service d'export JSON pour le site web. +Génère deux fichiers : + - servers.json : tous les serveurs où 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 ") + + 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} From b5fd8b65262fdc07eecb2e4423299e469d83c083 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 1 Mar 2026 01:08:35 +0100 Subject: [PATCH 5/6] =?UTF-8?q?V1.2=20r=C3=A9cup=C3=A9ration=20automatique?= =?UTF-8?q?=20des=20informations=20des=20agent=20O.K=20sur=20le=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/website_sync.py | 5 +++-- data/json-export-config.json | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 data/json-export-config.json diff --git a/commandes/website_sync.py b/commandes/website_sync.py index 71e584a..fac166a 100644 --- a/commandes/website_sync.py +++ b/commandes/website_sync.py @@ -471,6 +471,7 @@ class WebsiteSync(commands.Cog, name="Website Sync"): 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) + # 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é.") diff --git a/data/json-export-config.json b/data/json-export-config.json new file mode 100644 index 0000000..bd1043d --- /dev/null +++ b/data/json-export-config.json @@ -0,0 +1,37 @@ +{ + "targetGuildId": "1369669999345537145", + "autoHour": 3, + "autoMinute": 0, + "outputDir": "/home/gameurpro12/Documents/Mes_projets/site_Omega_Kube/website/data", + "founderRoles": [ + "fondateur", + "founder" + ], + "serverFields": { + "id": true, + "name": true, + "icon": true, + "banner": true, + "owner": true, + "memberCount": true, + "description": true, + "createdAt": true, + "boostLevel": false, + "boostCount": false, + "verificationLevel": false + }, + "memberFields": { + "id": true, + "username": true, + "displayName": true, + "globalName": true, + "avatar": true, + "bio": true, + "roles": true, + "joinedAt": true, + "createdAt": true, + "badges": false, + "accentColor": false, + "isBot": false + } +} \ No newline at end of file From 058473260a68d201ad804ea021b9921f40b53d58 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 1 Mar 2026 01:28:33 +0100 Subject: [PATCH 6/6] =?UTF-8?q?V1.3=20prod,=20pour=20auto=20r=C3=A9cup=20d?= =?UTF-8?q?es=20infos=20des=20agent=20O.K=20sur=20le=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/website_sync.py | 16 ++--- data/json-export-config.json | 2 +- data/json-export-history.json | 128 ++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 data/json-export-history.json diff --git a/commandes/website_sync.py b/commandes/website_sync.py index fac166a..78aad56 100644 --- a/commandes/website_sync.py +++ b/commandes/website_sync.py @@ -64,23 +64,19 @@ class WebsiteSync(commands.Cog, name="Website Sync"): def cog_unload(self): self.daily_export.cancel() - # ── Tâche de fond : génération quotidienne ──────────────────────────────── - @tasks.loop(minutes=1) + # ── Tâche de fond : génération (30s pour test) ──────────────────────────── + @tasks.loop(seconds=30) 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) + """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] Scheduler actif → génération auto à " - f"{config['autoHour']:02d}:{config['autoMinute']:02d} chaque jour." + f"[JSON Export] Mode Test actif → génération automatique toutes les 30 secondes." ) # ── Helper : embed de réponse ───────────────────────────────────────────── diff --git a/data/json-export-config.json b/data/json-export-config.json index bd1043d..8b42f5b 100644 --- a/data/json-export-config.json +++ b/data/json-export-config.json @@ -2,7 +2,7 @@ "targetGuildId": "1369669999345537145", "autoHour": 3, "autoMinute": 0, - "outputDir": "/home/gameurpro12/Documents/Mes_projets/site_Omega_Kube/website/data", + "outputDir": "/home/gameurpro12/Documents/Mes_projets/Site_Omega_Kube/website/assets/data", "founderRoles": [ "fondateur", "founder" diff --git a/data/json-export-history.json b/data/json-export-history.json new file mode 100644 index 0000000..84e4600 --- /dev/null +++ b/data/json-export-history.json @@ -0,0 +1,128 @@ +[ + { + "date": "2026-03-01T00:26:14.746054+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:25:45.072770+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:25:15.071380+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:24:45.388984+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:24:14.975026+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:23:44.713904+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:23:16.510199+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:22:44.966638+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:22:14.857332+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:21:44.958489+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:21:14.844468+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:20:44.959093+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:20:14.948095+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + }, + { + "date": "2026-03-01T00:19:44.835750+00:00", + "trigger": "auto", + "serversCount": 1, + "membersCount": 13, + "guildName": "Omega Kube Bêta Test", + "errors": [], + "success": true + } +] \ No newline at end of file