import disnake from disnake.ext import commands, tasks 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" # Chemins ABSOLUS BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "..", "data") ABSENCE_SETTINGS_FILE = os.path.join(DATA_DIR, "absence_settings.json") ABSENCE_DATA_FILE = os.path.join(DATA_DIR, "absences.json") # Créer le dossier data/ s'il n'existe pas os.makedirs(DATA_DIR, exist_ok=True) 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: Optional[str]): """Parse une date au format JJ/MM/AAAA HH:MM en timezone Paris""" if not date_str: raise ValueError("Date vide") return datetime.strptime(date_str, DATE_FORMAT).replace(tzinfo=PARIS_TZ) # --- MODALS & VIEWS --- class AbsenceModal(disnake.ui.Modal): def __init__(self, superieur: Optional[disnake.Member] = None): self.superieur = superieur self.motif_input = disnake.ui.TextInput( label="Motif de l'absence", style=disnake.TextInputStyle.paragraph, placeholder="Ex: Vacances, Raisons personnelles, Travail...", required=True, max_length=500, custom_id="absence_motif" ) self.debut_input = disnake.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, custom_id="absence_debut" ) self.fin_input = disnake.ui.TextInput( label="Fin (JJ/MM/AAAA HH:MM)", placeholder="Ex: 20/05/2024 18:00", min_length=16, max_length=16, required=True, custom_id="absence_fin" ) super().__init__(title="Déclarer une Absence Staff", components=[self.motif_input, self.debut_input, self.fin_input]) async def callback(self, interaction: disnake.ModalInteraction): try: val_debut = interaction.text_values["absence_debut"] val_fin = interaction.text_values["absence_fin"] motif = interaction.text_values["absence_motif"] debut = parse_date(val_debut) fin = parse_date(val_fin) except (ValueError, TypeError, KeyError) as e: return await interaction.response.send_message(f"❌ Format de date invalide ou champ manquant. 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 avec V2 Components components = [ disnake.ui.Container( disnake.ui.Section( f"# ABSENCE STAFF\n**👤 Membre** : {interaction.user.mention}", accessory=disnake.ui.Thumbnail(interaction.user.display_avatar.url) ), disnake.ui.Separator(), disnake.ui.TextDisplay(f"**⏳ Durée** : {duree_str}"), disnake.ui.TextDisplay(f"**📅 Période** : `{val_debut}` ➔ `{val_fin}`"), disnake.ui.TextDisplay(f"**📝 Motif** : {motif}"), disnake.ui.TextDisplay(f"**🛡️ Supérieur** : {self.superieur.mention if self.superieur else 'Aucun'}") ) ] absence_message = await channel.send(components=components) # Enregistrement guild_abs[str(interaction.user.id)] = { "pseudo": interaction.user.name, "avatar_url": interaction.user.display_avatar.url, "debut": val_debut, "fin": val_fin, "duree": duree_str, "motif": motif, "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(disnake.ui.View): def __init__(self, guild_id: int, bot): super().__init__(timeout=300) self.guild_id = guild_id self.bot = bot @disnake.ui.channel_select(placeholder="Sélectionnez le salon d'affichage", min_values=1, max_values=1) async def select_channel(self, select: disnake.ui.ChannelSelect, interaction: disnake.MessageInteraction): channel = select.values[0] settings = load_settings(self.guild_id) settings["absence_channel_id"] = channel.id save_settings(self.guild_id, settings) # Update the original message with new settings embed = self._get_updated_embed(settings, interaction.guild) await interaction.response.edit_message(embed=embed, view=self) await interaction.followup.send(f"✅ Salon d'absence configuré sur {channel.mention}", ephemeral=True) @disnake.ui.role_select(placeholder="Sélectionnez le rôle d'absence", min_values=1, max_values=1) async def select_role(self, select: disnake.ui.RoleSelect, interaction: disnake.MessageInteraction): role = select.values[0] settings = load_settings(self.guild_id) settings["absence_role_id"] = role.id save_settings(self.guild_id, settings) # Update the original message with new settings embed = self._get_updated_embed(settings, interaction.guild) await interaction.response.edit_message(embed=embed, view=self) await interaction.followup.send(f"✅ Rôle d'absence configuré sur **{role.name}**", ephemeral=True) def _get_updated_embed(self, settings, guild): channel_id = settings.get("absence_channel_id") role_id = settings.get("absence_role_id") embed = disnake.Embed( title="⚙️ Configuration des Absences Staff", description="Utilisez les menus déroulants ci-dessous pour configurer le système.", color=disnake.Color.blue() ) 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 ) return embed class Absence(commands.Cog): def __init__(self, bot): self.bot = bot self.check_absences.start() @commands.slash_command(name="absence", description="Déclarer une absence staff") async def absence(self, interaction: disnake.ApplicationCommandInteraction, superieur: Optional[disnake.Member] = commands.Param(None, description="Le supérieur hiérarchique à prévenir")): """Déclare une absence via un formulaire""" await interaction.response.send_modal(AbsenceModal(superieur)) @commands.slash_command(name="absence_config", description="Configurer le système d'absence (Admin)") @commands.has_permissions(administrator=True) async def absence_config(self, interaction: disnake.ApplicationCommandInteraction): """Ouvre le panneau de configuration des absences""" settings = load_settings(interaction.guild.id) embed = disnake.Embed( title="⚙️ Configuration des Absences Staff", description="Utilisez les boutons ci-dessous pour configurer le système.", color=disnake.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, self.bot), ephemeral=True) async def strike_absence_message(self, guild: disnake.Guild, channel_id: int, message_id: int, info: dict = None): """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) # Gestion des Rich Components V2 if info: avatar_url = info.get("avatar_url") if avatar_url: header_component = disnake.ui.Section( f"## ~~ABSENCE STAFF [TERMINÉ]~~\n~~**👤 Membre** : {info.get('pseudo', 'Inconnu')}~~", accessory=disnake.ui.Thumbnail(avatar_url) ) else: header_component = disnake.ui.TextDisplay(f"## ~~ABSENCE STAFF [TERMINÉ]~~\n~~**👤 Membre** : {info.get('pseudo', 'Inconnu')}~~") components = [ disnake.ui.Container( header_component, disnake.ui.Separator(), disnake.ui.TextDisplay(f"~~**⏳ Durée** : {info.get('duree', 'N/A')}~~"), disnake.ui.TextDisplay(f"~~**📅 Période** : {info.get('debut')} ➔ {info.get('fin')}~~"), disnake.ui.TextDisplay(f"~~**📝 Motif** : {info.get('motif')}~~"), disnake.ui.TextDisplay(f"~~**🛡️ Supérieur** : {info.get('superieur', 'Aucun')}~~") ) ] print(f"[DEBUG] Tentative d'édition du message V2 {message_id}") try: await msg.edit(components=components) print(f"[DEBUG] Succès édition V2") except Exception as e: print(f"[DEBUG] Erreur édition V2: {e}") return # Ancien format (Texte simple) - fallback content = msg.content if not content or "~~" in content: return lines = content.split("\n") new_lines = [] for line in lines: if line.startswith("# "): 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}") @commands.slash_command(name="fin_absence", description="Terminer son absence staff") async def fin_absence(self, interaction: disnake.ApplicationCommandInteraction): """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), info) # 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) @commands.slash_command(name="liste_absences", description="Afficher la liste des absences staff en cours") async def liste_absences(self, interaction: disnake.ApplicationCommandInteraction): """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) -> disnake.Embed: embed = disnake.Embed( title=f"📋 Absences staff - {interaction.guild.name} ({page_index + 1}/{len(pages)})", color=disnake.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(disnake.ui.View): def __init__(self): super().__init__(timeout=60) self.page = 0 @disnake.ui.button(label="⬅️ Précédent", style=disnake.ButtonStyle.secondary, disabled=True) async def previous(self, interaction_btn: disnake.Interaction, button: disnake.ui.Button): self.page -= 1 self.update_buttons() await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self) @disnake.ui.button(label="➡️ Suivant", style=disnake.ButtonStyle.secondary, disabled=len(pages) == 1) async def next(self, interaction_btn: disnake.Interaction, button: disnake.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), info) 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) def setup(bot): bot.add_cog(Absence(bot))