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