Kuby/commandes/ticket_whitelist.py

1831 lines
No EOL
86 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 : { "<guild_id>": {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())