import disnake import re from disnake.ext import commands from disnake import ui import json import os import asyncio from typing import Optional from src.logger import kuby_logger import time from datetime import datetime, timezone # Dictionnaire global pour suivre les renommages de salons # Format: {channel_id: [timestamp1, timestamp2, ...]} channel_rename_timestamps = {} def check_channel_rename_rate_limit(channel_id: int) -> int: """Vérifie si le renommage va atteindre la limite de Discord (2 edits / 10 min).""" now = time.time() if channel_id not in channel_rename_timestamps: channel_rename_timestamps[channel_id] = [] timestamps = [ts for ts in channel_rename_timestamps[channel_id] if now - ts < 600] channel_rename_timestamps[channel_id] = timestamps if len(timestamps) >= 2: return max(0, int(600 - (now - timestamps[0]))) return 0 def log_channel_rename(channel_id: int): """Enregistre un renommage de salon.""" now = time.time() if channel_id not in channel_rename_timestamps: channel_rename_timestamps[channel_id] = [] timestamps = [ts for ts in channel_rename_timestamps[channel_id] if now - ts < 600] timestamps.append(now) channel_rename_timestamps[channel_id] = timestamps # Chemins ABSOLUS BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "..", "data") TICKET_WHITELIST_SETTINGS_FILE = os.path.join(DATA_DIR, "ticket_whitelist_settings.json") # Créer le dossier data/ s'il n'existe pas os.makedirs(DATA_DIR, exist_ok=True) def load_settings(guild_id: int) -> dict: """Charge les paramètres de ticket pour un serveur""" if not os.path.exists(TICKET_WHITELIST_SETTINGS_FILE): return {} try: with open(TICKET_WHITELIST_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 de ticket""" data = {} if os.path.exists(TICKET_WHITELIST_SETTINGS_FILE): try: with open(TICKET_WHITELIST_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(TICKET_WHITELIST_SETTINGS_FILE), exist_ok=True) with open(TICKET_WHITELIST_SETTINGS_FILE, "w", encoding='utf-8') as f: json.dump(data, f, indent=4, ensure_ascii=False) async def get_ticket_user_id(channel: disnake.TextChannel) -> Optional[int]: """Récupère l'ID du propriétaire du ticket de manière robuste (gère le lag de cache)""" import re topic = getattr(channel, "topic", None) # Tentative d'utilisation du cache (plus rapide) if topic: match = re.search(r"(\d{17,20})", topic) if match: return int(match.group(1)) # Si le topic est vide ou ID introuvable, on tente de forcer un refresh via l'API try: # On utilise fetch_channel pour avoir un objet tout neuf avec le topic à jour refreshed_channel = await channel.guild.fetch_channel(channel.id) topic = getattr(refreshed_channel, "topic", None) if topic: match = re.search(r"(\d{17,20})", topic) if match: return int(match.group(1)) except: pass return None # ---- Admin: édition “question par question” (sans JSON) ---- def _load_whitelist_questions_file_payload() -> dict: """Charge data/whitelist_questions.json tel quel (legacy ou par guild).""" return _safe_load_json(QUESTIONS_DATA_FILE) def _save_whitelist_questions_file_payload(payload: dict) -> None: os.makedirs(os.path.dirname(QUESTIONS_DATA_FILE), exist_ok=True) with open(QUESTIONS_DATA_FILE, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False) def _get_or_init_questions_for_guild(payload: dict, guild_id: int) -> dict: """Retourne un dict questions pour guild, sans modifier le format global.""" if not isinstance(payload, dict): payload = {} # Legacy/global: on convertit à la volée vers format par guild if any(k in payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): legacy = _normalize_questions_payload(payload) return {**legacy} # caller gérera conversion globale g = payload.get(str(guild_id), None) if isinstance(g, dict): return {**g} return {} def _ensure_full_questions_shape(partial: dict) -> dict: """Fusionne DEFAULT + partial (attendu par build_text_input).""" if not isinstance(partial, dict): partial = {} return {**DEFAULT_QUESTIONS, **partial} # ---- Permissions Helper ---- def is_staff_or_admin(member: disnake.Member, guild: disnake.Guild) -> bool: """Vérifie si l'utilisateur a les permissions administrateur ou s'il est dans la whitelist du bot.""" if member.guild_permissions.administrator: return True try: whitelist_path = os.path.join(DATA_DIR, "whitelist.json") if os.path.exists(whitelist_path): with open(whitelist_path, "r", encoding="utf-8") as f: wl_data = json.load(f) guild_wl = wl_data.get(str(guild.id), []) if str(member.id) in guild_wl: return True except Exception as e: kuby_logger.error(f"Erreur lors de la vérification de la whitelist pour {member.id}: {e}") return False # ---- Admin: édition “question par question” (sans JSON) ---- def _load_whitelist_questions_file_payload() -> dict: """Charge data/whitelist_questions.json tel quel (legacy ou par guild).""" return _safe_load_json(QUESTIONS_DATA_FILE) def _save_whitelist_questions_file_payload(payload: dict) -> None: os.makedirs(os.path.dirname(QUESTIONS_DATA_FILE), exist_ok=True) with open(QUESTIONS_DATA_FILE, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False) def _get_or_init_questions_for_guild(payload: dict, guild_id: int) -> dict: """Retourne un dict questions pour guild, sans modifier le format global.""" if not isinstance(payload, dict): payload = {} # Legacy/global: on convertit à la volée vers format par guild if any(k in payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): legacy = _normalize_questions_payload(payload) return {**legacy} # caller gérera conversion globale g = payload.get(str(guild_id), None) if isinstance(g, dict): return {**g} return {} def _ensure_full_questions_shape(partial: dict) -> dict: """Fusionne DEFAULT + partial (attendu par build_text_input).""" if not isinstance(partial, dict): partial = {} return {**DEFAULT_QUESTIONS, **partial} class WhitelistQuestionEditModal(disnake.ui.Modal): """Modal pour éditer une question (label/placeholder/style/max/min).""" def __init__(self, guild_id: int, step_key: str, question_index: int, q: dict): self.guild_id = guild_id self.step_key = step_key self.question_index = question_index current_label = str(q.get("label", ""))[:100] current_placeholder = str(q.get("placeholder", ""))[:100] current_style = str(q.get("style", "")).lower() if q.get("style") is not None else "" current_max = q.get("max_length", None) current_min = q.get("min_length", None) self.label_input = disnake.ui.TextInput( label="Question (Libellé affiché)", custom_id="wq_label", style=disnake.TextInputStyle.short, required=True, max_length=100, value=current_label, placeholder="Ex: Quel est votre pseudo Epic ?" ) self.placeholder_input = disnake.ui.TextInput( label="Exemple de réponse (Placeholder)", custom_id="wq_placeholder", style=disnake.TextInputStyle.short, required=False, max_length=100, value=current_placeholder, placeholder="Ex: MonPseudo123" ) self.style_input = disnake.ui.TextInput( label="Style (short ou paragraph)", custom_id="wq_style", style=disnake.TextInputStyle.short, required=False, max_length=20, value=current_style if current_style else "short", placeholder="short = ligne simple, paragraph = texte long" ) self.max_input = disnake.ui.TextInput( label="Longueur maximale (Caractères max)", custom_id="wq_max", style=disnake.TextInputStyle.short, required=False, max_length=10, value=str(current_max) if current_max is not None else "", placeholder="Laisser vide pour la valeur par défaut" ) self.min_input = disnake.ui.TextInput( label="Longueur minimale (Caractères min)", custom_id="wq_min", style=disnake.TextInputStyle.short, required=False, max_length=10, value=str(current_min) if current_min is not None else "", placeholder="Laisser vide pour la valeur par défaut" ) super().__init__( title=f"✏️ Éditer ({step_key} - #{question_index+1})", custom_id=f"wq_edit_modal_{guild_id}_{step_key}_{question_index}", components=[self.label_input, self.placeholder_input, self.style_input, self.max_input, self.min_input], ) async def callback(self, interaction: disnake.ModalInteraction): try: label = interaction.text_values.get("wq_label", "").strip() placeholder = interaction.text_values.get("wq_placeholder", "").strip() style = interaction.text_values.get("wq_style", "").strip().lower() max_raw = interaction.text_values.get("wq_max", "").strip() min_raw = interaction.text_values.get("wq_min", "").strip() if not label: return await interaction.response.send_message("❌ Label invalide.", ephemeral=True) if style not in ["short", "paragraph", ""]: style = "short" max_length = int(max_raw) if max_raw.isdigit() else None min_length = int(min_raw) if min_raw.isdigit() else None payload = _load_whitelist_questions_file_payload() if any(k in payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): legacy = _ensure_full_questions_shape(_normalize_questions_payload(payload)) payload = {str(self.guild_id): legacy} gpayload = payload.get(str(self.guild_id), {}) if not isinstance(gpayload, dict): gpayload = {} step_questions = gpayload.get(self.step_key, None) if not isinstance(step_questions, list): step_questions = list(DEFAULT_QUESTIONS.get(self.step_key, [])) if self.question_index < 0 or self.question_index >= len(step_questions): return await interaction.response.send_message("❌ Question introuvable.", ephemeral=True) q = dict(step_questions[self.question_index]) q["label"] = label if placeholder: q["placeholder"] = placeholder else: q.pop("placeholder", None) if style: q["style"] = style else: q.pop("style", None) if max_length is not None: q["max_length"] = max_length else: q.pop("max_length", None) if min_length is not None: q["min_length"] = min_length else: q.pop("min_length", None) step_questions[self.question_index] = q gpayload[self.step_key] = step_questions payload[str(self.guild_id)] = gpayload _save_whitelist_questions_file_payload(payload) # Renvoyer le menu d'étape actualisé containers = build_step_questions_details_containers(self.guild_id, self.step_key) await interaction.response.edit_message(components=containers) except Exception as e: kuby_logger.error(f"Erreur WhitelistQuestionEditModal: {e}", exc_info=True) await interaction.response.send_message(f"❌ Erreur: {e}", ephemeral=True) class WhitelistQuestionsPickIndexModal(disnake.ui.Modal): """Choisir l'index de la question à éditer.""" def __init__(self, guild_id: int, step_key: str): self.guild_id = guild_id self.step_key = step_key questions = load_whitelist_questions_for_guild(guild_id).get(step_key, []) n = len(questions) self.index_input = disnake.ui.TextInput( label=f"Index de la question à éditer (1..{n})", custom_id="wq_index", style=disnake.TextInputStyle.short, required=True, max_length=3, placeholder="ex: 1", ) super().__init__( title=f"🧩 Modifier question ({step_key})", custom_id=f"wq_pick_edit_{guild_id}_{step_key}", components=[self.index_input], ) async def callback(self, interaction: disnake.ModalInteraction): raw = interaction.text_values.get("wq_index", "").strip() if not raw.isdigit(): return await interaction.response.send_message("❌ Index invalide (doit être un nombre).", ephemeral=True) idx1 = int(raw) if idx1 <= 0: return await interaction.response.send_message("❌ Index invalide (doit être >= 1).", ephemeral=True) questions = load_whitelist_questions_for_guild(self.guild_id).get(self.step_key, []) idx0 = idx1 - 1 if idx0 >= len(questions): return await interaction.response.send_message("❌ Index hors limites pour cette étape.", ephemeral=True) qdata = questions[idx0] # On ouvre directement le modal d'édition await interaction.response.send_modal( WhitelistQuestionEditModal(self.guild_id, self.step_key, idx0, qdata) ) class WhitelistQuestionAddModal(disnake.ui.Modal): """Ajouter une nouvelle question.""" def __init__(self, guild_id: int, step_key: str): self.guild_id = guild_id self.step_key = step_key self.id_input = disnake.ui.TextInput( label="Identifiant unique (ex: age, sans espace)", custom_id="wa_id", style=disnake.TextInputStyle.short, required=True, max_length=30, placeholder="ex: pseudo_epic", ) self.label_input = disnake.ui.TextInput( label="Question (Libellé affiché)", custom_id="wa_label", style=disnake.TextInputStyle.short, required=True, max_length=100, placeholder="Ex: Quel est votre pseudo Epic ?", ) self.placeholder_input = disnake.ui.TextInput( label="Exemple de réponse (Placeholder)", custom_id="wa_placeholder", style=disnake.TextInputStyle.short, required=False, max_length=100, placeholder="Ex: MonPseudo123", ) self.style_input = disnake.ui.TextInput( label="Style (short ou paragraph)", custom_id="wa_style", style=disnake.TextInputStyle.short, required=False, max_length=20, value="short", placeholder="short = ligne simple, paragraph = texte long" ) self.max_input = disnake.ui.TextInput( label="Longueur maximale (Caractères max)", custom_id="wa_max", style=disnake.TextInputStyle.short, required=False, max_length=10, placeholder="Laisser vide pour aucune limite", ) super().__init__( title="➕ Ajouter une question", custom_id=f"wq_add_modal_{guild_id}_{step_key}", components=[self.id_input, self.label_input, self.placeholder_input, self.style_input, self.max_input], ) async def callback(self, interaction: disnake.ModalInteraction): try: qid = interaction.text_values.get("wa_id", "").strip().lower().replace(" ", "_") label = interaction.text_values.get("wa_label", "").strip() placeholder = interaction.text_values.get("wa_placeholder", "").strip() style = interaction.text_values.get("wa_style", "").strip().lower() max_raw = interaction.text_values.get("wa_max", "").strip() if not qid or not label: return await interaction.response.send_message("❌ ID et Label obligatoires.", ephemeral=True) if style not in ["short", "paragraph"]: style = "short" max_length = int(max_raw) if max_raw.isdigit() else None payload = _load_whitelist_questions_file_payload() if any(k in payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): legacy = _ensure_full_questions_shape(_normalize_questions_payload(payload)) payload = {str(self.guild_id): legacy} gpayload = payload.get(str(self.guild_id), {}) if not isinstance(gpayload, dict): gpayload = {} step_questions = gpayload.get(self.step_key, None) if not isinstance(step_questions, list): step_questions = list(DEFAULT_QUESTIONS.get(self.step_key, [])) if len(step_questions) >= 5: return await interaction.response.send_message("❌ Impossible d'ajouter plus de 5 questions à cette étape.", ephemeral=True) # Vérifier si l'ID existe déjà if any(q.get("id") == qid for q in step_questions): return await interaction.response.send_message("❌ Cet ID existe déjà pour cette étape.", ephemeral=True) new_q = { "id": qid, "label": label } if placeholder: new_q["placeholder"] = placeholder if style != "short": new_q["style"] = style if max_length is not None: new_q["max_length"] = max_length step_questions.append(new_q) gpayload[self.step_key] = step_questions payload[str(self.guild_id)] = gpayload _save_whitelist_questions_file_payload(payload) containers = build_step_questions_details_containers(self.guild_id, self.step_key) await interaction.response.edit_message(components=containers) except Exception as e: kuby_logger.error(f"Erreur WhitelistQuestionAddModal: {e}", exc_info=True) await interaction.response.send_message(f"❌ Erreur: {e}", ephemeral=True) class WhitelistQuestionDeleteModal(disnake.ui.Modal): """Supprimer une question.""" def __init__(self, guild_id: int, step_key: str): self.guild_id = guild_id self.step_key = step_key questions = load_whitelist_questions_for_guild(guild_id).get(step_key, []) n = len(questions) self.index_input = disnake.ui.TextInput( label=f"Index de la question à supprimer (1..{n})", custom_id="wq_del_index", style=disnake.TextInputStyle.short, required=True, max_length=3, placeholder="ex: 1", ) super().__init__( title=f"🗑️ Supprimer question ({step_key})", custom_id=f"wq_delete_modal_{guild_id}_{step_key}", components=[self.index_input], ) async def callback(self, interaction: disnake.ModalInteraction): try: raw = interaction.text_values.get("wq_del_index", "").strip() if not raw.isdigit(): return await interaction.response.send_message("❌ Index invalide.", ephemeral=True) idx1 = int(raw) idx0 = idx1 - 1 payload = _load_whitelist_questions_file_payload() if any(k in payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): legacy = _ensure_full_questions_shape(_normalize_questions_payload(payload)) payload = {str(self.guild_id): legacy} gpayload = payload.get(str(self.guild_id), {}) if not isinstance(gpayload, dict): gpayload = {} step_questions = gpayload.get(self.step_key, None) if not isinstance(step_questions, list): step_questions = list(DEFAULT_QUESTIONS.get(self.step_key, [])) if idx0 < 0 or idx0 >= len(step_questions): return await interaction.response.send_message("❌ Index hors limites.", ephemeral=True) del_q = step_questions.pop(idx0) gpayload[self.step_key] = step_questions payload[str(self.guild_id)] = gpayload _save_whitelist_questions_file_payload(payload) containers = build_step_questions_details_containers(self.guild_id, self.step_key) await interaction.response.edit_message(components=containers) except Exception as e: kuby_logger.error(f"Erreur WhitelistQuestionDeleteModal: {e}", exc_info=True) await interaction.response.send_message(f"❌ Erreur: {e}", ephemeral=True) class WhitelistRoleConfigModal(disnake.ui.Modal): """Modal pour configurer les 3 rôles membres principaux d'un seul coup.""" def __init__(self, guild_id: int): self.guild_id = guild_id settings = load_settings(guild_id) self.ao_input = disnake.ui.TextInput( label="ID Rôle Attente Oral", custom_id="rc_oral", style=disnake.TextInputStyle.short, required=False, max_length=25, value=str(settings.get("attente_oral_role_id", "")), ) self.caf_input = disnake.ui.TextInput( label="ID Rôle Candidature à faire", custom_id="rc_candidature", style=disnake.TextInputStyle.short, required=False, max_length=25, value=str(settings.get("candidature_a_faire_role_id", "")), ) self.cit_input = disnake.ui.TextInput( label="ID Rôle Citoyen", custom_id="rc_citoyen", style=disnake.TextInputStyle.short, required=False, max_length=25, value=str(settings.get("citoyen_role_id", "")), ) super().__init__( title="⚙️ Configuration Rôles Membres", custom_id=f"twc_role_modal_{guild_id}", components=[self.ao_input, self.caf_input, self.cit_input], ) async def callback(self, interaction: disnake.ModalInteraction): try: ao_val = interaction.text_values.get("rc_oral", "").strip() caf_val = interaction.text_values.get("rc_candidature", "").strip() cit_val = interaction.text_values.get("rc_citoyen", "").strip() settings = load_settings(self.guild_id) if ao_val.isdigit(): settings["attente_oral_role_id"] = int(ao_val) if caf_val.isdigit(): settings["candidature_a_faire_role_id"] = int(caf_val) if cit_val.isdigit(): settings["citoyen_role_id"] = int(cit_val) save_settings(self.guild_id, settings) container = build_config_container(interaction.guild) await interaction.response.edit_message(components=[container]) except Exception as e: await interaction.response.send_message(f"❌ Erreur: {e}", ephemeral=True) class WhitelistConfigCategoryModal(disnake.ui.Modal): def __init__(self, guild_id: int): self.guild_id = guild_id settings = load_settings(guild_id) self.cat_input = disnake.ui.TextInput( label="ID de la catégorie des tickets", custom_id="wcc_cat_id", style=disnake.TextInputStyle.short, required=True, value=str(settings.get("ticket_category_id", "")) ) super().__init__(title="📁 Catégorie Tickets", custom_id=f"twc_cat_modal_{guild_id}", components=[self.cat_input]) async def callback(self, interaction: disnake.ModalInteraction): cat_val = interaction.text_values.get("wcc_cat_id", "").strip() if not cat_val.isdigit(): return await interaction.response.send_message("❌ ID invalide (doit être numérique).", ephemeral=True) category = disnake.utils.get(interaction.guild.categories, id=int(cat_val)) if not category: return await interaction.response.send_message("❌ Catégorie introuvable avec cet ID.", ephemeral=True) settings = load_settings(self.guild_id) settings["ticket_category_id"] = category.id save_settings(self.guild_id, settings) container = build_config_container(interaction.guild) await interaction.response.edit_message(components=[container]) class WhitelistConfigStaffModal(disnake.ui.Modal): def __init__(self, guild_id: int, config_type: str): self.guild_id = guild_id self.config_type = config_type settings = load_settings(guild_id) current_val = settings.get("staff_role_id" if config_type == "staff" else "team_staff_role_id", "") self.role_input = disnake.ui.TextInput( label="ID du rôle Staff" if config_type == "staff" else "ID du rôle Équipe Staff", custom_id="wcs_role_id", style=disnake.TextInputStyle.short, required=True, value=str(current_val) ) super().__init__( title="🛡️ Rôle Staff" if config_type == "staff" else "👥 Rôle Équipe Staff", custom_id=f"twc_staff_modal_{guild_id}_{config_type}", components=[self.role_input] ) async def callback(self, interaction: disnake.ModalInteraction): role_val = interaction.text_values.get("wcs_role_id", "").strip() if not role_val.isdigit(): return await interaction.response.send_message("❌ ID invalide (doit être numérique).", ephemeral=True) role = interaction.guild.get_role(int(role_val)) if not role: return await interaction.response.send_message("❌ Rôle introuvable avec cet ID.", ephemeral=True) settings = load_settings(self.guild_id) settings["staff_role_id" if self.config_type == "staff" else "team_staff_role_id"] = role.id save_settings(self.guild_id, settings) container = build_config_container(interaction.guild) await interaction.response.edit_message(components=[container]) # ---- V2 Components Renderers ---- def build_config_container(guild: disnake.Guild) -> disnake.ui.Container: """Construit l'interface principale de config avec les Containers V2.""" settings = load_settings(guild.id) cat_id = settings.get("ticket_category_id") s_role_id = settings.get("staff_role_id") ts_role_id = settings.get("team_staff_role_id") ao_role_id = settings.get("attente_oral_role_id") caf_role_id = settings.get("candidature_a_faire_role_id") c_role_id = settings.get("citoyen_role_id") sections = [ disnake.ui.TextDisplay(content="## ⚙️ Configuration Système Whitelist"), disnake.ui.TextDisplay(content="Gérez la catégorie de tickets, les rôles staff, les rôles des joueurs, et modularisez le questionnaire."), disnake.ui.Separator(), disnake.ui.Section( f"📁 **Catégorie de Tickets**\nID actuel : `{cat_id or 'Non configuré'}`", accessory=disnake.ui.Button(label="📁 Modifier", style=disnake.ButtonStyle.primary, custom_id="twc_set_category") ), disnake.ui.Section( f"🛡️ **Rôle Staff (Action tickets)**\nConfiguré : " + (f"<@&{s_role_id}>" if s_role_id else "`Non configuré`"), accessory=disnake.ui.Button(label="🛡️ Modifier", style=disnake.ButtonStyle.primary, custom_id="twc_set_staff") ), disnake.ui.Section( f"👥 **Rôle Équipe Staff (Ping tickets)**\nConfiguré : " + (f"<@&{ts_role_id}>" if ts_role_id else "`Non configuré`"), accessory=disnake.ui.Button(label="👥 Modifier", style=disnake.ButtonStyle.primary, custom_id="twc_set_team") ), disnake.ui.Section( f"⚙️ **Rôles Membres (Oral / Candidature / Citoyen)**\n" f"🎤 Oral : " + (f"<@&{ao_role_id}>" if ao_role_id else "`Non configuré`") + "\n" f"📝 Candidature : " + (f"<@&{caf_role_id}>" if caf_role_id else "`Non configuré`") + "\n" f"🏙️ Citoyen : " + (f"<@&{c_role_id}>" if c_role_id else "`Non configuré`"), accessory=disnake.ui.Button(label="⚙️ Rôles Membres", style=disnake.ButtonStyle.primary, custom_id="twc_set_member_roles") ), disnake.ui.Separator(), disnake.ui.Section( "📝 **Questions du Formulaire (Modulaires)**\nConfigurez, ajoutez, modifiez ou supprimez les questions posées aux candidats.", accessory=disnake.ui.Button(label="📝 Gérer les Questions", style=disnake.ButtonStyle.success, custom_id="twc_manage_questions") ) ] return disnake.ui.Container(*sections) def build_questions_steps_container(guild_id: int) -> disnake.ui.Container: """Construit l'interface du wizard pour choisir l'étape en Containers V2.""" questions = load_whitelist_questions_for_guild(guild_id) sections = [ disnake.ui.TextDisplay(content="## 📝 Questionnaire Whitelist"), disnake.ui.TextDisplay(content="Choisissez l'étape du formulaire à configurer :"), disnake.ui.Separator(), disnake.ui.Section( f"📋 **Étape 1 : Infos Personnelles**\nNombre de questions : `{len(questions.get('step1', []))}` / 5", accessory=disnake.ui.Button(label="Étape 1", style=disnake.ButtonStyle.primary, custom_id="twc_questions_step1") ), disnake.ui.Section( f"👤 **Étape 2 - Partie 1 : Infos RP (P1)**\nNombre de questions : `{len(questions.get('step2_part1', []))}` / 5", accessory=disnake.ui.Button(label="Étape 2 (P1)", style=disnake.ButtonStyle.primary, custom_id="twc_questions_step2_part1") ), disnake.ui.Section( f"👤 **Étape 2 - Partie 2 : Infos RP (P2)**\nNombre de questions : `{len(questions.get('step2_part2', []))}` / 5", accessory=disnake.ui.Button(label="Étape 2 (P2)", style=disnake.ButtonStyle.primary, custom_id="twc_questions_step2_part2") ), disnake.ui.Section( f"📖 **Étape 3 : Développement du Personnage**\nNombre de questions : `{len(questions.get('step3', []))}` / 5", accessory=disnake.ui.Button(label="Étape 3", style=disnake.ButtonStyle.primary, custom_id="twc_questions_step3") ), disnake.ui.Separator(), disnake.ui.Section( "⬅️ **Retour** au menu principal", accessory=disnake.ui.Button(label="Retour", style=disnake.ButtonStyle.secondary, custom_id="twc_back_to_main") ) ] return disnake.ui.Container(*sections) def build_step_questions_details_containers(guild_id: int, step_key: str) -> list[disnake.ui.Container]: """Affiche les détails d'une étape et propose les actions d'édition en Containers V2.""" questions = load_whitelist_questions_for_guild(guild_id).get(step_key, []) step_names = { "step1": "Étape 1 (Infos Perso)", "step2_part1": "Étape 2 - P1 (Infos RP)", "step2_part2": "Étape 2 - P2 (Infos RP)", "step3": "Étape 3 (Développement)" } c1_children = [ disnake.ui.TextDisplay(content=f"## ⚙️ Configuration : {step_names.get(step_key, step_key)}"), disnake.ui.TextDisplay(content="Voici la liste des questions actuelles pour cette étape (Max 5) :"), disnake.ui.Separator() ] if not questions: c1_children.append(disnake.ui.TextDisplay(content="*Aucune question configurée pour cette étape.*")) else: for i, q in enumerate(questions): q_style = q.get("style", "short") q_min = f" | Min: {q['min_length']}" if "min_length" in q else "" q_max = f" | Max: {q['max_length']}" if "max_length" in q else "" c1_children.append( disnake.ui.Section( f"**{i+1}. {q.get('label')}**\nID: `{q.get('id')}` | Style: `{q_style}`{q_min}{q_max}", accessory=disnake.ui.Button( label=f"✏️ Modifier #{i+1}", style=disnake.ButtonStyle.primary, custom_id=f"twc_qedit_{step_key}_{i}" ) ) ) c2_children = [ disnake.ui.Section( "➕ Ajouter une nouvelle question (max 5)", accessory=disnake.ui.Button( label="Ajouter", style=disnake.ButtonStyle.green, custom_id=f"twc_qadd_{step_key}", disabled=(len(questions) >= 5) ) ), disnake.ui.Section( "🗑️ Supprimer une question existante", accessory=disnake.ui.Button( label="Supprimer", style=disnake.ButtonStyle.danger, custom_id=f"twc_qdelete_{step_key}", disabled=(len(questions) == 0) ) ), disnake.ui.Section( "🔄 Réinitialiser les questions par défaut", accessory=disnake.ui.Button( label="Réinitialiser", style=disnake.ButtonStyle.secondary, custom_id=f"twc_qreset_{step_key}" ) ), disnake.ui.Section( "⬅️ Retour", accessory=disnake.ui.Button( label="Retour", style=disnake.ButtonStyle.secondary, custom_id="twc_manage_questions" ) ) ] return [disnake.ui.Container(*c1_children), disnake.ui.Container(*c2_children)] class ContinueFormView(disnake.ui.View): """Vue avec un bouton pour continuer à l'étape suivante""" def __init__(self, current_step, guild_id: int, step1_data=None, step2_part1_data=None, step2_part2_data=None): super().__init__(timeout=180.0) # Définir un timeout pour la vue self.current_step = current_step self.guild_id = guild_id self.step1_data = step1_data self.step2_part1_data = step2_part1_data self.step2_part2_data = step2_part2_data @disnake.ui.button(label="📋 Continuer le formulaire", style=disnake.ButtonStyle.green, custom_id="continue_form") async def continue_form(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Passe à l'étape suivante du formulaire""" if self.current_step == 1: await interaction.response.send_modal(TicketStep2Part1(self.step1_data, self.guild_id)) elif self.current_step == 2: await interaction.response.send_modal(TicketStep2Part2(self.step1_data, self.step2_part1_data, self.guild_id)) elif self.current_step == 3: await interaction.response.send_modal(TicketStep3(self.step1_data, self.step2_part1_data, self.step2_part2_data, self.guild_id)) # Vérifier si le message existe toujours avant de le modifier try: await interaction.message.edit(view=self) except disnake.errors.NotFound: # Si le message n'existe plus, nous ne faisons rien pass # ------------------------- # Questions Whitelist (modulaires) # ------------------------- DEFAULT_QUESTIONS = { "step1": [ {"id": "pseudo", "label": "Pseudo Epic (Fortnite)", "placeholder": "Ton pseudo", "max_length": 16}, {"id": "age", "label": "Âge", "placeholder": "Ex: 18", "max_length": 2}, {"id": "experience", "label": "Expérience RP ?", "placeholder": "Oui/Non", "max_length": 50, "style": "paragraph"}, {"id": "decouverte", "label": "Comment as-tu découvert le serveur ?", "placeholder": "Ex: YouTube", "max_length": 20}, {"id": "micro", "label": "As-tu un micro de qualité ?", "placeholder": "Oui/Non", "max_length": 3}, ], "step2_part1": [ {"id": "prenom", "label": "Prénom", "placeholder": "Ton prénom", "max_length": 32}, {"id": "nom", "label": "Nom", "placeholder": "Ton nom", "max_length": 32}, {"id": "age_perso", "label": "Âge du personnage", "placeholder": "Ex: 25", "max_length": 3}, {"id": "genre", "label": "Genre", "placeholder": "M/F", "max_length": 5}, {"id": "naissance", "label": "Date et lieu de naissance", "placeholder": "Ex: 01/01/1995, Paris", "max_length": 50}, ], "step2_part2": [ {"id": "traits", "label": "Traits de caractère", "placeholder": "Ex: Courageux", "max_length": 100}, {"id": "nationalite", "label": "Nationalité", "placeholder": "Ex: Française", "max_length": 30}, {"id": "skin_rp", "label": "Skin RP", "placeholder": "Ex: Civil", "max_length": 30}, {"id": "metier_souhaite", "label": "Métier souhaité", "placeholder": "Ex: Médecin", "max_length": 30}, ], "step3": [ {"id": "histoire", "label": "Histoire du personnage", "placeholder": "Raconte ton histoire", "max_length": 500, "style": "paragraph", "min_length": 425}, {"id": "objectifs", "label": "Objectifs RP (court et long terme)", "placeholder": "Tes objectifs", "max_length": 100, "style": "paragraph"}, ], } # Chemins questions QUESTIONS_CONFIG_FILE = os.path.join(BASE_DIR, "..", "config", "whitelist_questions.json") QUESTIONS_DATA_FILE = os.path.join(BASE_DIR, "..", "data", "whitelist_questions.json") def _safe_load_json(path: str) -> dict: try: if not os.path.exists(path): return {} with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else {} except Exception: return {} def _normalize_questions_payload(payload: dict) -> dict: """Normalise le JSON pour obtenir {step1: [...], step2_part1: [...], ...}.""" if not payload: return {} # Cas 1: structure déjà {step1: [...], ...} if any(k in payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): return payload # Cas 2: structure {questions: [...]} # (moins probable ici, mais on garde une sécurité) if "questions" in payload and isinstance(payload["questions"], list): return {"step1": payload["questions"]} return {} def _normalize_questions_payload_like_default(payload: dict) -> dict: """Normalise vers la forme attendue {step1: [...], step2_part1: [...], ...}.""" return _normalize_questions_payload(payload) def _extract_questions_payload_from_file_payload(file_payload: dict, guild_id: Optional[int] = None) -> dict: """ Supporte plusieurs formats de whitelist_questions.json : - Legacy (global) : {step1:[...], step2_part1:[...], ...} - Par guild : { "": {step1:[...], ...}, ... } (et éventuellement des clés __defaults__ / autres) """ if not isinstance(file_payload, dict) or not file_payload: return {} # Legacy/global if any(k in file_payload for k in ["step1", "step2_part1", "step2_part2", "step3"]): return _normalize_questions_payload_like_default(file_payload) # Par guild if guild_id is not None: gpayload = file_payload.get(str(guild_id), None) if isinstance(gpayload, dict): return _normalize_questions_payload_like_default(gpayload) return {} def load_whitelist_questions_for_guild(guild_id: int) -> dict: """Charge les questions blacklist whitelist custom pour un serveur, sinon fallback DEFAULT.""" file_payload = _safe_load_json(QUESTIONS_DATA_FILE) guild_payload = _extract_questions_payload_from_file_payload(file_payload, guild_id=guild_id) if guild_payload: return {**DEFAULT_QUESTIONS, **guild_payload} cfg_payload = _normalize_questions_payload_like_default(_safe_load_json(QUESTIONS_CONFIG_FILE)) if cfg_payload: return {**DEFAULT_QUESTIONS, **cfg_payload} return DEFAULT_QUESTIONS # Garder compatibilité: ancien code appelle load_whitelist_questions() sans guild_id. def load_whitelist_questions() -> dict: """Fallback global (compat). Préférer load_whitelist_questions_for_guild().""" return load_whitelist_questions_for_guild(guild_id=0) def _text_input_style_from_str(style: Optional[str]): if not style: return disnake.TextInputStyle.short style = style.lower().strip() if style == "paragraph": return disnake.TextInputStyle.paragraph return disnake.TextInputStyle.short def build_text_input(q: dict) -> disnake.ui.TextInput: qid = q.get("id") label = q.get("label") placeholder = q.get("placeholder", None) kwargs = { "label": label, "custom_id": qid, "required": q.get("required", True), "max_length": q.get("max_length", None), "style": _text_input_style_from_str(q.get("style")), } # Supprime les None pour éviter erreurs kwargs = {k: v for k, v in kwargs.items() if v is not None} if placeholder is not None: # disnake n'accepte pas toujours placeholder="" selon versions: on garde une valeur string kwargs["placeholder"] = str(placeholder) if "min_length" in q and q.get("min_length") is not None: kwargs["min_length"] = q.get("min_length") return disnake.ui.TextInput(**kwargs) def build_components_for_step(step_questions: list[dict]) -> list: # Discord v2 components are handled by disnake internally via ui components return [build_text_input(q) for q in (step_questions or [])] class TicketStep1(disnake.ui.Modal): """Étape 1 du formulaire - Infos personnelles""" def __init__(self, guild_id: int): questions = load_whitelist_questions_for_guild(guild_id).get("step1", []) super().__init__( title="📋 Informations Personnelles", custom_id="ticket_step1", components=build_components_for_step(questions), ) async def callback(self, interaction: disnake.ModalInteraction): """Envoie un bouton pour passer à l'étape 2""" view = ContinueFormView(current_step=1, guild_id=interaction.guild.id, step1_data=interaction.text_values) await interaction.response.send_message("✅ Première étape terminée ! Cliquez ci-dessous pour continuer.", view=view, ephemeral=True) class TicketStep2Part1(disnake.ui.Modal): """Étape 2 du formulaire - Infos RP (Partie 1)""" def __init__(self, step1_data, guild_id: int): self.step1_data = step1_data questions = load_whitelist_questions_for_guild(guild_id).get("step2_part1", []) super().__init__( title="👤 Infos sur ton personnage (Partie 1)", custom_id="ticket_step2_part1", components=build_components_for_step(questions), ) async def callback(self, interaction: disnake.ModalInteraction): """Envoie un bouton pour passer à l'étape suivante""" view = ContinueFormView(current_step=2, guild_id=interaction.guild.id, step1_data=self.step1_data, step2_part1_data=interaction.text_values) await interaction.response.send_message("✅ Deuxième partie terminée ! Cliquez ci-dessous pour continuer.", view=view, ephemeral=True) class TicketStep2Part2(disnake.ui.Modal): """Étape 2 du formulaire - Infos RP (Partie 2)""" def __init__(self, step1_data, step2_part1_data, guild_id: int): self.step1_data = step1_data self.step2_part1_data = step2_part1_data questions = load_whitelist_questions_for_guild(guild_id).get("step2_part2", []) super().__init__( title="👤 Infos sur ton personnage (Partie 2)", custom_id="ticket_step2_part2", components=build_components_for_step(questions), ) async def callback(self, interaction: disnake.ModalInteraction): """Envoie un bouton pour passer à l'étape 3""" view = ContinueFormView(current_step=3, guild_id=interaction.guild.id, step1_data=self.step1_data, step2_part1_data=self.step2_part1_data, step2_part2_data=interaction.text_values) await interaction.response.send_message("✅ Deuxième étape terminée ! Cliquez ci-dessous pour continuer.", view=view, ephemeral=True) class TicketStep3(disnake.ui.Modal): """Étape 3 du formulaire - Histoire du personnage""" def __init__(self, step1_data, step2_part1_data, step2_part2_data, guild_id: int): self.step1_data = step1_data self.step2_part1_data = step2_part1_data self.step2_part2_data = step2_part2_data questions = load_whitelist_questions_for_guild(guild_id).get("step3", []) super().__init__( title="📋 Développement du Personnage", custom_id="ticket_step3", components=build_components_for_step(questions), ) async def callback(self, interaction: disnake.ModalInteraction): """Création du ticket après validation""" guild = interaction.guild user = interaction.user settings = load_settings(guild.id) category_id = settings.get("ticket_category_id") staff_role_id = settings.get("staff_role_id") team_staff_role_id = settings.get("team_staff_role_id") if not category_id or not staff_role_id or not team_staff_role_id: return await interaction.response.send_message("❌ Erreur : Le système de tickets n'est pas entièrement configuré sur ce serveur (Catégorie, Rôle Staff ou Rôle Équipe Staff manquant).", ephemeral=True) category = disnake.utils.get(guild.categories, id=int(category_id)) if category is None: return await interaction.response.send_message("❌ Erreur : La catégorie des tickets configurée n'existe plus !", ephemeral=True) staff_role = disnake.utils.get(guild.roles, id=int(staff_role_id)) team_staff_role = disnake.utils.get(guild.roles, id=int(team_staff_role_id)) if staff_role is None or team_staff_role is None: return await interaction.response.send_message("❌ Erreur : L'un des rôles staff configurés n'existe plus !", ephemeral=True) # Vérifier si l'utilisateur a déjà un ticket ouvert for channel in category.text_channels: if channel.topic == f"Ticket de {user.id}": return await interaction.response.send_message(f"❌ Vous avez déjà un ticket ouvert : {channel.mention}", ephemeral=True) # Définir les permissions du salon du ticket overwrites = { guild.default_role: disnake.PermissionOverwrite(read_messages=False), user: disnake.PermissionOverwrite(read_messages=True, send_messages=True), staff_role: disnake.PermissionOverwrite(read_messages=True, send_messages=True), team_staff_role: disnake.PermissionOverwrite(read_messages=True, send_messages=False) } # Créer un salon de ticket avec le nom [pseudo]-écrit ticket_channel = await category.create_text_channel( name=f"{user.name}-écrit", topic=f"Ticket de {user.id}", overwrites=overwrites ) # Enregistrer le ticket dans la base de données globale pour le système de notation try: from commandes.ticket.data.storage import get_storage from commandes.ticket.data.models import TicketData, TicketStatus storage = get_storage() ticket_data = TicketData( channel_id=ticket_channel.id, guild_id=guild.id, user_id=user.id, category_id="whitelist", status=TicketStatus.OPEN, created_at=datetime.now(timezone.utc).replace(tzinfo=None) ) storage.save_ticket(ticket_data) except Exception as e: kuby_logger.error(f"Erreur lors de l'enregistrement du ticket whitelist dans la base globale : {e}") # Création de l'embed avec les infos du formulaire embed = disnake.Embed(title="📩 Ticket Whitelist", color=0x00ff00) # Helper to get value from data s1 = self.step1_data s2p1 = self.step2_part1_data s2p2 = self.step2_part2_data s3 = interaction.text_values def format_answers(questions, text_values): lines = [] for q in questions: qid = q.get("id") label = q.get("label") val = text_values.get(qid, "Non renseigné") lines.append(f"> - {label} : {val}") return "\n".join(lines) if lines else "> - Aucune donnée" questions_step1 = load_whitelist_questions_for_guild(guild.id).get("step1", []) questions_step2_part1 = load_whitelist_questions_for_guild(guild.id).get("step2_part1", []) questions_step2_part2 = load_whitelist_questions_for_guild(guild.id).get("step2_part2", []) questions_step3 = load_whitelist_questions_for_guild(guild.id).get("step3", []) embed.add_field(name="📋 - Informations Personnelles", value=format_answers(questions_step1, s1), inline=False) embed.add_field(name="👤 - Informations RP", value=( format_answers(questions_step2_part1, s2p1) + "\n" + format_answers(questions_step2_part2, s2p2) ), inline=False) embed.add_field(name="📋 - Développement du Personnage", value=format_answers(questions_step3, s3), inline=False) # Envoyer l'embed avec les boutons d'actions pour les staff await ticket_channel.send(embed=embed, view=TicketManagementView()) # Envoyer un message pour mentionner l'équipe staff await ticket_channel.send(f"{team_staff_role.mention} - Nouveau ticket créé !") # Mettre à jour le pseudo du joueur si le bot a les permissions nécessaires if guild.me.guild_permissions.manage_nicknames: first_name = s2p1.get("prenom", "") last_name = s2p1.get("nom", "") epic_pseudo = s1.get("pseudo", "") if first_name and last_name and epic_pseudo: new_nickname = f"{first_name} {last_name} | {epic_pseudo}" elif first_name and last_name: new_nickname = f"{first_name} {last_name}" elif epic_pseudo: new_nickname = f"{epic_pseudo}" else: new_nickname = user.name if len(new_nickname) > 32: new_nickname = new_nickname[:32] try: await user.edit(nick=new_nickname) except disnake.Forbidden: pass # Envoyer un message de confirmation try: await interaction.response.send_message(f"✅ Ticket ouvert : {ticket_channel.mention}", ephemeral=True) except disnake.errors.NotFound: await interaction.followup.send(f"✅ Ticket ouvert : {ticket_channel.mention}", ephemeral=True) class TicketManagementView(disnake.ui.View): """Vue pour gérer le ticket avec les boutons Fermer, Réouvrir, Claim et Supprimer""" def __init__(self): super().__init__(timeout=None) async def get_ticket_owner(self, interaction: disnake.Interaction): """Récupère le propriétaire du ticket via le helper robuste""" owner_id = await get_ticket_user_id(interaction.channel) if owner_id: try: member = interaction.guild.get_member(owner_id) if not member: member = await interaction.guild.fetch_member(owner_id) return member except: return None return None @disnake.ui.button(label="🔒 Fermer le Ticket", style=disnake.ButtonStyle.red, custom_id="close_ticket") async def close_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Ferme le ticket en désactivant l'envoi de messages pour le membre""" await interaction.response.defer(ephemeral=True) settings = load_settings(interaction.guild.id) staff_role_id = settings.get("staff_role_id") is_staff = any(r.id == int(staff_role_id) for r in interaction.user.roles) if staff_role_id else False if not is_staff and not is_staff_or_admin(interaction.user, interaction.guild): return await interaction.followup.send("❌ Seuls les staffs peuvent fermer un ticket.", ephemeral=True) owner = await self.get_ticket_owner(interaction) if not owner: return await interaction.followup.send("❌ Impossible de trouver le propriétaire du ticket à fermer.", ephemeral=True) try: await interaction.channel.set_permissions(owner, read_messages=False, send_messages=False) log_channel_rename(interaction.channel.id) asyncio.create_task(interaction.channel.edit(name=f"fermé-{owner.display_name}".replace(" ", "-").lower()[:100])) # Enregistrer le statut de fermeture dans la base globale et envoyer notation feedback_sent = False ticket = None try: from commandes.ticket.data.storage import get_storage from commandes.ticket.data.models import TicketData, TicketStatus from commandes.ticket.feedback_view import FeedbackView storage = get_storage() ticket = storage.load_ticket(interaction.channel.id) now = datetime.now(timezone.utc).replace(tzinfo=None) if ticket: ticket.status = TicketStatus.CLOSED ticket.closed_at = now if not ticket.claimed_by: ticket.claimed_by = interaction.user.id storage.save_ticket(ticket) else: kuby_logger.warning(f"[WhitelistTicket] Ticket {interaction.channel.id} non trouvé en base, création on-the-fly.") ticket = TicketData( channel_id=interaction.channel.id, guild_id=interaction.guild.id, user_id=owner.id, category_id="whitelist", status=TicketStatus.CLOSED, created_at=now, closed_at=now, claimed_by=interaction.user.id, ) storage.save_ticket(ticket) # Envoyer le questionnaire de notation par DM view_feedback = FeedbackView(interaction.client, storage, ticket) try: dm_embed = disnake.Embed( title="📝 Ton avis nous intéresse !", description=( f"Ton ticket de whitelist sur **{interaction.guild.name}** a été fermé par **{interaction.user.display_name}**.\n" f"Merci de prendre quelques secondes pour évaluer le staff qui t'a accompagné !" ), color=disnake.Color.blurple(), timestamp=datetime.now(timezone.utc) ) dm_embed.set_footer(text=interaction.guild.name, icon_url=interaction.guild.icon.url if interaction.guild.icon else None) await owner.send(embed=dm_embed, view=view_feedback) feedback_sent = True except disnake.Forbidden: kuby_logger.warning(f"[WhitelistTicket] Impossible d'envoyer le DM de notation à {owner} (DMs fermés).") except Exception as dm_err: kuby_logger.error(f"[WhitelistTicket] Erreur lors de l'envoi du DM de notation : {dm_err}") except Exception as db_err: kuby_logger.error(f"Erreur lors de l'archivage du ticket whitelist / envoi notation : {db_err}") # Embed de fermeture visible dans le salon close_embed = disnake.Embed( title="🔒 Ticket Fermé", description=( f"Ce ticket a été fermé par {interaction.user.mention}.\n" f"Le joueur **{owner.display_name}** n'a plus accès à ce salon." ), color=disnake.Color.red(), timestamp=datetime.now(timezone.utc) ) close_embed.add_field(name="👤 Joueur", value=owner.mention, inline=True) close_embed.add_field(name="🛡️ Staff", value=interaction.user.mention, inline=True) if feedback_sent: close_embed.set_footer(text="✅ Formulaire d'évaluation envoyé en DM au joueur.") else: close_embed.set_footer(text="⚠️ Formulaire non envoyé (DMs fermés ou erreur).") new_view = ClosedTicketView() await interaction.channel.send(embed=close_embed, view=new_view) await interaction.followup.send("✅ Ticket fermé avec succès.", ephemeral=True) except disnake.errors.RateLimited as e: await interaction.followup.send(f"⚠️ Limite de débit Discord : Réessayez dans {e.retry_after:.1f}s.", ephemeral=True) except Exception as e: kuby_logger.error(f"Erreur lors de la fermeture du ticket: {e}") await interaction.followup.send(f"❌ Erreur lors de la fermeture : {e}", ephemeral=True) @disnake.ui.button(label="👤 Claim le Ticket", style=disnake.ButtonStyle.blurple, custom_id="claim_ticket") async def claim_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Un staff prend en charge le ticket""" await interaction.response.defer(ephemeral=False) settings = load_settings(interaction.guild.id) staff_role_id = settings.get("staff_role_id") is_staff = any(r.id == int(staff_role_id) for r in interaction.user.roles) if staff_role_id else False if not is_staff and not is_staff_or_admin(interaction.user, interaction.guild): return await interaction.followup.send("❌ Seuls les staffs peuvent claim un ticket.") try: # Enregistrer la prise en charge (claim) dans la base globale try: from commandes.ticket.data.storage import get_storage from commandes.ticket.data.models import TicketStatus storage = get_storage() ticket = storage.load_ticket(interaction.channel.id) if ticket: ticket.claimed_by = interaction.user.id ticket.status = TicketStatus.CLAIMED storage.save_ticket(ticket) except Exception as db_err: kuby_logger.error(f"Erreur lors de l'enregistrement du claim du ticket whitelist dans la base globale : {db_err}") staff_role = interaction.guild.get_role(int(staff_role_id)) if staff_role_id else None # Retrait des permissions du rôle staff pour forcer le claimer à parler seul if staff_role: await interaction.channel.set_permissions(staff_role, send_messages=False, read_messages=True) # Donner les permissions au claimer await interaction.channel.set_permissions(interaction.user, read_messages=True, send_messages=True) await interaction.followup.send(f"👥 {interaction.user.mention} a pris en charge ce ticket.") except Exception as e: kuby_logger.error(f"Erreur lors du claim du ticket: {e}") await interaction.followup.send(f"❌ Erreur lors du claim : {e}") class ClosedTicketView(disnake.ui.View): """Vue pour les tickets fermés avec les boutons Réouvrir et Supprimer définitivement""" def __init__(self): super().__init__(timeout=None) async def get_ticket_owner(self, interaction: disnake.Interaction): """Récupère le propriétaire du ticket via le helper robuste""" owner_id = await get_ticket_user_id(interaction.channel) if owner_id: try: member = interaction.guild.get_member(owner_id) if not member: member = await interaction.guild.fetch_member(owner_id) return member except: return None return None def _is_staff(self, interaction: disnake.Interaction) -> bool: settings = load_settings(interaction.guild.id) staff_role_id = settings.get("staff_role_id") is_role_staff = any(r.id == int(staff_role_id) for r in interaction.user.roles) if staff_role_id else False return is_role_staff or is_staff_or_admin(interaction.user, interaction.guild) @disnake.ui.button(label="♻ Réouvrir le Ticket", style=disnake.ButtonStyle.green, custom_id="reopen_ticket") async def reopen_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Réouvre le ticket pour le membre""" await interaction.response.defer(ephemeral=True) if not self._is_staff(interaction): return await interaction.followup.send("❌ Seuls les staffs peuvent réouvrir un ticket.", ephemeral=True) owner = await self.get_ticket_owner(interaction) if not owner: return await interaction.followup.send("❌ Impossible de trouver le propriétaire du ticket à réouvrir.", ephemeral=True) try: embed = disnake.Embed( title="♻️ Réouverture du Ticket", description=f"Choisissez le statut à appliquer pour **{owner.display_name}**.", color=disnake.Color.blue() ) view = ReopenStatusView(owner) await interaction.channel.send(embed=embed, view=view) await interaction.followup.send("✅ Choisissez le statut ci-dessous.", ephemeral=True) except Exception as e: kuby_logger.error(f"Erreur lors de la préparation de réouverture: {e}") await interaction.followup.send(f"❌ Erreur : {e}", ephemeral=True) @disnake.ui.button(label="🗑️ Supprimer définitivement", style=disnake.ButtonStyle.danger, custom_id="delete_ticket") async def delete_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Génère un transcript HTML, l'envoie dans le canal de log, puis supprime le salon""" await interaction.response.defer(ephemeral=True) if not self._is_staff(interaction): return await interaction.followup.send("❌ Seuls les staffs peuvent supprimer un ticket.", ephemeral=True) owner = await self.get_ticket_owner(interaction) try: # --- 1. Génération du transcript --- transcript_file = None ticket = None try: from commandes.ticket.data.storage import get_storage from commandes.ticket.data.models import TicketData, TicketStatus from commandes.ticket.utils.transcript import get_transcript_generator storage = get_storage() ticket = storage.load_ticket(interaction.channel.id) # Collecter les messages du salon messages = [] async for msg in interaction.channel.history(limit=1000, oldest_first=True): if msg.author.bot: continue messages.append({ "id": str(msg.id), "author_id": msg.author.id, "author_name": str(msg.author), "content": msg.content, "timestamp": msg.created_at.isoformat(), "is_staff": msg.author.guild_permissions.administrator or ( any(r.name.lower() in ("staff", "mod", "modérateur", "admin") for r in getattr(msg.author, "roles", [])) ) }) if not ticket: now = datetime.now(timezone.utc).replace(tzinfo=None) ticket = TicketData( channel_id=interaction.channel.id, guild_id=interaction.guild.id, user_id=owner.id if owner else 0, category_id="whitelist", status=TicketStatus.CLOSED, created_at=now, closed_at=now, claimed_by=interaction.user.id, ) storage.save_ticket(ticket) transcript_gen = get_transcript_generator() transcript_file = transcript_gen.save_transcript(ticket, messages, format="html") except Exception as tr_err: kuby_logger.error(f"[WhitelistTicket] Erreur génération transcript : {tr_err}") # --- 2. Envoi dans le canal de log --- settings = load_settings(interaction.guild.id) log_channel_id = settings.get("log_channel_id") if log_channel_id: try: log_channel = interaction.guild.get_channel(int(log_channel_id)) if log_channel: duration_str = "N/A" if ticket and ticket.created_at and ticket.closed_at: delta = ticket.closed_at - ticket.created_at mins = int(delta.total_seconds() // 60) duration_str = f"{mins} min" log_embed = disnake.Embed( title="📄 Ticket Whitelist Supprimé", description=f"Le ticket du canal `{interaction.channel.name}` a été supprimé définitivement.", color=disnake.Color.dark_red(), timestamp=datetime.now(timezone.utc) ) log_embed.add_field(name="👤 Joueur", value=owner.mention if owner else "Inconnu", inline=True) log_embed.add_field(name="🛡️ Supprimé par", value=interaction.user.mention, inline=True) log_embed.add_field(name="⏱️ Durée", value=duration_str, inline=True) log_embed.set_footer(text=f"ID Salon : {interaction.channel.id}") import os if transcript_file and os.path.exists(transcript_file): file = disnake.File(transcript_file, filename=f"whitelist_{interaction.channel.name}.html") await log_channel.send(embed=log_embed, file=file) else: log_embed.add_field(name="⚠️ Transcript", value="Non généré (erreur ou salon vide).", inline=False) await log_channel.send(embed=log_embed) except Exception as log_err: kuby_logger.error(f"[WhitelistTicket] Erreur envoi log : {log_err}") # --- 3. Suppression du salon --- await interaction.followup.send("✅ Suppression en cours...", ephemeral=True) await interaction.channel.delete(reason=f"Ticket whitelist supprimé par {interaction.user}") except Exception as e: kuby_logger.error(f"Erreur lors de la suppression du ticket whitelist : {e}") try: await interaction.followup.send(f"❌ Erreur lors de la suppression : {e}", ephemeral=True) except: pass class ReopenStatusView(disnake.ui.View): """Vue pour choisir le statut lors d'une réouverture""" def __init__(self, owner: disnake.Member): super().__init__(timeout=180) self.owner = owner @disnake.ui.button(label="Écrit (Ouvert)", style=disnake.ButtonStyle.secondary) async def set_written(self, button: disnake.ui.Button, interaction: disnake.Interaction): await self.apply_status(interaction, "ouvert") @disnake.ui.button(label="Oral (À faire)", style=disnake.ButtonStyle.primary) async def set_oral(self, button: disnake.ui.Button, interaction: disnake.Interaction): await self.apply_status(interaction, "oral-à-faire") @disnake.ui.button(label="Accepté", style=disnake.ButtonStyle.success) async def set_accepted(self, button: disnake.ui.Button, interaction: disnake.Interaction): await self.apply_status(interaction, "accepté") async def apply_status(self, interaction: disnake.Interaction, prefix: str): await interaction.response.defer(ephemeral=True) try: settings = load_settings(interaction.guild.id) candidature_a_faire_role_id = settings.get("candidature_a_faire_role_id") attente_oral_role_id = settings.get("attente_oral_role_id") citoyen_role_id = settings.get("citoyen_role_id") # On récupère les objets rôles role_écrit = interaction.guild.get_role(int(candidature_a_faire_role_id)) if candidature_a_faire_role_id else None role_oral = interaction.guild.get_role(int(attente_oral_role_id)) if attente_oral_role_id else None role_citoyen = interaction.guild.get_role(int(citoyen_role_id)) if citoyen_role_id else None # Synchronisation des RÔLES en fonction du statut choisi roles_to_remove = [] role_to_add = None if prefix == "ouvert": role_to_add = role_écrit roles_to_remove = [r for r in [role_oral, role_citoyen] if r] elif prefix == "oral-à-faire": role_to_add = role_oral roles_to_remove = [r for r in [role_écrit, role_citoyen] if r] elif prefix == "accepté": role_to_add = role_citoyen roles_to_remove = [r for r in [role_écrit, role_oral] if r] # Mise à jour des rôles sur le membre if roles_to_remove: await self.owner.remove_roles(*roles_to_remove, reason="Réouverture du ticket - Maj Statut") if role_to_add: await self.owner.add_roles(role_to_add, reason="Réouverture du ticket - Maj Statut") # Mise à jour du SALON await interaction.channel.set_permissions(self.owner, read_messages=True, send_messages=True) new_name = f"{prefix}-{self.owner.display_name}".replace(" ", "-").lower()[:100] log_channel_rename(interaction.channel.id) asyncio.create_task(interaction.channel.edit(name=new_name, topic=f"Ticket de {self.owner.id}")) new_view = TicketManagementView() await interaction.channel.send(f"✅ Ticket réouvert par {interaction.user.mention} (Statut: **{prefix}**).", view=new_view) await interaction.followup.send(f"✅ Statut **{prefix}** appliqué et rôles synchronisés.", ephemeral=True) self.stop() except Exception as e: kuby_logger.error(f"Erreur lors de la réouverture/synchro: {e}") await interaction.followup.send(f"❌ Erreur système : {e}", ephemeral=True) @disnake.ui.button(label="❌ Supprimer le Ticket", style=disnake.ButtonStyle.danger, custom_id="delete_ticket") async def delete_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Supprime le salon du ticket""" await interaction.response.defer(ephemeral=True) settings = load_settings(interaction.guild.id) staff_role_id = settings.get("staff_role_id") is_staff = any(r.id == int(staff_role_id) for r in interaction.user.roles) if staff_role_id else False if not is_staff and not is_staff_or_admin(interaction.user, interaction.guild): return await interaction.followup.send("❌ Seuls les staffs peuvent supprimer un ticket.", ephemeral=True) try: await interaction.followup.send("❌ Suppression du ticket...", ephemeral=True) await interaction.channel.delete() except Exception as e: kuby_logger.error(f"Erreur lors de la suppression du ticket: {e}") try: await interaction.followup.send(f"❌ Erreur lors de la suppression : {e}", ephemeral=True) except: pass class TicketView(disnake.ui.View): """Vue avec le bouton pour ouvrir le ticket""" def __init__(self): super().__init__(timeout=None) @disnake.ui.button(label="📑 Ouvrir un Ticket Whitelist", style=disnake.ButtonStyle.red, custom_id="open_ticket") async def open_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction): """Affiche le premier formulaire""" await interaction.response.send_modal(TicketStep1(interaction.guild.id)) class Ticket(commands.Cog): """Commande pour envoyer l'embed de ticket""" def __init__(self, bot): self.bot = bot async def cog_load(self): """Enregistre les vues persistantes""" self.bot.add_view(TicketView()) self.bot.add_view(TicketManagementView()) self.bot.add_view(ClosedTicketView()) @commands.slash_command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.") async def ticket_whitelist(self, interaction: disnake.ApplicationCommandInteraction): """Envoie l'embed avec le bouton d'ouverture de ticket""" if not is_staff_or_admin(interaction.user, interaction.guild): return await interaction.response.send_message("❌ Vous n'avez pas la permission de lancer cette commande.", ephemeral=True) embed = disnake.Embed( title="📩 Ticket Whitelist", description="Pour réaliser votre whitelist, ouvrez un ticket et remplissez le formulaire.", color=0x00ff00 ) view = TicketView() await interaction.channel.send(embed=embed, view=view) await interaction.response.send_message("✅ Embed des tickets envoyé.", ephemeral=True) @commands.slash_command(name="ticket_whitelist_config", description="Configurer le système de tickets whitelist (Staff/Admin)") async def ticket_whitelist_config(self, interaction: disnake.ApplicationCommandInteraction): """Interface de configuration des tickets en Containers V2""" if not is_staff_or_admin(interaction.user, interaction.guild): return await interaction.response.send_message("❌ Vous n'avez pas la permission de configurer le système de ticket.", ephemeral=True) container = build_config_container(interaction.guild) await interaction.response.send_message( components=[container], flags=disnake.MessageFlags(is_components_v2=True), ephemeral=True ) @commands.Cog.listener() async def on_interaction(self, inter: disnake.Interaction): """Gère les clicks sur les boutons V2 de configuration""" if inter.type != disnake.InteractionType.component: return custom_id = getattr(inter.data, "custom_id", None) if not custom_id or not custom_id.startswith("twc_"): return # Sécurité permission if not is_staff_or_admin(inter.user, inter.guild): return await inter.response.send_message("❌ Permission insuffisante.", ephemeral=True) # Routage des boutons if custom_id == "twc_back_to_main": container = build_config_container(inter.guild) await inter.response.edit_message(components=[container]) elif custom_id == "twc_set_category": await inter.response.send_modal(WhitelistConfigCategoryModal(inter.guild.id)) elif custom_id == "twc_set_staff": await inter.response.send_modal(WhitelistConfigStaffModal(inter.guild.id, "staff")) elif custom_id == "twc_set_team": await inter.response.send_modal(WhitelistConfigStaffModal(inter.guild.id, "team")) elif custom_id == "twc_set_member_roles": await inter.response.send_modal(WhitelistRoleConfigModal(inter.guild.id)) elif custom_id == "twc_manage_questions": container = build_questions_steps_container(inter.guild.id) await inter.response.edit_message(components=[container]) # Choix des étapes questions elif custom_id.startswith("twc_questions_"): step_key = custom_id.replace("twc_questions_", "") containers = build_step_questions_details_containers(inter.guild.id, step_key) await inter.response.edit_message(components=containers) # Ajouter une question elif custom_id.startswith("twc_qadd_"): step_key = custom_id.replace("twc_qadd_", "") await inter.response.send_modal(WhitelistQuestionAddModal(inter.guild.id, step_key)) # Modifier une question elif custom_id.startswith("twc_qedit_"): # Format: twc_qedit_{step_key}_{idx} parts = custom_id.replace("twc_qedit_", "").split("_") idx_str = parts[-1] step_key = "_".join(parts[:-1]) if idx_str.isdigit(): idx0 = int(idx_str) questions = load_whitelist_questions_for_guild(inter.guild.id).get(step_key, []) if 0 <= idx0 < len(questions): qdata = questions[idx0] await inter.response.send_modal(WhitelistQuestionEditModal(inter.guild.id, step_key, idx0, qdata)) # Supprimer une question elif custom_id.startswith("twc_qdelete_"): step_key = custom_id.replace("twc_qdelete_", "") await inter.response.send_modal(WhitelistQuestionDeleteModal(inter.guild.id, step_key)) # Réinitialiser une étape elif custom_id.startswith("twc_qreset_"): step_key = custom_id.replace("twc_qreset_", "") payload = _load_whitelist_questions_file_payload() if str(inter.guild.id) in payload: payload[str(inter.guild.id)].pop(step_key, None) _save_whitelist_questions_file_payload(payload) containers = build_step_questions_details_containers(inter.guild.id, step_key) await inter.response.edit_message(components=containers) @commands.Cog.listener() async def on_message(self, message): """Surveille les messages des staffs dans les tickets pour validation automatique""" if message.guild is None or message.author.bot: return # On ne traite que si le message contient "validé" if "validé" not in message.content.lower(): return # Détection du format du topic du ticket via helper (robuste) user_id = await get_ticket_user_id(message.channel) if not user_id: return settings = load_settings(message.guild.id) staff_role_id = settings.get("staff_role_id") is_staff = False if staff_role_id: is_staff = any(r.id == int(staff_role_id) for r in message.author.roles) if not is_staff and not is_staff_or_admin(message.author, message.guild): return try: # Enregistrer le staff validant comme claimed_by dans la base globale try: from commandes.ticket.data.storage import get_storage as _get_storage from commandes.ticket.data.models import TicketStatus as _TicketStatus _storage = _get_storage() _ticket = _storage.load_ticket(message.channel.id) if _ticket: if not _ticket.claimed_by: _ticket.claimed_by = message.author.id _storage.save_ticket(_ticket) except Exception as _rating_err: kuby_logger.warning(f"[WhitelistTicket] Impossible de mettre à jour claimed_by lors de la validation : {_rating_err}") try: user = await message.guild.fetch_member(user_id) except (disnake.NotFound, disnake.HTTPException): await message.channel.send("❌ Utilisateur introuvable. A-t-il quitté le serveur ?", delete_after=10) return attente_oral_role_id = settings.get("attente_oral_role_id") candidature_a_faire_role_id = settings.get("candidature_a_faire_role_id") citoyen_role_id = settings.get("citoyen_role_id") if not attente_oral_role_id or not candidature_a_faire_role_id or not citoyen_role_id: await message.channel.send("❌ Erreur de configuration : Impossible d'attribuer les rôles, ils ne sont pas tous définis dans `/ticket_whitelist_config`.", delete_after=10) return attente_oral_role = message.guild.get_role(int(attente_oral_role_id)) candidature_a_faire_role = message.guild.get_role(int(candidature_a_faire_role_id)) citoyen_role = message.guild.get_role(int(citoyen_role_id)) if (attente_oral_role and attente_oral_role in user.roles) or "oral-à-faire" in message.channel.name.lower(): # DEUXIÈME VALIDATION -> ACCEPTÉ / CITOYEN if attente_oral_role: await user.remove_roles(attente_oral_role) if citoyen_role: await user.add_roles(citoyen_role) # Mise à jour du nom du salon new_name = f"accepté-{user.display_name}".replace(" ", "-").lower()[:100] wait_time = check_channel_rename_rate_limit(message.channel.id) log_channel_rename(message.channel.id) asyncio.create_task(message.channel.edit(name=new_name)) # Confirmation dans le salon (Feedback visuel) embed_val = disnake.Embed( title="🎉 Whitelist Validée !", description=f"Félicitations {user.mention}, ton entretien oral a été validé par {message.author.mention} !\nTu es désormais **Citoyen**.", color=disnake.Color.green() ) await message.channel.send(embed=embed_val) # DM au joueur try: embed_dm = disnake.Embed( title="🎉 Félicitations - Citoyenneté Validée !", description=f"Nous avons le plaisir de t'annoncer que ton passage sur **{message.guild.name}** a été validé avec succès !", color=disnake.Color.green() ) embed_dm.add_field(name="🏙️ Nouveau Statut", value="Citoyen", inline=True) embed_dm.add_field(name="📍 Serveur", value=message.guild.name, inline=True) embed_dm.add_field(name="📋 Prochaine étape", value="Tu peux désormais te connecter au serveur et commencer ton aventure RP. N'oublie pas de consulter les salons d'informations !", inline=False) if wait_time > 0: m, s = divmod(wait_time, 60) embed_dm.add_field(name="⏳ Nom du ticket", value=f"Le nom de ton ticket sera actualisé d'ici {m}m et {s}s environ (limite de sécurité Discord).", inline=False) embed_dm.add_field(name="🔗 Lien vers ton ticket", value=f"[Clique ici pour voir ton ticket]({message.channel.jump_url})", inline=False) embed_dm.set_footer(text=f"L'équipe {message.guild.name}") if message.guild.icon: embed_dm.set_thumbnail(url=message.guild.icon.url) await user.send(embed=embed_dm) except: pass else: # PREMIÈRE VALIDATION -> ORAL À FAIRE if candidature_a_faire_role: await user.remove_roles(candidature_a_faire_role) if attente_oral_role: await user.add_roles(attente_oral_role) # Mise à jour du nom du salon new_name = f"oral-à-faire-{user.display_name}".replace(" ", "-").lower()[:100] wait_time = check_channel_rename_rate_limit(message.channel.id) log_channel_rename(message.channel.id) asyncio.create_task(message.channel.edit(name=new_name)) # Confirmation dans le salon embed_val = disnake.Embed( title="📝 Étape Écrite Validée !", description=f"Ta candidature écrite a été validée par {message.author.mention}.\nTu passes désormais en **Attente Oral**.", color=disnake.Color.orange() ) await message.channel.send(embed=embed_val) # DM au joueur try: embed_dm = disnake.Embed( title="📝 Validation Étape Écrite", description=f"Ta candidature écrite sur **{message.guild.name}** a été examinée et validée par notre équipe !", color=disnake.Color.orange() ) embed_dm.add_field(name="🎤 Statut Actuel", value="Attente Oral", inline=True) embed_dm.add_field(name="🛠️ Action Requise", value="Merci de te rendre dans le salon vocal 'Attente Oral' dès que tu es disponible pour passer ton entretien.", inline=False) if wait_time > 0: m, s = divmod(wait_time, 60) embed_dm.add_field(name="⏳ Nom du ticket", value=f"Le nom de ton ticket sera actualisé d'ici {m}m et {s}s environ (limite de sécurité Discord).", inline=False) embed_dm.add_field(name="🔗 Lien vers ton ticket", value=f"[Clique ici pour voir ton ticket]({message.channel.jump_url})", inline=False) embed_dm.set_footer(text=f"L'équipe {message.guild.name}") if message.guild.icon: embed_dm.set_thumbnail(url=message.guild.icon.url) await user.send(embed=embed_dm) except: pass except Exception as e: kuby_logger.error(f"Erreur lors de la validation du ticket: {e}", exc_info=True) await message.channel.send(f"❌ Une erreur système est survenue : {e}") def setup(bot): bot.add_cog(Ticket(bot)) bot.add_view(TicketView()) bot.add_view(TicketManagementView()) bot.add_view(ClosedTicketView())