diff --git a/commandes/ticket/__init__.py b/commandes/ticket/__init__.py index aecc64f..c95348e 100644 --- a/commandes/ticket/__init__.py +++ b/commandes/ticket/__init__.py @@ -542,9 +542,12 @@ class CategorySelectionView(disnake.ui.View): ephemeral=True ) return - - # Show priority modal - modal = TicketPriorityModal(self.bot, self.config, category) + + # 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) await interaction.response.send_modal(modal) except Exception as e: kuby_logger.error(f"[CategorySelection] Error: {e}", exc_info=True) @@ -593,7 +596,7 @@ class TicketPriorityModal(disnake.ui.Modal): async def on_error(self, interaction: disnake.Interaction, error: Exception): """Handle errors in the modal""" await interaction.response.send_message("Une erreur inattendue s'est produite lors de la création du ticket.", ephemeral=True) - + async def _create_ticket(self, interaction: disnake.Interaction, priority: Priority, reason: str): """Crée le ticket""" guild = interaction.guild @@ -702,6 +705,206 @@ class TicketPriorityModal(disnake.ui.Modal): 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): """Modal pour définir la catégorie Discord où les tickets seront créés""" @@ -2101,31 +2304,84 @@ class AddCategoryModal(disnake.ui.Modal): 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.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.storage = storage self.config = config - + async def callback(self, interaction: disnake.ModalInteraction): import uuid - + cat_id = str(uuid.uuid4())[:8] - + name_val = interaction.text_values.get("add_cat_name", "Nouvelle Catégorie") desc_val = interaction.text_values.get("add_cat_desc", "") 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( id=cat_id, name=name_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.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): @@ -2851,7 +3107,7 @@ class TicketCommands(commands.Cog): if not ( str(custom_id).startswith("ticket_") 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 @@ -2900,8 +3156,11 @@ class TicketCommands(commands.Cog): await inter.response.send_message("Catégorie non trouvée.", ephemeral=True) return - # ouvrir directement le modal de priorité (même logique que CategorySelectionView) - modal = TicketPriorityModal(self.bot, config, category) + # 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) await inter.response.send_modal(modal) except Exception as e: kuby_logger.error(f"[TicketSystem] cat_ routing error: {e}", exc_info=True) @@ -2934,6 +3193,80 @@ class TicketCommands(commands.Cog): view = TicketManagementView(self.bot, self.storage, config, ticket) 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) === elif custom_id == "close_cancel": await inter.response.defer() @@ -3259,7 +3592,85 @@ class TicketCommands(commands.Cog): print(f"[Ticket] Error registering views for guild {guild_id}: {e}") except Exception as 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 === @commands.slash_command(name="ticket", description="Ouvre le panel des tickets") diff --git a/commandes/ticket/data/models.py b/commandes/ticket/data/models.py index d03ce03..f1fd33a 100644 --- a/commandes/ticket/data/models.py +++ b/commandes/ticket/data/models.py @@ -60,7 +60,10 @@ class TicketCategory: priority_enabled: bool = True, allow_claims: 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.name = name @@ -76,7 +79,10 @@ class TicketCategory: self.allow_claims = allow_claims self.survey_enabled = survey_enabled 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]: """Convert category to dictionary for storage""" return { @@ -93,7 +99,10 @@ class TicketCategory: "priority_enabled": self.priority_enabled, "allow_claims": self.allow_claims, "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 @@ -113,7 +122,10 @@ class TicketCategory: priority_enabled=data.get("priority_enabled", True), allow_claims=data.get("allow_claims", 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,7 +200,8 @@ class TicketData: transcript_path: Optional[str] = None rating: Optional[int] = None feedback: Optional[str] = None - + recruitment_responses: Dict[str, str] = field(default_factory=dict) + @property def is_open(self) -> bool: return self.status in [TicketStatus.OPEN, TicketStatus.CLAIMED] @@ -214,9 +227,10 @@ class TicketData: "reason": self.reason, "transcript_path": self.transcript_path, "rating": self.rating, - "feedback": self.feedback + "feedback": self.feedback, + "recruitment_responses": self.recruitment_responses } - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'TicketData': messages = [TicketMessage.from_dict(m) for m in data.get("messages", [])] @@ -234,7 +248,8 @@ class TicketData: reason=data.get("reason", ""), transcript_path=data.get("transcript_path"), rating=data.get("rating"), - feedback=data.get("feedback") + feedback=data.get("feedback"), + recruitment_responses=data.get("recruitment_responses", {}) )