From 5a9e9b52d6cd48e0ef56b44dc418ec695b4d3c7e Mon Sep 17 00:00:00 2001 From: Mathis Date: Thu, 18 Jun 2026 22:12:01 +0200 Subject: [PATCH 1/3] Ajout: Syncronisation automatique des pp discord des agent O.K sur le site via Kuby --- .gitignore | 5 +- commandes/team_json.py | 176 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 commandes/team_json.py diff --git a/.gitignore b/.gitignore index 21803b9..649fd36 100755 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ logs/ .vscode/ .idea/ .DS_Store -.roo/ \ No newline at end of file +.roo/ +.continue/mcpServers/new-mcp-server.yaml +.continue/mcpServers/new-mcp-server-1.yaml +.gitignore diff --git a/commandes/team_json.py b/commandes/team_json.py new file mode 100644 index 0000000..0ee9bbb --- /dev/null +++ b/commandes/team_json.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +Team JSON Generator +=================== +Ce module parcourt les membres d'un serveur Discord, filtre ceux qui possèdent +un ou plusieurs rôles spécifiés, télécharge leurs avatars et génère un fichier +JSON compatible avec le site de production. + +Le JSON attendu ressemble à : +[ + { + "id": "123456789012345678", + "name": "Nom affiché", + "role": "Nom du rôle filtré", + "avatar": "123456789012345678.png", + "bio": "", + "stats": [], + "skills": [], + "social": {"discord":"","twitter":"",...} + }, + ... +] + +Le champ ``avatar`` correspond au nom de fichier stocké dans ``website/avatars/``. +""" + +import os +import json +import aiohttp +from pathlib import Path +from typing import List +import disnake +from disnake.ext import commands, tasks + +# ---------------------------------------------------------------------- +# Configuration – variables d'environnement (dans le .env du projet) +# ---------------------------------------------------------------------- +TARGET_GUILD_ID = os.getenv("TARGET_GUILD_ID") # ID du serveur contenant l'équipe +# Liste d'IDs de rôles séparés par des virgules (ex. "123,456,789") +TEAM_ROLE_IDS = os.getenv("TEAM_ROLE_IDS", "").split(",") if os.getenv("TEAM_ROLE_IDS") else [] + +# Dossiers où seront stockés les avatars et le JSON final +AVATAR_ROOT = Path(__file__).parents[2] / "website" / "avatars" +AVATAR_ROOT.mkdir(parents=True, exist_ok=True) + +JSON_OUTPUT = Path(__file__).parents[2] / "website" / "team.json" + +# ---------------------------------------------------------------------- +class TeamJSON(commands.Cog): + """Cog responsable de la génération du JSON d'équipe et du téléchargement des avatars.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + # Démarre la tâche quotidienne (synchronisation 24h/24) + self.sync_daily.start() + + # ------------------------------------------------------------------ + @tasks.loop(hours=24) + async def sync_daily(self): + """Tâche planifiée exécutée chaque jour (cron).""" + await self._generate_team(trigger="cron") + + @sync_daily.before_loop + async def before_sync(self): + await self.bot.wait_until_ready() + + # ------------------------------------------------------------------ + async def _download_avatar(self, session: aiohttp.ClientSession, member: disnake.Member) -> str: + """Télécharge l'avatar du *member* et le sauvegarde dans ``AVATAR_ROOT``. + Retourne le nom de fichier (ex. ``"123456789.png"``).""" + avatar_url = str(member.display_avatar.url) + filename = f"{member.id}.png" + dest_path = AVATAR_ROOT / filename + try: + async with session.get(avatar_url) as resp: + if resp.status == 200: + dest_path.write_bytes(await resp.read()) + else: + self.bot.logger.warning( + f"[TeamJSON] Avatar download failed for {member.id} – HTTP {resp.status}" + ) + except Exception as exc: + self.bot.logger.error(f"[TeamJSON] Exception while downloading avatar {member.id}: {exc}") + return filename + + # ------------------------------------------------------------------ + async def _generate_team(self, *, trigger: str = "manual") -> None: + """Construit le JSON de l'équipe et télécharge les avatars. + + Le JSON produit suit exactement le format attendu par le site de production. + Les champs ``stats``, ``skills`` et ``social`` sont laissés vides car ils ne + sont pas disponibles via l'API bot. + """ + if not TARGET_GUILD_ID: + self.bot.logger.error("[TeamJSON] TARGET_GUILD_ID non défini – arrêt du générateur.") + return + if not TEAM_ROLE_IDS: + self.bot.logger.error("[TeamJSON] TEAM_ROLE_IDS non défini – arrêt du générateur.") + return + + # Récupération de la guilde + guild = self.bot.get_guild(int(TARGET_GUILD_ID)) + if not guild: + try: + guild = await self.bot.fetch_guild(int(TARGET_GUILD_ID)) + except Exception as exc: + self.bot.logger.error(f"[TeamJSON] Impossible de récupérer la guilde {TARGET_GUILD_ID}: {exc}") + return + + # Convertir les IDs de rôle en entiers pour la comparaison + role_ids = {int(r) for r in TEAM_ROLE_IDS if r.strip()} + members_data: List[dict] = [] + + async with aiohttp.ClientSession() as session: + async for member in guild.fetch_members(limit=None): + if member.bot: + continue + # Intersection entre les rôles du membre et les rôles ciblés + member_role_ids = {r.id for r in member.roles} + intersect = member_role_ids.intersection(role_ids) + if not intersect: + continue # le membre ne possède aucun des rôles recherchés + + # On prend le premier rôle correspondant comme "role" dans le JSON + role_name = next((r.name for r in member.roles if r.id in intersect), "") + avatar_file = await self._download_avatar(session, member) + + entry = { + "id": str(member.id), + "name": member.display_name, + "role": role_name, + "avatar": avatar_file, + "bio": "", # non récupérable via l'API bot + "stats": [], + "skills": [], + "social": { + "discord": "", + "twitter": "", + "instagram": "", + "youtube": "", + "tiktok": "", + "github": "", + }, + } + members_data.append(entry) + + # Écriture du JSON final + try: + JSON_OUTPUT.parent.mkdir(parents=True, exist_ok=True) + JSON_OUTPUT.write_text( + json.dumps(members_data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + self.bot.logger.info( + f"[TeamJSON] JSON généré ({len(members_data)} membres) → {JSON_OUTPUT}" + ) + except Exception as exc: + self.bot.logger.error(f"[TeamJSON] Échec de l'écriture du JSON : {exc}") + + # ------------------------------------------------------------------ + @commands.slash_command( + name="generate_team_json", + description="Génère le JSON de l'équipe (admin only)" + ) + @commands.has_permissions(administrator=True) + async def generate_team_json(self, inter: disnake.ApplicationCommandInteraction): + """Commande manuelle pour (re)générer le JSON et les avatars.""" + await inter.response.defer(ephemeral=True) + await self._generate_team(trigger="manual") + await inter.edit_original_response( + content="✅ JSON de l'équipe généré et avatars synchronisés." + ) + +# ---------------------------------------------------------------------- +def setup(bot: commands.Bot): + bot.add_cog(TeamJSON(bot)) From 710ae134b0293bc76cdb4d50b013e9ddd41dde63 Mon Sep 17 00:00:00 2001 From: Mathis Date: Thu, 18 Jun 2026 23:12:51 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Fix:=20syst=C3=A8me=20de=20ticket=20&=20opt?= =?UTF-8?q?imisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commandes/ticket/feedback_view.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/commandes/ticket/feedback_view.py b/commandes/ticket/feedback_view.py index 7361091..c60536b 100644 --- a/commandes/ticket/feedback_view.py +++ b/commandes/ticket/feedback_view.py @@ -127,18 +127,21 @@ class RatingSelect(disnake.ui.Select): await interaction.response.defer() class FeedbackModal(disnake.ui.Modal): - def __init__(self, view): - super().__init__(components=[], title="Votre avis") + """Modal that collects a free‑form comment for a ticket feedback.""" + def __init__(self, view: FeedbackView): self.view = view self.comment = disnake.ui.TextInput( label="Commentaire", style=disnake.TextInputStyle.paragraph, placeholder="Dites-nous ce que vous avez pensé du support...", required=True, - max_length=1000 + max_length=1000, ) - self.add_item(self.comment) + super().__init__(title="Votre avis", components=[self.comment]) async def callback(self, interaction: disnake.Interaction): self.view.comment = self.comment.value - await interaction.response.send_message("Commentaire enregistré ! N'oubliez pas de valider avec 'Envoyer'.", ephemeral=True) + await interaction.response.send_message( + "Commentaire enregistré ! N'oubliez pas de valider avec « Envoyer ».", + ephemeral=True, + ) From 9051093fb3feb1b4a6500f66d8ecc21f54104844 Mon Sep 17 00:00:00 2001 From: Mathis Date: Thu, 25 Jun 2026 17:48:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Syst=C3=A8me=20WL=20modulaire=20+=20int?= =?UTF-8?q?=C3=A9gration=20des=20notations=20staffs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 0 commandes/ticket_whitelist.py | 1411 ++++++++++++++++++++++++++----- config/whitelist_questions.json | 9 + 3 files changed, 1224 insertions(+), 196 deletions(-) delete mode 100644 TODO.md create mode 100644 config/whitelist_questions.json diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e69de29..0000000 diff --git a/commandes/ticket_whitelist.py b/commandes/ticket_whitelist.py index e065ee9..986ba65 100644 --- a/commandes/ticket_whitelist.py +++ b/commandes/ticket_whitelist.py @@ -8,6 +8,7 @@ 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, ...]} @@ -94,108 +95,723 @@ async def get_ticket_user_id(channel: disnake.TextChannel) -> Optional[int]: return None -class TicketWhitelistConfigView(disnake.ui.View): - def __init__(self, guild_id: int): - super().__init__(timeout=60) +# ---- 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 - async def prompt_for_id(self, interaction: disnake.Interaction, prompt_text: str, is_category: bool = False): - await interaction.response.send_message(prompt_text, ephemeral=True) - - def check(m): - return m.author == interaction.user and m.channel == interaction.channel + 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: - msg = await interaction.client.wait_for('message', check=check, timeout=30) - target = None - if is_category: - if msg.content.isdigit(): - target = disnake.utils.get(interaction.guild.categories, id=int(msg.content)) - if not target: - await interaction.followup.send("❌ Catégorie introuvable avec cet ID.", ephemeral=True) - return None - else: - await interaction.followup.send("❌ Veuillez envoyer l'ID (les chiffres) de la catégorie.", ephemeral=True) - return None + 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: - if msg.role_mentions: - target = msg.role_mentions[0] - elif msg.content.isdigit(): - target = interaction.guild.get_role(int(msg.content)) + 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}" + ) + ) + ) - if not target: - await interaction.followup.send("❌ ID ou mention invalide.", ephemeral=True) - return None - - try: await msg.delete() - except: pass - return target - - except __import__("asyncio").TimeoutError: - await interaction.followup.send("❌ Temps écoulé.", ephemeral=True) - return None + 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)] - @ui.button(label="Catégorie Tickets", style=disnake.ButtonStyle.primary, row=0, emoji="📁") - async def set_category(self, interaction: disnake.Interaction, button: ui.Button): - category = await self.prompt_for_id(interaction, "Envoie l'ID de la **catégorie** où créer les tickets :", is_category=True) - if category: - settings = load_settings(self.guild_id) - settings["ticket_category_id"] = category.id - save_settings(self.guild_id, settings) - await interaction.followup.send(f"✅ Catégorie configurée sur **{category.name}**", ephemeral=True) - @ui.button(label="Rôle Staff", style=disnake.ButtonStyle.primary, row=0, emoji="🛡️") - async def set_staff_role(self, interaction: disnake.Interaction, button: ui.Button): - role = await self.prompt_for_id(interaction, "Mentionne le **rôle Staff** ou envoie son ID :") - if role: - settings = load_settings(self.guild_id) - settings["staff_role_id"] = role.id - save_settings(self.guild_id, settings) - await interaction.followup.send(f"✅ Rôle Staff configuré sur **{role.name}**", ephemeral=True) - - @ui.button(label="Rôle Équipe Staff", style=disnake.ButtonStyle.primary, row=0, emoji="👥") - async def set_team_staff_role(self, interaction: disnake.Interaction, button: ui.Button): - role = await self.prompt_for_id(interaction, "Mentionne le **rôle Équipe Staff** (à ping pour les nouveaux tickets) ou envoie son ID :") - if role: - settings = load_settings(self.guild_id) - settings["team_staff_role_id"] = role.id - save_settings(self.guild_id, settings) - await interaction.followup.send(f"✅ Rôle Équipe Staff configuré sur **{role.name}**", ephemeral=True) - - @ui.button(label="Rôle Attente Oral", style=disnake.ButtonStyle.secondary, row=1, emoji="🎤") - async def set_attente_oral_role(self, interaction: disnake.Interaction, button: ui.Button): - role = await self.prompt_for_id(interaction, "Mentionne le rôle **Attente Oral** ou envoie son ID :") - if role: - settings = load_settings(self.guild_id) - settings["attente_oral_role_id"] = role.id - save_settings(self.guild_id, settings) - await interaction.followup.send(f"✅ Rôle Attente Oral configuré sur **{role.name}**", ephemeral=True) - - @ui.button(label="Rôle Candidature à faire", style=disnake.ButtonStyle.secondary, row=1, emoji="📝") - async def set_candidature_a_faire_role(self, interaction: disnake.Interaction, button: ui.Button): - role = await self.prompt_for_id(interaction, "Mentionne le rôle **Candidature à faire** ou envoie son ID :") - if role: - settings = load_settings(self.guild_id) - settings["candidature_a_faire_role_id"] = role.id - save_settings(self.guild_id, settings) - await interaction.followup.send(f"✅ Rôle Candidature à faire configuré sur **{role.name}**", ephemeral=True) - - @ui.button(label="Rôle Citoyen", style=disnake.ButtonStyle.success, row=1, emoji="🏙️") - async def set_citoyen_role(self, interaction: disnake.Interaction, button: ui.Button): - role = await self.prompt_for_id(interaction, "Mentionne le rôle **Citoyen** ou envoie son ID :") - if role: - settings = load_settings(self.guild_id) - settings["citoyen_role_id"] = role.id - save_settings(self.guild_id, settings) - await interaction.followup.send(f"✅ Rôle Citoyen configuré sur **{role.name}**", ephemeral=True) class ContinueFormView(disnake.ui.View): """Vue avec un bouton pour continuer à l'étape suivante""" - def __init__(self, current_step, step1_data=None, step2_part1_data=None, step2_part2_data=None): + 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 @@ -204,11 +820,11 @@ class ContinueFormView(disnake.ui.View): 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)) + 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)) + 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)) + 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: @@ -217,94 +833,223 @@ class ContinueFormView(disnake.ui.View): # 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): + 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=[ - disnake.ui.TextInput(label="Pseudo Epic (Fortnite)", custom_id="pseudo", required=True, max_length=16), - disnake.ui.TextInput(label="Âge", custom_id="age", required=True, max_length=2), - disnake.ui.TextInput(label="Expérience RP ?", custom_id="experience", style=disnake.TextInputStyle.paragraph, required=True, max_length=50), - disnake.ui.TextInput(label="Comment as-tu découvert le serveur ?", custom_id="decouverte", required=True, max_length=20), - disnake.ui.TextInput(label="As-tu un micro de qualité ?", custom_id="micro", required=True, max_length=3), - ] + 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, step1_data=interaction.text_values) + 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): + 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=[ - disnake.ui.TextInput(label="Prénom", custom_id="prenom", required=True), - disnake.ui.TextInput(label="Nom", custom_id="nom", required=True), - disnake.ui.TextInput(label="Âge du personnage", custom_id="age_perso", required=True), - disnake.ui.TextInput(label="Genre", custom_id="genre", required=True, max_length=5), - disnake.ui.TextInput(label="Date et lieu de naissance", custom_id="naissance", required=True), - ] + 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, step1_data=self.step1_data, step2_part1_data=interaction.text_values) + 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): + 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=[ - disnake.ui.TextInput(label="Traits de caractère", custom_id="traits", required=True), - disnake.ui.TextInput(label="Nationalité", custom_id="nationalite", required=True), - disnake.ui.TextInput(label="Skin RP", custom_id="skin_rp", required=True), - disnake.ui.TextInput(label="Métier souhaité", custom_id="metier_souhaite", required=True), - ] + 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, step1_data=self.step1_data, step2_part1_data=self.step2_part1_data, step2_part2_data=interaction.text_values) + 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): + 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=[ - disnake.ui.TextInput( - label="Histoire du personnage", - custom_id="histoire", - style=disnake.TextInputStyle.paragraph, - required=True, - min_length=425, - max_length=500 - ), - disnake.ui.TextInput(label="Objectifs RP (court et long terme)", custom_id="objectifs", style=disnake.TextInputStyle.paragraph, required=True, max_length=100), - ] + components=build_components_for_step(questions), ) + async def callback(self, interaction: disnake.ModalInteraction): """Création du ticket après validation""" guild = interaction.guild @@ -348,6 +1093,23 @@ class TicketStep3(disnake.ui.Modal): 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) @@ -357,28 +1119,25 @@ class TicketStep3(disnake.ui.Modal): s2p2 = self.step2_part2_data s3 = interaction.text_values - embed.add_field(name="📋 - Informations Personnelles", value=( - f"> - Pseudo Epic : {s1['pseudo']}\n" - f"> - Âge : {s1['age']}\n" - f"> - Expérience RP : {s1['experience']}\n" - f"> - Découverte du serveur : {s1['decouverte']}\n" - f"> - Micro de qualité : {s1['micro']}" - ), inline=False) + 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=( - f"> - Prénom : {s2p1['prenom']}\n" - f"> - Nom : {s2p1['nom']}\n" - f"> - Âge du personnage : {s2p1['age_perso']}\n" - f"> - Genre : {s2p1['genre']}\n" - f"> - Naissance : {s2p1['naissance']}\n" - f"> - Traits de caractère : {s2p2['traits']}\n" - f"> - Nationalité : {s2p2['nationalite']}\n" - f"> - Skin RP : {s2p2['skin_rp']}\n" - f"> - Métier souhaité : {s2p2['metier_souhaite']}" - ), inline=False) - embed.add_field(name="📋 - Développement du Personnage", value=( - f"> - Histoire : {s3['histoire']}\n" - f"> - Objectifs : {s3['objectifs']}" + 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()) @@ -388,7 +1147,19 @@ class TicketStep3(disnake.ui.Modal): # Mettre à jour le pseudo du joueur si le bot a les permissions nécessaires if guild.me.guild_permissions.manage_nicknames: - new_nickname = f"{s2p1['prenom']} {s2p1['nom']} | {s1['pseudo']}" + 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) @@ -428,7 +1199,7 @@ class TicketManagementView(disnake.ui.View): 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 interaction.user.guild_permissions.administrator: + 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) @@ -440,9 +1211,77 @@ class TicketManagementView(disnake.ui.View): log_channel_rename(interaction.channel.id) asyncio.create_task(interaction.channel.edit(name=f"fermé-{owner.display_name}".replace(" ", "-").lower()[:100])) - # Envoi du message de fermeture dans le salon + # 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(f"🔒 Ce ticket a été fermé par {interaction.user.mention}.", view=new_view) + 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) @@ -459,10 +1298,23 @@ class TicketManagementView(disnake.ui.View): 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 interaction.user.guild_permissions.administrator: + 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 @@ -477,7 +1329,7 @@ class TicketManagementView(disnake.ui.View): 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""" + """Vue pour les tickets fermés avec les boutons Réouvrir et Supprimer définitivement""" def __init__(self): super().__init__(timeout=None) @@ -495,16 +1347,17 @@ class ClosedTicketView(disnake.ui.View): 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) - - 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 interaction.user.guild_permissions.administrator: + 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) @@ -512,7 +1365,6 @@ class ClosedTicketView(disnake.ui.View): return await interaction.followup.send("❌ Impossible de trouver le propriétaire du ticket à réouvrir.", ephemeral=True) try: - # On demande au staff quel statut remettre embed = disnake.Embed( title="♻️ Réouverture du Ticket", description=f"Choisissez le statut à appliquer pour **{owner.display_name}**.", @@ -525,6 +1377,106 @@ class ClosedTicketView(disnake.ui.View): 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): @@ -599,7 +1551,7 @@ class ReopenStatusView(disnake.ui.View): 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 interaction.user.guild_permissions.administrator: + 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: @@ -619,7 +1571,7 @@ class TicketView(disnake.ui.View): @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()) + await interaction.response.send_modal(TicketStep1(interaction.guild.id)) class Ticket(commands.Cog): """Commande pour envoyer l'embed de ticket""" @@ -634,9 +1586,11 @@ class Ticket(commands.Cog): self.bot.add_view(ClosedTicketView()) @commands.slash_command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.") - @commands.has_permissions(administrator=True) 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.", @@ -646,33 +1600,91 @@ class Ticket(commands.Cog): 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 (Admin)") - @commands.has_permissions(administrator=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""" - settings = load_settings(interaction.guild.id) + """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) - embed = disnake.Embed( - title="⚙️ Configuration Tickets Whitelist", - description="Utilisez les boutons ci-dessous pour lier la catégorie et les rôles.", - color=disnake.Color.blue() + 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 - 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") - - embed.add_field(name="📁 Catégorie Tickets", value=f"Id: {cat_id}" if cat_id else "❌ Non configuré", inline=True) - embed.add_field(name="🛡️ Staff", value=f"<@&{s_role_id}>" if s_role_id else "❌ Non configuré", inline=True) - embed.add_field(name="👥 Équipe Staff", value=f"<@&{ts_role_id}>" if ts_role_id else "❌ Non configuré", inline=True) - embed.add_field(name="🎤 Attente Oral", value=f"<@&{ao_role_id}>" if ao_role_id else "❌ Non configuré", inline=True) - embed.add_field(name="📝 Candidature à faire", value=f"<@&{caf_role_id}>" if caf_role_id else "❌ Non configuré", inline=True) - embed.add_field(name="🏙️ Citoyen", value=f"<@&{c_role_id}>" if c_role_id else "❌ Non configuré", inline=True) - - await interaction.response.send_message(embed=embed, view=TicketWhitelistConfigView(interaction.guild.id), ephemeral=True) + # 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): @@ -687,25 +1699,32 @@ class Ticket(commands.Cog): # Détection du format du topic du ticket via helper (robuste) user_id = await get_ticket_user_id(message.channel) if not user_id: - await message.channel.send("❌ Debug: Impossible de récupérer l'ID utilisateur via le topic (même après refresh API).") return settings = load_settings(message.guild.id) staff_role_id = settings.get("staff_role_id") - # Vérification si l'auteur est staff ou admin 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 message.author.guild_permissions.administrator: - if "validé" in message.content.lower(): - await message.channel.send("❌ Debug: Permission refusée. Vous n'êtes ni Staff ni Admin.") + if not is_staff and not is_staff_or_admin(message.author, message.guild): return try: - # L'ID est déjà trouvé par le helper ci-dessus - user_id = user_id + # 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): @@ -743,7 +1762,7 @@ class Ticket(commands.Cog): ) await message.channel.send(embed=embed_val) - # DM au joueur (RESTAURATION DESIGN CAPTURE) + # DM au joueur try: embed_dm = disnake.Embed( title="🎉 Félicitations - Citoyenneté Validée !", @@ -782,7 +1801,7 @@ class Ticket(commands.Cog): ) await message.channel.send(embed=embed_val) - # DM au joueur (RESTAURATION DESIGN CAPTURE) + # DM au joueur try: embed_dm = disnake.Embed( title="📝 Validation Étape Écrite", diff --git a/config/whitelist_questions.json b/config/whitelist_questions.json new file mode 100644 index 0000000..2cf228b --- /dev/null +++ b/config/whitelist_questions.json @@ -0,0 +1,9 @@ +{ + "questions": [ + {"label": "Pseudo Epic (Fortnite)", "custom_id": "pseudo", "placeholder": "JohnDoe", "required": true, "style": "short", "max_length": 16}, + {"label": "Âge", "custom_id": "age", "placeholder": "18", "required": true, "style": "short", "max_length": 2}, + {"label": "Expérience RP ?", "custom_id": "experience", "placeholder": "Oui/Non", "required": true, "style": "paragraph", "max_length": 50}, + {"label": "Comment as‑tu découvert le serveur ?", "custom_id": "decouverte", "placeholder": "", "required": true, "style": "short", "max_length": 20}, + {"label": "As‑tu un micro de qualité ?", "custom_id": "micro", "placeholder": "", "required": true, "style": "short", "max_length": 3} + ] +}