Kuby/commandes/ticket_whitelist.py

1834 lines
86 KiB
Python
Raw Permalink Normal View History

2026-05-06 17:07:09 +02:00
import disnake
import re
2026-05-06 17:07:09 +02:00
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
2026-07-06 16:08:14 +02:00
from utils.premium import check_premium_tier
# 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
2026-05-25 19:08:45 +02:00
# 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)
2026-05-06 17:07:09 +02:00
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)
2026-05-06 17:07:09 +02:00
# 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)]
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
class ContinueFormView(disnake.ui.View):
2026-02-07 14:42:39 +01:00
"""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):
2026-02-07 14:42:39 +01:00
super().__init__(timeout=180.0) # Définir un timeout pour la vue
self.current_step = current_step
self.guild_id = guild_id
2026-02-07 14:42:39 +01:00
self.step1_data = step1_data
self.step2_part1_data = step2_part1_data
self.step2_part2_data = step2_part2_data
2026-05-06 17:07:09 +02:00
@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):
2026-02-07 14:42:39 +01:00
"""Passe à l'étape suivante du formulaire"""
if self.current_step == 1:
await interaction.response.send_modal(TicketStep2Part1(self.step1_data, self.guild_id))
2026-02-07 14:42:39 +01:00
elif self.current_step == 2:
await interaction.response.send_modal(TicketStep2Part2(self.step1_data, self.step2_part1_data, self.guild_id))
2026-02-07 14:42:39 +01:00
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))
2026-02-07 14:42:39 +01:00
# Vérifier si le message existe toujours avant de le modifier
try:
await interaction.message.edit(view=self)
2026-05-06 17:07:09 +02:00
except disnake.errors.NotFound:
2026-02-07 14:42:39 +01:00
# 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 [])]
2026-05-06 17:07:09 +02:00
class TicketStep1(disnake.ui.Modal):
2026-02-07 14:42:39 +01:00
"""Étape 1 du formulaire - Infos personnelles"""
def __init__(self, guild_id: int):
questions = load_whitelist_questions_for_guild(guild_id).get("step1", [])
2026-05-06 17:07:09 +02:00
super().__init__(
title="📋 Informations Personnelles",
custom_id="ticket_step1",
components=build_components_for_step(questions),
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
async def callback(self, interaction: disnake.ModalInteraction):
2026-02-07 14:42:39 +01:00
"""Envoie un bouton pour passer à l'étape 2"""
view = ContinueFormView(current_step=1, guild_id=interaction.guild.id, step1_data=interaction.text_values)
2026-02-07 14:42:39 +01:00
await interaction.response.send_message("✅ Première étape terminée ! Cliquez ci-dessous pour continuer.", view=view, ephemeral=True)
2026-05-06 17:07:09 +02:00
class TicketStep2Part1(disnake.ui.Modal):
2026-02-07 14:42:39 +01:00
"""Étape 2 du formulaire - Infos RP (Partie 1)"""
def __init__(self, step1_data, guild_id: int):
2026-02-07 14:42:39 +01:00
self.step1_data = step1_data
questions = load_whitelist_questions_for_guild(guild_id).get("step2_part1", [])
2026-05-06 17:07:09 +02:00
super().__init__(
title="👤 Infos sur ton personnage (Partie 1)",
custom_id="ticket_step2_part1",
components=build_components_for_step(questions),
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
async def callback(self, interaction: disnake.ModalInteraction):
2026-02-07 14:42:39 +01:00
"""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)
2026-02-07 14:42:39 +01:00
await interaction.response.send_message("✅ Deuxième partie terminée ! Cliquez ci-dessous pour continuer.", view=view, ephemeral=True)
2026-05-06 17:07:09 +02:00
class TicketStep2Part2(disnake.ui.Modal):
2026-02-07 14:42:39 +01:00
"""Étape 2 du formulaire - Infos RP (Partie 2)"""
def __init__(self, step1_data, step2_part1_data, guild_id: int):
2026-02-07 14:42:39 +01:00
self.step1_data = step1_data
self.step2_part1_data = step2_part1_data
questions = load_whitelist_questions_for_guild(guild_id).get("step2_part2", [])
2026-05-06 17:07:09 +02:00
super().__init__(
title="👤 Infos sur ton personnage (Partie 2)",
custom_id="ticket_step2_part2",
components=build_components_for_step(questions),
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
async def callback(self, interaction: disnake.ModalInteraction):
2026-02-07 14:42:39 +01:00
"""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)
2026-02-07 14:42:39 +01:00
await interaction.response.send_message("✅ Deuxième étape terminée ! Cliquez ci-dessous pour continuer.", view=view, ephemeral=True)
2026-05-06 17:07:09 +02:00
class TicketStep3(disnake.ui.Modal):
2026-02-07 14:42:39 +01:00
"""Étape 3 du formulaire - Histoire du personnage"""
def __init__(self, step1_data, step2_part1_data, step2_part2_data, guild_id: int):
2026-02-07 14:42:39 +01:00
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", [])
2026-05-06 17:07:09 +02:00
super().__init__(
title="📋 Développement du Personnage",
custom_id="ticket_step3",
components=build_components_for_step(questions),
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
async def callback(self, interaction: disnake.ModalInteraction):
2026-02-07 14:42:39 +01:00
"""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)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
category = disnake.utils.get(guild.categories, id=int(category_id))
2026-02-07 14:42:39 +01:00
if category is None:
return await interaction.response.send_message("❌ Erreur : La catégorie des tickets configurée n'existe plus !", ephemeral=True)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
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)
2026-02-07 14:42:39 +01:00
# 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)
2026-02-07 14:42:39 +01:00
# Définir les permissions du salon du ticket
overwrites = {
2026-05-06 17:07:09 +02:00
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)
2026-02-07 14:42:39 +01:00
}
# Créer un salon de ticket avec le nom [pseudo]-écrit
2026-02-07 14:42:39 +01:00
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}")
2026-02-07 14:42:39 +01:00
# Création de l'embed avec les infos du formulaire
2026-05-06 17:07:09 +02:00
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)
2026-02-07 14:42:39 +01:00
embed.add_field(name="👤 - Informations RP", value=(
format_answers(questions_step2_part1, s2p1) + "\n" + format_answers(questions_step2_part2, s2p2)
2026-02-07 14:42:39 +01:00
), inline=False)
embed.add_field(name="📋 - Développement du Personnage", value=format_answers(questions_step3, s3), inline=False)
2026-02-07 14:42:39 +01:00
# Envoyer l'embed avec les boutons d'actions pour les staff
await ticket_channel.send(embed=embed, view=TicketManagementView())
2026-02-07 14:42:39 +01:00
# 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
2026-02-07 14:42:39 +01:00
if len(new_nickname) > 32:
new_nickname = new_nickname[:32]
try: await user.edit(nick=new_nickname)
2026-05-06 17:07:09 +02:00
except disnake.Forbidden: pass
2026-02-07 14:42:39 +01:00
# Envoyer un message de confirmation
try:
await interaction.response.send_message(f"✅ Ticket ouvert : {ticket_channel.mention}", ephemeral=True)
2026-05-06 17:07:09 +02:00
except disnake.errors.NotFound:
2026-02-07 14:42:39 +01:00
await interaction.followup.send(f"✅ Ticket ouvert : {ticket_channel.mention}", ephemeral=True)
2026-05-06 17:07:09 +02:00
class TicketManagementView(disnake.ui.View):
2026-02-07 14:42:39 +01:00
"""Vue pour gérer le ticket avec les boutons Fermer, Réouvrir, Claim et Supprimer"""
def __init__(self):
2026-02-07 14:42:39 +01:00
super().__init__(timeout=None)
2026-05-06 17:07:09 +02:00
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
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
@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):
2026-02-07 14:42:39 +01:00
"""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)
2026-02-07 14:42:39 +01:00
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)
2026-05-06 17:07:09 +02:00
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)
2026-05-06 17:07:09 +02:00
@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):
2026-02-07 14:42:39 +01:00
"""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.")
2026-02-07 14:42:39 +01:00
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}")
2026-05-06 17:07:09 +02:00
class ClosedTicketView(disnake.ui.View):
"""Vue pour les tickets fermés avec les boutons Réouvrir et Supprimer définitivement"""
2026-02-07 14:42:39 +01:00
def __init__(self):
2026-02-07 14:42:39 +01:00
super().__init__(timeout=None)
2026-05-06 17:07:09 +02:00
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)
2026-05-06 17:07:09 +02:00
@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):
2026-02-07 14:42:39 +01:00
"""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:
2026-05-06 17:07:09 +02:00
embed = disnake.Embed(
title="♻️ Réouverture du Ticket",
description=f"Choisissez le statut à appliquer pour **{owner.display_name}**.",
2026-05-06 17:07:09 +02:00
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
2026-05-06 17:07:09 +02:00
class ReopenStatusView(disnake.ui.View):
"""Vue pour choisir le statut lors d'une réouverture"""
2026-05-06 17:07:09 +02:00
def __init__(self, owner: disnake.Member):
super().__init__(timeout=180)
self.owner = owner
2026-05-06 17:07:09 +02:00
@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")
2026-05-06 17:07:09 +02:00
@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")
2026-05-06 17:07:09 +02:00
@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é")
2026-05-06 17:07:09 +02:00
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)
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
@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):
2026-02-07 14:42:39 +01:00
"""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
2026-02-07 14:42:39 +01:00
2026-05-06 17:07:09 +02:00
class TicketView(disnake.ui.View):
2026-02-07 14:42:39 +01:00
"""Vue avec le bouton pour ouvrir le ticket"""
def __init__(self):
super().__init__(timeout=None)
2026-05-06 17:07:09 +02:00
@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):
2026-02-07 14:42:39 +01:00
"""Affiche le premier formulaire"""
await interaction.response.send_modal(TicketStep1(interaction.guild.id))
2026-02-07 14:42:39 +01:00
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())
2026-05-06 17:07:09 +02:00
@commands.slash_command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.")
2026-07-06 16:08:14 +02:00
@check_premium_tier()
2026-05-06 17:07:09 +02:00
async def ticket_whitelist(self, interaction: disnake.ApplicationCommandInteraction):
2026-02-07 14:42:39 +01:00
"""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)
2026-05-06 17:07:09 +02:00
embed = disnake.Embed(
2026-02-07 14:42:39 +01:00
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)")
2026-07-06 16:08:14 +02:00
@check_premium_tier()
2026-05-06 17:07:09 +02:00
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)
2026-02-07 14:42:39 +01:00
@commands.Cog.listener()
async def on_message(self, message):
"""Surveille les messages des staffs dans les tickets pour validation automatique"""
2026-02-07 14:42:39 +01:00
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}")
2026-02-07 14:42:39 +01:00
try:
user = await message.guild.fetch_member(user_id)
2026-05-06 17:07:09 +02:00
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)
2026-05-06 17:07:09 +02:00
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**.",
2026-05-06 17:07:09 +02:00
color=disnake.Color.green()
)
await message.channel.send(embed=embed_val)
# DM au joueur
try:
2026-05-06 17:07:09 +02:00
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 !",
2026-05-06 17:07:09 +02:00
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
2026-05-06 17:07:09 +02:00
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**.",
2026-05-06 17:07:09 +02:00
color=disnake.Color.orange()
)
await message.channel.send(embed=embed_val)
# DM au joueur
try:
2026-05-06 17:07:09 +02:00
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 !",
2026-05-06 17:07:09 +02:00
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}")
2026-02-07 14:42:39 +01:00
def setup(bot):
bot.add_cog(Ticket(bot))
bot.add_view(TicketView())
bot.add_view(TicketManagementView())
bot.add_view(ClosedTicketView())