feat(ticket): système de ticket recrutement avec questions et DM accept/refus

- Ajout catégorie recrutement avec questions personnalisées
- Modal dynamique affichant les questions lors de la création
- Envoi des réponses dans un salon staff configurable
- Fermeture avec boutons Accepter/Refuser et envoi DM automatique au candidat
- Mise à jour des modèles TicketCategory et TicketData

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lowei 2026-05-22 14:26:02 +02:00
parent db4876e085
commit b3db1ef9f5
2 changed files with 451 additions and 25 deletions

View file

@ -543,7 +543,10 @@ class CategorySelectionView(disnake.ui.View):
) )
return return
# Show priority modal # Check if this is a recruitment category
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(self.bot, self.config, category)
else:
modal = TicketPriorityModal(self.bot, self.config, category) modal = TicketPriorityModal(self.bot, self.config, category)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
except Exception as e: except Exception as e:
@ -702,6 +705,206 @@ class TicketPriorityModal(disnake.ui.Modal):
await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True) await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True)
class RecruitmentQuestionsModal(disnake.ui.Modal):
"""Modal pour les questions de recrutement"""
def __init__(self, bot, config, category: TicketCategory):
self.bot = bot
self.config = config
self.category = category
# Construire dynamiquement les inputs de questions
components = []
self.question_inputs = {}
for i, question in enumerate(category.questions):
input_field = disnake.ui.TextInput(
label=f"Question {i+1}",
custom_id=f"recruitment_q_{i}",
placeholder=question,
required=True,
max_length=1000,
style=disnake.TextInputStyle.paragraph
)
self.question_inputs[f"recruitment_q_{i}"] = input_field
components.append(input_field)
super().__init__(title=f"Recrutement: {category.name}", components=components)
async def callback(self, interaction: disnake.ModalInteraction):
await interaction.response.defer(ephemeral=True)
try:
# Collecter les réponses
responses = {}
for key, input_field in self.question_inputs.items():
responses[key] = interaction.text_values.get(key, "")
# Créer le ticket avec les réponses stockées
await self._create_recruitment_ticket(interaction, responses)
except Exception as e:
await interaction.followup.send(f"Erreur lors de la création du ticket: {e}", ephemeral=True)
async def _create_recruitment_ticket(self, interaction: disnake.Interaction, responses: Dict[str, str]):
"""Crée le ticket de recrutement"""
guild = interaction.guild
user = interaction.user
storage = get_storage()
# Build permissions
overwrites = {
guild.default_role: disnake.PermissionOverwrite(read_messages=False),
user: disnake.PermissionOverwrite(read_messages=True, send_messages=True),
guild.me: disnake.PermissionOverwrite(read_messages=True, send_messages=True)
}
# Add staff roles (category-specific roles override global ones)
staff_roles_to_add = self.category.staff_roles if self.category.staff_roles else self.config.staff_roles
for role_id in staff_roles_to_add:
try:
role = guild.get_role(int(role_id))
if role and role not in overwrites:
overwrites[role] = disnake.PermissionOverwrite(read_messages=True, send_messages=True)
except (ValueError, TypeError):
continue
# Create channel name
safe_username = "".join(c for c in user.display_name if not c.isdigit()).strip()
if not safe_username:
safe_username = "user"
safe_category = "".join(c for c in self.category.name if not c.isdigit()).strip()
channel_name = f"recrutement-{safe_username}"
try:
category_channel = None
if self.category.discord_category_id:
try:
category_channel = guild.get_channel(int(self.category.discord_category_id))
if category_channel and not isinstance(category_channel, disnake.CategoryChannel):
category_channel = None
except (ValueError, TypeError):
pass
if not category_channel and self.config.default_category:
try:
category_channel = guild.get_channel(int(self.config.default_category))
if category_channel and not isinstance(category_channel, disnake.CategoryChannel):
category_channel = None
except (ValueError, TypeError):
pass
ticket_channel = await guild.create_text_channel(
name=channel_name,
category=category_channel,
overwrites=overwrites,
topic=f"Recrutement de {user} | Catégorie: {self.category.name}"
)
except Exception as e:
await interaction.followup.send(f"Erreur: {e}", ephemeral=True)
return
# Create ticket data
ticket_data = TicketData(
channel_id=ticket_channel.id,
guild_id=guild.id,
user_id=user.id,
category_id=self.category.id,
priority=Priority.NORMAL,
status=TicketStatus.OPEN,
reason="Recrutement",
recruitment_responses=responses
)
storage.save_ticket(ticket_data)
# Build mentions for staff
mentions = [user.mention]
for role_id in staff_roles_to_add:
mentions.append(f"<@&{role_id}>")
mention_content = " ".join(mentions)
# Send welcome message in ticket channel
welcome_text = f"Bienvenue {user.mention} !\n\nMerci de votre intérêt pour rejoindre notre équipe.\n\n"
welcome_text += "Nos responsables vont analyser vos réponses et vous contacteront.\n\n"
# Afficher les questions et réponses
welcome_text += "**Vos réponses:**\n"
for i, question in enumerate(self.category.questions):
key = f"recruitment_q_{i}"
response = responses.get(key, "Non répondu")
welcome_text += f"> **{question}**\n> {response}\n\n"
# Create container with management buttons and recruitment responses
sections = [
disnake.ui.TextDisplay(content=welcome_text),
disnake.ui.Separator(divider=True)
]
# Add management buttons if staff
sections.append(
disnake.ui.Section(
"Actions",
accessory=disnake.ui.Button(
label="🔒 Fermer (Accepté/Refusé)",
style=disnake.ButtonStyle.green,
custom_id="recruitment_close"
)
)
)
container = disnake.ui.Container(*sections)
await ticket_channel.send(components=[disnake.ui.Container(
disnake.ui.TextDisplay(content=mention_content),
disnake.ui.Separator(divider=True),
*sections
)])
# Envoyer les réponses dans le salon staff si configuré
if self.category.responses_channel_id:
try:
responses_channel = guild.get_channel(self.category.responses_channel_id)
if responses_channel:
embed = disnake.Embed(
title=f"📝 Nouveau Candidature - {user}",
description=f"**Candidature dans** {ticket_channel.mention}",
color=disnake.Color.blue(),
timestamp=datetime.now()
)
embed.set_thumbnail(url=user.display_avatar.url)
for i, question in enumerate(self.category.questions):
key = f"recruitment_q_{i}"
response = responses.get(key, "Non répondu")
embed.add_field(
name=f"Q{i+1}: {question}",
value=response,
inline=False
)
embed.add_field(name="Candidat", value=user.mention, inline=True)
embed.add_field(name="Discord ID", value=str(user.id), inline=True)
await responses_channel.send(embed=embed)
except Exception as e:
print(f"Error sending recruitment responses to staff channel: {e}")
# Log
if self.config.log_channel_id:
log_channel = guild.get_channel(self.config.log_channel_id)
if log_channel:
log_embed = disnake.Embed(
title="🎓 Nouveau Ticket de Recrutement",
color=disnake.Color.purple(),
timestamp=datetime.now()
)
log_embed.add_field(name="Utilisateur", value=user.mention, inline=True)
log_embed.add_field(name="Catégorie", value=self.category.name, inline=True)
log_embed.add_field(name="Salon", value=ticket_channel.mention, inline=True)
await log_channel.send(embed=log_embed)
await interaction.followup.send(f"Ticket créé: {ticket_channel.mention}", ephemeral=True)
class SetCategoryModal(disnake.ui.Modal): class SetCategoryModal(disnake.ui.Modal):
"""Modal pour définir la catégorie Discord où les tickets seront créés""" """Modal pour définir la catégorie Discord où les tickets seront créés"""
@ -2101,7 +2304,39 @@ class AddCategoryModal(disnake.ui.Modal):
self.name_input = disnake.ui.TextInput(label="Nom", custom_id="add_cat_name", placeholder="Ex: Support", required=True) self.name_input = disnake.ui.TextInput(label="Nom", custom_id="add_cat_name", placeholder="Ex: Support", required=True)
self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph) self.desc_input = disnake.ui.TextInput(label="Description", custom_id="add_cat_desc", placeholder="Description...", required=False, style=disnake.TextInputStyle.paragraph)
self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5) self.emoji_input = disnake.ui.TextInput(label="Emoji", custom_id="add_cat_emoji", placeholder="🎫", required=False, max_length=5)
super().__init__(title="Ajouter une Catégorie", components=[self.name_input, self.desc_input, self.emoji_input]) # Recruitment fields
self.is_recruitment_input = disnake.ui.TextInput(
label="Recrutement? (oui/non)",
custom_id="add_cat_recruitment",
placeholder="non",
required=False,
max_length=5
)
self.questions_input = disnake.ui.TextInput(
label="Questions recrutement (une par ligne)",
custom_id="add_cat_questions",
placeholder="Quel age avez-vous?\nPourquoi voulez-vous rejoindre?\nExperiences precedentes?",
required=False,
style=disnake.TextInputStyle.paragraph
)
self.responses_channel_input = disnake.ui.TextInput(
label="ID salon pour les réponses",
custom_id="add_cat_responses_channel",
placeholder="ID du salon staff où envoyer les réponses",
required=False,
max_length=20
)
super().__init__(
title="Ajouter une Catégorie",
components=[
self.name_input,
self.desc_input,
self.emoji_input,
self.is_recruitment_input,
self.questions_input,
self.responses_channel_input
]
)
self.bot = bot self.bot = bot
self.storage = storage self.storage = storage
self.config = config self.config = config
@ -2114,18 +2349,39 @@ class AddCategoryModal(disnake.ui.Modal):
name_val = interaction.text_values.get("add_cat_name", "Nouvelle Catégorie") name_val = interaction.text_values.get("add_cat_name", "Nouvelle Catégorie")
desc_val = interaction.text_values.get("add_cat_desc", "") desc_val = interaction.text_values.get("add_cat_desc", "")
emoji_val = interaction.text_values.get("add_cat_emoji", "🎫") emoji_val = interaction.text_values.get("add_cat_emoji", "🎫")
is_recruitment_val = interaction.text_values.get("add_cat_recruitment", "non")
questions_val = interaction.text_values.get("add_cat_questions", "")
responses_channel_val = interaction.text_values.get("add_cat_responses_channel", "")
is_recruitment = is_recruitment_val.lower() in ["oui", "yes", "o", "y", "1", "true"]
questions = [q.strip() for q in questions_val.split("\n") if q.strip()] if questions_val else []
responses_channel_id = None
if responses_channel_val and responses_channel_val.isdigit():
responses_channel_id = int(responses_channel_val)
category = TicketCategory( category = TicketCategory(
id=cat_id, id=cat_id,
name=name_val, name=name_val,
description=desc_val, description=desc_val,
emoji=emoji_val emoji=emoji_val,
is_recruitment=is_recruitment,
questions=questions,
responses_channel_id=responses_channel_id
) )
self.config.categories.append(category) self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config) self.storage.save_config(interaction.guild_id, self.config)
await interaction.response.send_message(f"Catégorie **{name_val}** créée!", ephemeral=True) recruitment_info = ""
if is_recruitment:
recruitment_info = f"\n✅ Mode recrutement activé avec {len(questions)} question(s)"
if responses_channel_id:
recruitment_info += f"\n📝 Salon réponses: <#{responses_channel_id}>"
await interaction.response.send_message(
f"Catégorie **{name_val}** créée!{recruitment_info}",
ephemeral=True
)
class EditCategoryModal(disnake.ui.Modal): class EditCategoryModal(disnake.ui.Modal):
@ -2851,7 +3107,7 @@ class TicketCommands(commands.Cog):
if not ( if not (
str(custom_id).startswith("ticket_") str(custom_id).startswith("ticket_")
or str(custom_id).startswith("cat_") or str(custom_id).startswith("cat_")
or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"} or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel", "recruitment_close", "recruitment_accept", "recruitment_refuse"}
): ):
return return
@ -2900,7 +3156,10 @@ class TicketCommands(commands.Cog):
await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) await inter.response.send_message("Catégorie non trouvée.", ephemeral=True)
return return
# ouvrir directement le modal de priorité (même logique que CategorySelectionView) # Check if recruitment category with questions
if category.is_recruitment and category.questions:
modal = RecruitmentQuestionsModal(self.bot, config, category)
else:
modal = TicketPriorityModal(self.bot, config, category) modal = TicketPriorityModal(self.bot, config, category)
await inter.response.send_modal(modal) await inter.response.send_modal(modal)
except Exception as e: except Exception as e:
@ -2934,6 +3193,80 @@ class TicketCommands(commands.Cog):
view = TicketManagementView(self.bot, self.storage, config, ticket) view = TicketManagementView(self.bot, self.storage, config, ticket)
await view._transcript_callback(inter) await view._transcript_callback(inter)
# === Recrutement: Fermeture avec Accepté/Refusé ===
elif custom_id == "recruitment_close":
ticket = self.storage.load_ticket(inter.channel.id)
if not ticket:
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
return
category = config.get_category(ticket.category_id)
if not category or not category.is_recruitment:
# Si pas un ticket recrutement, utiliser la fermeture normale
view = TicketManagementView(self.bot, self.storage, config, ticket)
await view._close_callback(inter)
return
# Vérifier que c'est du staff
if not is_staff(inter, config, category):
await inter.response.send_message("Réservé au staff.", ephemeral=True)
return
await inter.response.send_message(
"Choisissez la décision pour cette candidature:",
components=[
disnake.ui.Container(
disnake.ui.Section(
"# 🎓 Décision de Recrutement",
accessory=disnake.ui.Button(
label="✅ Accepter",
style=disnake.ButtonStyle.green,
custom_id="recruitment_accept"
)
),
disnake.ui.Separator(divider=True),
disnake.ui.Section(
"Refuser la candidature",
accessory=disnake.ui.Button(
label="❌ Refuser",
style=disnake.ButtonStyle.red,
custom_id="recruitment_refuse"
)
)
)
],
flags=disnake.MessageFlags(is_components_v2=True),
ephemeral=True
)
elif custom_id == "recruitment_accept":
ticket = self.storage.load_ticket(inter.channel.id) if inter.channel else None
if not ticket:
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
return
category = config.get_category(ticket.category_id)
if not is_staff(inter, config, category):
await inter.response.send_message("Réservé au staff.", ephemeral=True)
return
await inter.response.defer()
await self._send_recruitment_decision(inter, ticket, category, accepted=True)
elif custom_id == "recruitment_refuse":
ticket = self.storage.load_ticket(inter.channel.id) if inter.channel else None
if not ticket:
await inter.response.send_message("Ticket non trouvé.", ephemeral=True)
return
category = config.get_category(ticket.category_id)
if not is_staff(inter, config, category):
await inter.response.send_message("Réservé au staff.", ephemeral=True)
return
await inter.response.defer()
await self._send_recruitment_decision(inter, ticket, category, accepted=False)
# === Fermeture via composants V2 (sans view=, donc custom_id) === # === Fermeture via composants V2 (sans view=, donc custom_id) ===
elif custom_id == "close_cancel": elif custom_id == "close_cancel":
await inter.response.defer() await inter.response.defer()
@ -3260,6 +3593,84 @@ class TicketCommands(commands.Cog):
except Exception as e: except Exception as e:
print(f"[Ticket] Error in _register_all_views: {e}") print(f"[Ticket] Error in _register_all_views: {e}")
async def _send_recruitment_decision(self, inter: disnake.Interaction, ticket, category, accepted: bool):
"""Envoie la décision de recrutement (Accepté/Refusé) au candidat via DM"""
try:
guild = inter.guild
user = guild.get_member(ticket.user_id)
if not user:
try:
user = await guild.fetch_member(ticket.user_id)
except Exception:
pass
# Déterminer le message selon la décision
if accepted:
title = "✅ Candidature Acceptée !"
color = disnake.Color.green()
description = (
f"Félicitations {user.mention} !\n\n"
f"Votre candidature pour **Boga Life** a été acceptée.\n\n"
f"Un responsable va bientôt vous contacter pour les prochaines étapes.\n\n"
f"Bienvenue dans l'équipe ! 🎉"
)
else:
title = "❌ Candidature Refusée"
color = disnake.Color.red()
description = (
f"Bonjour {user.mention},\n\n"
f"Après analyse de votre candidature, nous avons le regret de vous informer "
f"que celle-ci n'a pas été retenue pour le poste.\n\n"
f"Cette décision ne remet pas en cause vos qualités. N'hésitez pas à repostuler "
f"ultérieurement si vous êtes toujours intéressé(e).\n\n"
f"Nous vous souhaitons bonne continuation."
)
embed = disnake.Embed(
title=title,
description=description,
color=color,
timestamp=datetime.now()
)
embed.set_footer(text="Boga Life - Système de Recrutement")
# Envoyer le DM à l'utilisateur
if user:
try:
await user.send(embed=embed)
await inter.followup.send("✅ Décision envoyée au candidat par DM.", ephemeral=True)
except disnake.Forbidden:
await inter.followup.send("⚠️ Impossible d'envoyer un DM - l'utilisateur a ses messages privés fermés.", ephemeral=True)
except Exception as e:
await inter.followup.send(f"⚠️ Erreur lors de l'envoi du DM: {e}", ephemeral=True)
else:
await inter.followup.send("⚠️ Utilisateur introuvable.", ephemeral=True)
# Fermer le ticket
ticket.status = TicketStatus.CLOSED
ticket.closed_at = datetime.now()
self.storage.save_ticket(ticket)
# Envoyer un message dans le salon du ticket
close_embed = disnake.Embed(
title=f"🔒 Ticket Fermé - {'Accepté' if accepted else 'Refusé'}",
description=f"Candidature traitée par {inter.user.mention}",
color=color
)
close_embed.add_field(name="Decision", value="✅ Acceptee" if accepted else "❌ Refusee", inline=True)
# Supprimer le salon
try:
await inter.channel.delete(reason=f"Recrutement termine - {'Accepte' if accepted else 'Refuse'} par {inter.user}")
except Exception as e:
await inter.channel.send(embed=close_embed)
print(f"Error deleting recruitment channel: {e}")
except Exception as e:
kuby_logger.error(f"[TicketSystem] _send_recruitment_decision failed: {e}", exc_info=True)
if not inter.response.is_done():
await inter.followup.send("❌ Erreur lors de la decision de recrutement.", ephemeral=True)
# === COMMANDES === # === COMMANDES ===
@commands.slash_command(name="ticket", description="Ouvre le panel des tickets") @commands.slash_command(name="ticket", description="Ouvre le panel des tickets")

View file

@ -60,7 +60,10 @@ class TicketCategory:
priority_enabled: bool = True, priority_enabled: bool = True,
allow_claims: bool = True, allow_claims: bool = True,
survey_enabled: bool = True, survey_enabled: bool = True,
discord_category_id: Optional[int] = None discord_category_id: Optional[int] = None,
is_recruitment: bool = False,
questions: List[str] = None,
responses_channel_id: Optional[int] = None
): ):
self.id = id self.id = id
self.name = name self.name = name
@ -76,6 +79,9 @@ class TicketCategory:
self.allow_claims = allow_claims self.allow_claims = allow_claims
self.survey_enabled = survey_enabled self.survey_enabled = survey_enabled
self.discord_category_id = discord_category_id self.discord_category_id = discord_category_id
self.is_recruitment = is_recruitment
self.questions = questions or []
self.responses_channel_id = responses_channel_id
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert category to dictionary for storage""" """Convert category to dictionary for storage"""
@ -93,7 +99,10 @@ class TicketCategory:
"priority_enabled": self.priority_enabled, "priority_enabled": self.priority_enabled,
"allow_claims": self.allow_claims, "allow_claims": self.allow_claims,
"survey_enabled": self.survey_enabled, "survey_enabled": self.survey_enabled,
"discord_category_id": self.discord_category_id "discord_category_id": self.discord_category_id,
"is_recruitment": self.is_recruitment,
"questions": self.questions,
"responses_channel_id": self.responses_channel_id
} }
@classmethod @classmethod
@ -113,7 +122,10 @@ class TicketCategory:
priority_enabled=data.get("priority_enabled", True), priority_enabled=data.get("priority_enabled", True),
allow_claims=data.get("allow_claims", True), allow_claims=data.get("allow_claims", True),
survey_enabled=data.get("survey_enabled", True), survey_enabled=data.get("survey_enabled", True),
discord_category_id=data.get("discord_category_id") discord_category_id=data.get("discord_category_id"),
is_recruitment=data.get("is_recruitment", False),
questions=data.get("questions", []),
responses_channel_id=data.get("responses_channel_id")
) )
@ -188,6 +200,7 @@ class TicketData:
transcript_path: Optional[str] = None transcript_path: Optional[str] = None
rating: Optional[int] = None rating: Optional[int] = None
feedback: Optional[str] = None feedback: Optional[str] = None
recruitment_responses: Dict[str, str] = field(default_factory=dict)
@property @property
def is_open(self) -> bool: def is_open(self) -> bool:
@ -214,7 +227,8 @@ class TicketData:
"reason": self.reason, "reason": self.reason,
"transcript_path": self.transcript_path, "transcript_path": self.transcript_path,
"rating": self.rating, "rating": self.rating,
"feedback": self.feedback "feedback": self.feedback,
"recruitment_responses": self.recruitment_responses
} }
@classmethod @classmethod
@ -234,7 +248,8 @@ class TicketData:
reason=data.get("reason", ""), reason=data.get("reason", ""),
transcript_path=data.get("transcript_path"), transcript_path=data.get("transcript_path"),
rating=data.get("rating"), rating=data.get("rating"),
feedback=data.get("feedback") feedback=data.get("feedback"),
recruitment_responses=data.get("recruitment_responses", {})
) )