2026-05-06 17:07:09 +02:00
import disnake
2026-03-29 19:29:17 +02:00
import re
2026-05-06 17:07:09 +02:00
from disnake . ext import commands
from disnake import ui
2026-03-14 15:40:19 +01:00
import json
import os
2026-03-28 21:58:44 +01:00
import asyncio
2026-03-14 15:40:19 +01:00
from typing import Optional
2026-03-28 21:58:44 +01:00
from src . logger import kuby_logger
2026-04-02 19:06:50 +02:00
import time
2026-06-25 17:48:12 +02:00
from datetime import datetime , timezone
2026-07-06 16:08:14 +02:00
from utils . premium import check_premium_tier
2026-04-02 19:06:50 +02:00
# 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-03-14 15:40:19 +01:00
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 )
2026-03-14 15:40:19 +01:00
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 ] :
2026-04-02 19:06:50 +02:00
""" 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)
2026-04-02 19:06:50 +02:00
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
2026-06-25 17:48:12 +02:00
# ---- 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 ) :
2026-03-14 15:40:19 +01:00
self . guild_id = guild_id
2026-06-25 17:48:12 +02:00
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 "
)
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
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 ] ,
)
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
async def callback ( self , interaction : disnake . ModalInteraction ) :
2026-03-14 15:40:19 +01:00
try :
2026-06-25 17:48:12 +02:00
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
2026-03-14 15:40:19 +01:00
else :
2026-06-25 17:48:12 +02:00
q . pop ( " placeholder " , None )
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
if style :
q [ " style " ] = style
else :
q . pop ( " style " , None )
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
if max_length is not None :
q [ " max_length " ] = max_length
else :
q . pop ( " max_length " , None )
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
if min_length is not None :
q [ " min_length " ] = min_length
else :
q . pop ( " min_length " , None )
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
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 ( )
2026-03-14 15:40:19 +01:00
settings = load_settings ( self . guild_id )
2026-06-25 17:48:12 +02:00
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 )
2026-03-14 15:40:19 +01:00
save_settings ( self . guild_id , settings )
2026-06-25 17:48:12 +02:00
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** \n ID 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)** \n Configuré : " + ( 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)** \n Configuré : " + ( 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)** \n Configurez, 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** \n Nombre 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)** \n Nombre 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)** \n Nombre 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** \n Nombre 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 ' ) } ** \n ID: ` { 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 """
2026-06-25 17:48:12 +02:00
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
2026-06-25 17:48:12 +02:00
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 " )
2026-05-09 18:42:42 +02:00
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 :
2026-06-25 17:48:12 +02:00
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 :
2026-06-25 17:48:12 +02:00
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 :
2026-06-25 17:48:12 +02:00
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
2026-06-25 17:48:12 +02:00
# -------------------------
# 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 """
2026-06-25 17:48:12 +02:00
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 " ,
2026-06-25 17:48:12 +02:00
components = build_components_for_step ( questions ) ,
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-06-25 17:48:12 +02: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 """
2026-06-25 17:48:12 +02:00
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) """
2026-06-25 17:48:12 +02:00
def __init__ ( self , step1_data , guild_id : int ) :
2026-02-07 14:42:39 +01:00
self . step1_data = step1_data
2026-06-25 17:48:12 +02:00
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 " ,
2026-06-25 17:48:12 +02:00
components = build_components_for_step ( questions ) ,
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-06-25 17:48:12 +02: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 """
2026-06-25 17:48:12 +02:00
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) """
2026-06-25 17:48:12 +02:00
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
2026-06-25 17:48:12 +02:00
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 " ,
2026-06-25 17:48:12 +02:00
components = build_components_for_step ( questions ) ,
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-06-25 17:48:12 +02: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 """
2026-06-25 17:48:12 +02:00
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 """
2026-06-25 17:48:12 +02:00
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
2026-06-25 17:48:12 +02:00
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 " ,
2026-06-25 17:48:12 +02:00
components = build_components_for_step ( questions ) ,
2026-05-06 17:07:09 +02:00
)
2026-02-07 14:42:39 +01:00
2026-06-25 17:48:12 +02: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
2026-03-14 15:40:19 +01:00
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 :
2026-03-14 15:40:19 +01:00
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 ) )
2026-03-14 15:40:19 +01:00
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 } " :
2026-03-14 15:40:19 +01:00
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
}
2026-03-14 15:40:19 +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
)
2026-06-25 17:48:12 +02:00
# 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
2026-06-25 17:48:12 +02:00
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 = (
2026-06-25 17:48:12 +02:00
format_answers ( questions_step2_part1 , s2p1 ) + " \n " + format_answers ( questions_step2_part2 , s2p2 )
2026-02-07 14:42:39 +01:00
) , inline = False )
2026-06-25 17:48:12 +02:00
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
2026-03-14 15:40:19 +01:00
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 :
2026-06-25 17:48:12 +02:00
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 ]
2026-03-14 15:40:19 +01:00
try : await user . edit ( nick = new_nickname )
2026-05-06 17:07:09 +02:00
except disnake . Forbidden : pass
2026-03-14 15:40:19 +01:00
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 """
2026-03-14 15:40:19 +01:00
def __init__ ( self ) :
2026-02-07 14:42:39 +01:00
super ( ) . __init__ ( timeout = None )
2026-03-28 21:58:44 +01:00
2026-05-06 17:07:09 +02:00
async def get_ticket_owner ( self , interaction : disnake . Interaction ) :
2026-04-02 19:06:50 +02:00
""" Récupère le propriétaire du ticket via le helper robuste """
owner_id = await get_ticket_user_id ( interaction . channel )
if owner_id :
2026-03-28 21:58:44 +01:00
try :
member = interaction . guild . get_member ( owner_id )
if not member :
2026-03-29 19:29:17 +02:00
member = await interaction . guild . fetch_member ( owner_id )
2026-03-28 21:58:44 +01:00
return member
2026-03-29 19:29:17 +02:00
except :
2026-03-28 21:58:44 +01:00
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 " )
2026-05-09 18:42:42 +02:00
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 """
2026-03-28 22:17:17 +01:00
await interaction . response . defer ( ephemeral = True )
2026-03-14 15:40:19 +01:00
settings = load_settings ( interaction . guild . id )
staff_role_id = settings . get ( " staff_role_id " )
2026-03-28 22:17:17 +01:00
is_staff = any ( r . id == int ( staff_role_id ) for r in interaction . user . roles ) if staff_role_id else False
2026-06-25 17:48:12 +02:00
if not is_staff and not is_staff_or_admin ( interaction . user , interaction . guild ) :
2026-03-28 22:17:17 +01:00
return await interaction . followup . send ( " ❌ Seuls les staffs peuvent fermer un ticket. " , ephemeral = True )
2026-03-14 15:40:19 +01:00
2026-03-28 21:58:44 +01:00
owner = await self . get_ticket_owner ( interaction )
if not owner :
2026-03-28 22:17:17 +01:00
return await interaction . followup . send ( " ❌ Impossible de trouver le propriétaire du ticket à fermer. " , ephemeral = True )
2026-02-07 14:42:39 +01:00
2026-03-28 22:17:17 +01:00
try :
await interaction . channel . set_permissions ( owner , read_messages = False , send_messages = False )
2026-04-02 19:06:50 +02:00
log_channel_rename ( interaction . channel . id )
asyncio . create_task ( interaction . channel . edit ( name = f " fermé- { owner . display_name } " . replace ( " " , " - " ) . lower ( ) [ : 100 ] ) )
2026-03-28 22:17:17 +01:00
2026-06-25 17:48:12 +02:00
# 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). " )
2026-03-28 22:17:17 +01:00
new_view = ClosedTicketView ( )
2026-06-25 17:48:12 +02:00
await interaction . channel . send ( embed = close_embed , view = new_view )
2026-03-28 22:17:17 +01:00
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 )
2026-03-28 22:17:17 +01:00
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 " )
2026-05-09 18:42:42 +02:00
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 """
2026-03-28 22:17:17 +01:00
await interaction . response . defer ( ephemeral = False )
2026-03-14 15:40:19 +01:00
settings = load_settings ( interaction . guild . id )
staff_role_id = settings . get ( " staff_role_id " )
2026-03-28 22:17:17 +01:00
is_staff = any ( r . id == int ( staff_role_id ) for r in interaction . user . roles ) if staff_role_id else False
2026-06-25 17:48:12 +02:00
if not is_staff and not is_staff_or_admin ( interaction . user , interaction . guild ) :
2026-03-28 22:17:17 +01:00
return await interaction . followup . send ( " ❌ Seuls les staffs peuvent claim un ticket. " )
2026-02-07 14:42:39 +01:00
2026-03-28 22:17:17 +01:00
try :
2026-06-25 17:48:12 +02:00
# 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 } " )
2026-03-28 22:17:17 +01:00
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 ) :
2026-06-25 17:48:12 +02:00
""" Vue pour les tickets fermés avec les boutons Réouvrir et Supprimer définitivement """
2026-02-07 14:42:39 +01:00
2026-03-14 15:40:19 +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 ) :
2026-04-02 19:06:50 +02:00
""" Récupère le propriétaire du ticket via le helper robuste """
owner_id = await get_ticket_user_id ( interaction . channel )
if owner_id :
2026-03-28 21:58:44 +01:00
try :
member = interaction . guild . get_member ( owner_id )
if not member :
2026-03-29 19:29:17 +02:00
member = await interaction . guild . fetch_member ( owner_id )
2026-03-28 21:58:44 +01:00
return member
2026-03-29 19:29:17 +02:00
except :
2026-03-28 21:58:44 +01:00
return None
return None
2026-06-25 17:48:12 +02:00
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 " )
2026-05-09 18:42:42 +02:00
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 """
2026-03-28 22:17:17 +01:00
await interaction . response . defer ( ephemeral = True )
2026-06-25 17:48:12 +02:00
if not self . _is_staff ( interaction ) :
2026-03-28 22:17:17 +01:00
return await interaction . followup . send ( " ❌ Seuls les staffs peuvent réouvrir un ticket. " , ephemeral = True )
2026-03-14 15:40:19 +01:00
2026-03-28 21:58:44 +01:00
owner = await self . get_ticket_owner ( interaction )
if not owner :
2026-03-28 22:17:17 +01:00
return await interaction . followup . send ( " ❌ Impossible de trouver le propriétaire du ticket à réouvrir. " , ephemeral = True )
2026-03-14 15:40:19 +01:00
2026-03-28 22:17:17 +01:00
try :
2026-05-06 17:07:09 +02:00
embed = disnake . Embed (
2026-03-28 22:23:23 +01:00
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 ( )
2026-03-28 22:23:23 +01:00
)
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 )
2026-06-25 17:48:12 +02:00
@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 ) :
2026-03-28 22:23:23 +01:00
""" Vue pour choisir le statut lors d ' une réouverture """
2026-05-06 17:07:09 +02:00
def __init__ ( self , owner : disnake . Member ) :
2026-03-28 22:23:23 +01:00
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 )
2026-05-09 18:42:42 +02:00
async def set_written ( self , button : disnake . ui . Button , interaction : disnake . Interaction ) :
2026-03-28 22:23:23 +01:00
await self . apply_status ( interaction , " ouvert " )
2026-05-06 17:07:09 +02:00
@disnake.ui.button ( label = " Oral (À faire) " , style = disnake . ButtonStyle . primary )
2026-05-09 18:42:42 +02:00
async def set_oral ( self , button : disnake . ui . Button , interaction : disnake . Interaction ) :
2026-03-28 22:23:23 +01:00
await self . apply_status ( interaction , " oral-à-faire " )
2026-05-06 17:07:09 +02:00
@disnake.ui.button ( label = " Accepté " , style = disnake . ButtonStyle . success )
2026-05-09 18:42:42 +02:00
async def set_accepted ( self , button : disnake . ui . Button , interaction : disnake . Interaction ) :
2026-03-28 22:23:23 +01:00
await self . apply_status ( interaction , " accepté " )
2026-05-06 17:07:09 +02:00
async def apply_status ( self , interaction : disnake . Interaction , prefix : str ) :
2026-03-28 22:23:23 +01:00
await interaction . response . defer ( ephemeral = True )
try :
2026-03-29 19:29:17 +02:00
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
2026-03-28 22:23:23 +01:00
await interaction . channel . set_permissions ( self . owner , read_messages = True , send_messages = True )
new_name = f " { prefix } - { self . owner . display_name } " . replace ( " " , " - " ) . lower ( ) [ : 100 ]
2026-04-02 19:06:50 +02:00
log_channel_rename ( interaction . channel . id )
asyncio . create_task ( interaction . channel . edit ( name = new_name , topic = f " Ticket de { self . owner . id } " ) )
2026-03-28 22:17:17 +01:00
new_view = TicketManagementView ( )
2026-03-29 19:29:17 +02:00
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 )
2026-03-28 22:23:23 +01:00
self . stop ( )
2026-03-28 22:17:17 +01:00
except Exception as e :
2026-03-29 19:29:17 +02:00
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 " )
2026-05-09 18:42:42 +02:00
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 """
2026-03-28 22:17:17 +01:00
await interaction . response . defer ( ephemeral = True )
2026-03-14 15:40:19 +01:00
settings = load_settings ( interaction . guild . id )
staff_role_id = settings . get ( " staff_role_id " )
2026-03-28 22:17:17 +01:00
is_staff = any ( r . id == int ( staff_role_id ) for r in interaction . user . roles ) if staff_role_id else False
2026-06-25 17:48:12 +02:00
if not is_staff and not is_staff_or_admin ( interaction . user , interaction . guild ) :
2026-03-28 22:17:17 +01:00
return await interaction . followup . send ( " ❌ Seuls les staffs peuvent supprimer un ticket. " , ephemeral = True )
2026-03-14 15:40:19 +01:00
2026-03-28 22:17:17 +01:00
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 " )
2026-05-09 18:42:42 +02:00
async def open_ticket ( self , button : disnake . ui . Button , interaction : disnake . Interaction ) :
2026-02-07 14:42:39 +01:00
""" Affiche le premier formulaire """
2026-06-25 17:48:12 +02:00
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
2026-03-28 21:58:44 +01:00
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 """
2026-06-25 17:48:12 +02:00
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 )
2026-06-25 17:48:12 +02:00
@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 ) :
2026-06-25 17:48:12 +02:00
""" 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 )
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
container = build_config_container ( interaction . guild )
await interaction . response . send_message (
components = [ container ] ,
flags = disnake . MessageFlags ( is_components_v2 = True ) ,
ephemeral = True
2026-03-14 15:40:19 +01:00
)
2026-06-25 17:48:12 +02:00
@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
2026-03-14 15:40:19 +01:00
2026-06-25 17:48:12 +02:00
# 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-03-14 15:40:19 +01:00
2026-02-07 14:42:39 +01:00
@commands.Cog.listener ( )
async def on_message ( self , message ) :
2026-03-28 22:12:36 +01:00
""" 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
2026-03-28 22:12:36 +01:00
# On ne traite que si le message contient "validé"
if " validé " not in message . content . lower ( ) :
return
2026-04-02 19:06:50 +02:00
# 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
2026-03-14 15:40:19 +01:00
settings = load_settings ( message . guild . id )
staff_role_id = settings . get ( " staff_role_id " )
2026-03-28 22:12:36 +01:00
is_staff = False
if staff_role_id :
is_staff = any ( r . id == int ( staff_role_id ) for r in message . author . roles )
2026-06-25 17:48:12 +02:00
if not is_staff and not is_staff_or_admin ( message . author , message . guild ) :
2026-03-14 15:40:19 +01:00
return
2026-03-28 22:12:36 +01:00
try :
2026-06-25 17:48:12 +02:00
# 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 :
2026-03-29 19:29:17 +02:00
user = await message . guild . fetch_member ( user_id )
2026-05-06 17:07:09 +02:00
except ( disnake . NotFound , disnake . HTTPException ) :
2026-03-29 19:29:17 +02:00
await message . channel . send ( " ❌ Utilisateur introuvable. A-t-il quitté le serveur ? " , delete_after = 10 )
2026-03-28 22:12:36 +01:00
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 ) )
2026-03-29 19:29:17 +02:00
if ( attente_oral_role and attente_oral_role in user . roles ) or " oral-à-faire " in message . channel . name . lower ( ) :
2026-03-28 22:12:36 +01:00
# 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 ]
2026-04-02 19:06:50 +02:00
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 ) )
2026-03-28 22:12:36 +01:00
# Confirmation dans le salon (Feedback visuel)
2026-05-06 17:07:09 +02:00
embed_val = disnake . Embed (
2026-03-28 22:12:36 +01:00
title = " 🎉 Whitelist Validée ! " ,
description = f " Félicitations { user . mention } , ton entretien oral a été validé par { message . author . mention } ! \n Tu es désormais **Citoyen**. " ,
2026-05-06 17:07:09 +02:00
color = disnake . Color . green ( )
2026-03-28 22:12:36 +01:00
)
await message . channel . send ( embed = embed_val )
2026-06-25 17:48:12 +02:00
# DM au joueur
2026-03-28 22:12:36 +01:00
try :
2026-05-06 17:07:09 +02:00
embed_dm = disnake . Embed (
2026-03-28 22:12:36 +01:00
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 ( )
2026-03-28 22:12:36 +01:00
)
embed_dm . add_field ( name = " 🏙️ Nouveau Statut " , value = " Citoyen " , inline = True )
embed_dm . add_field ( name = " 📍 Serveur " , value = message . guild . name , inline = True )
2026-03-28 22:23:23 +01:00
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 )
2026-04-02 19:06:50 +02:00
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 )
2026-03-28 22:23:23 +01:00
embed_dm . add_field ( name = " 🔗 Lien vers ton ticket " , value = f " [Clique ici pour voir ton ticket]( { message . channel . jump_url } ) " , inline = False )
2026-03-28 22:12:36 +01:00
embed_dm . set_footer ( text = f " L ' équipe { message . guild . name } " )
2026-03-28 22:23:23 +01:00
if message . guild . icon : embed_dm . set_thumbnail ( url = message . guild . icon . url )
2026-03-28 22:12:36 +01:00
await user . send ( embed = embed_dm )
except : pass
2026-03-15 16:53:38 +01:00
2026-03-28 22:12:36 +01:00
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 ]
2026-04-02 19:06:50 +02:00
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 ) )
2026-03-28 22:12:36 +01:00
# Confirmation dans le salon
2026-05-06 17:07:09 +02:00
embed_val = disnake . Embed (
2026-03-28 22:12:36 +01:00
title = " 📝 Étape Écrite Validée ! " ,
description = f " Ta candidature écrite a été validée par { message . author . mention } . \n Tu passes désormais en **Attente Oral**. " ,
2026-05-06 17:07:09 +02:00
color = disnake . Color . orange ( )
2026-03-28 22:12:36 +01:00
)
await message . channel . send ( embed = embed_val )
2026-06-25 17:48:12 +02:00
# DM au joueur
2026-03-28 22:12:36 +01:00
try :
2026-05-06 17:07:09 +02:00
embed_dm = disnake . Embed (
2026-03-28 22:12:36 +01:00
title = " 📝 Validation Étape Écrite " ,
2026-03-28 22:23:23 +01:00
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 ( )
2026-03-28 22:12:36 +01:00
)
2026-03-28 22:23:23 +01:00
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 )
2026-04-02 19:06:50 +02:00
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 )
2026-03-28 22:23:23 +01:00
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 )
2026-03-28 22:12:36 +01:00
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
2026-05-09 18:42:42 +02:00
def setup ( bot ) :
bot . add_cog ( Ticket ( bot ) )
2026-03-14 15:40:19 +01:00
bot . add_view ( TicketView ( ) )
bot . add_view ( TicketManagementView ( ) )
bot . add_view ( ClosedTicketView ( ) )