import disnake from disnake.ext import commands import json import os from typing import List, Optional, Dict import datetime import asyncio import logging from utils.premium import check_premium_tier kuby_logger = logging.getLogger("KubyBot") # Chemins ABSOLUS BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "..", "data") CONV_SETTINGS_FILE = os.path.join(DATA_DIR, "convocation_settings.json") CONV_BACKUPS_FILE = os.path.join(DATA_DIR, "convocation_backups.json") # Créer le dossier data/ s'il n'existe pas os.makedirs(DATA_DIR, exist_ok=True) 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) 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.slash_command(name="convocation", description="Gère les convocations de membres") @check_premium_tier() @commands.has_permissions(manage_roles=True) async def convocation_group(self, interaction: disnake.ApplicationCommandInteraction): pass @convocation_group.sub_command(name="convoquer", description="Convoque un membre et verrouille ses accès") async def convoquer(self, interaction: disnake.ApplicationCommandInteraction, membre: disnake.Member = commands.Param(description="Le membre à convoquer"), raison: str = commands.Param(description="La raison de la convocation"), heure: str = commands.Param(description="L'heure de la convocation (ex: 14:30)")): guild_id = interaction.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", []) if not role_conv_id: await interaction.response.send_message("❌ Le rôle de convocation n'est pas configuré. Utilisez `/convocation config`.", ephemeral=True) return await interaction.response.defer(ephemeral=True) try: # --- BACKUP --- bot_top_role = interaction.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": {} } # 1. Backup and Remove current roles current_roles = [interaction.guild.get_role(rid) for rid in backup_data["roles"] if interaction.guild.get_role(rid)] if current_roles: try: await membre.remove_roles(*current_roles, reason=f"Convocation par {interaction.author}") except disnake.Forbidden: await interaction.followup.send("⚠️ Je n'ai pas pu retirer certains rôles (Permission refusée).", ephemeral=True) # 2. Dynamic Lockdown: Loop through ALL channels semaphore = asyncio.Semaphore(10) async def lock_channel(channel): async with semaphore: # 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)): return False # Check existing override for the member overwrites = channel.overwrites_for(membre) if not overwrites.is_empty(): # Save existing override 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: await channel.set_permissions(membre, view_channel=False, reason="Convocation Lockdown") return True except disnake.Forbidden: return False except Exception as e: kuby_logger.warning(f"⚠️ [Convocation] Impossible de verrouiller {channel.name}: {e}") return False tasks = [lock_channel(c) for c in interaction.guild.channels] results = await asyncio.gather(*tasks) lockdown_count = sum(1 for r in results if r is True) self.save_backup(guild_id, membre.id, backup_data) # 3. Add Convocation role role_conv = interaction.guild.get_role(role_conv_id) if role_conv: if role_conv.position >= bot_top_role.position: await interaction.followup.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 disnake.Forbidden: await interaction.followup.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 = disnake.Embed( title="CONVOCATION | 🔖", description=f"> Bonjour {membre.mention}, vous êtes **convoqué** par {interaction.author.mention}. Voici les **détails** :", color=0x0099ff, timestamp=disnake.utils.utcnow() ) 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_conv.set_footer(text=f"Gestion de {interaction.guild.name}") embed_log = disnake.Embed( title="📋 CONVOCATION ENVOYÉE", color=0xff9900, timestamp=disnake.utils.utcnow() ) embed_log.add_field(name="👤 Membre convoqué", value=f"{membre.mention} ({membre.id})", inline=True) embed_log.add_field(name="👮 Convoqué par", value=f"{interaction.author.mention}", inline=True) embed_log.add_field(name="📄 Raison", value=raison, inline=False) embed_log.add_field(name="🕒 Heure", value=heure, inline=False) # DM dm_sent = False try: await membre.send(embed=embed_conv) dm_sent = True except disnake.Forbidden: dm_sent = False # Admin & Channel Logs for admin_id in admin_ids: try: admin = await self.bot.fetch_user(admin_id) await admin.send(embed=embed_log) except: pass if log_channel_id: chan = interaction.guild.get_channel(log_channel_id) if chan: await chan.send(embed=embed_log) await interaction.followup.send(f"✅ Convocation envoyée à {membre.mention}. ({lockdown_count} salons verrouillés) " + ("" if dm_sent else "(⚠️ DM impossible)"), ephemeral=True) except Exception as e: import traceback traceback.print_exc() await interaction.followup.send(f"❌ Une erreur critique est survenue : {e}", ephemeral=True) @convocation_group.sub_command(name="lever", description="Lève la convocation d'un membre et restaure ses accès") async def lever(self, interaction: disnake.ApplicationCommandInteraction, membre: disnake.Member = commands.Param(description="Le membre à libérer")): guild_id = interaction.guild.id settings = self.get_guild_settings(guild_id) role_conv_id = settings.get("role_id") await interaction.response.defer(ephemeral=True) backup_data = self.get_backup(guild_id, membre.id) if not backup_data: await interaction.followup.send("⚠️ Aucune donnée de convocation trouvée pour ce membre.", ephemeral=True) return # 1. Remove Convocation role & Restore original roles FIRST try: if role_conv_id: role_conv = interaction.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") role_ids = backup_data.get("roles", []) if role_ids: restored_roles = [] for rid in role_ids: role = interaction.guild.get_role(rid) if role and role.position < interaction.guild.me.top_role.position: restored_roles.append(role) if restored_roles: await membre.add_roles(*restored_roles, reason="Restauration fin de convocation") except Exception as e: kuby_logger.error(f"❌ [Convocation] Erreur lors de la restauration des rôles pour {membre}: {e}") # 2. Restore channel overrides (Parallel) semaphore = asyncio.Semaphore(10) overrides = backup_data.get("overrides", {}) async def restore_channel(channel): async with semaphore: chan_id_str = str(channel.id) try: if chan_id_str in overrides: # Restore previous override ov_data = overrides[chan_id_str] overwrite = disnake.PermissionOverwrite(**ov_data) await channel.set_permissions(membre, overwrite=overwrite, reason="Restauration fin de convocation") return "restored" elif membre in channel.overwrites: # Clear our lockdown override only if it exists await channel.set_permissions(membre, overwrite=None, reason="Fin de convocation - Cleanup") return "cleared" except disnake.Forbidden: return "forbidden" except Exception as e: kuby_logger.warning(f"⚠️ [Convocation] Erreur restauration {channel.name}: {e}") return "error" return "skipped" tasks = [restore_channel(c) for c in interaction.guild.channels] results = await asyncio.gather(*tasks) restore_count = sum(1 for r in results if r == "restored") clear_count = sum(1 for r in results if r == "cleared") forbidden_count = sum(1 for r in results if r == "forbidden") kuby_logger.info(f"🔓 [Convocation] Restauration terminée pour {membre}: {restore_count} restaurés, {clear_count} nettoyés, {forbidden_count} échecs permissions.") self.clear_backup(guild_id, membre.id) # 4. Notification embed = disnake.Embed( title="🔓 CONVOCATION TERMINÉE", description=f"Votre convocation sur **{interaction.guild.name}** est terminée. Vos accès ont été restaurés.", color=disnake.Color.green() ) try: await membre.send(embed=embed) except: pass await interaction.followup.send(f"✅ Convocation levée pour {membre.mention}. Access restaurés.", ephemeral=True) @convocation_group.sub_command(name="config", description="Configure le système de convocation") async def config(self, interaction: disnake.ApplicationCommandInteraction, role: disnake.Role = commands.Param(description="Le rôle de convocation (Muet/Convocation)"), channel: disnake.TextChannel = commands.Param(None, description="Le salon de logs"), attente: disnake.abc.GuildChannel = commands.Param(None, description="Salon (ou catégorie) d'attente à laisser visible"), admins: str = commands.Param(None, description="Liste d'IDs d'admins à notifier (séparés par des espaces)")): guild_id = interaction.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 = disnake.Embed(title="⚙️ Configuration Convocation Mise à jour", color=disnake.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 interaction.response.send_message(embed=embed, ephemeral=True) def setup(bot): bot.add_cog(Convocation(bot))