const { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); const db = require('../../functions/database/db.js'); const { colors, emojis } = require('../../utils/constants'); // Questions pré-définies pour les candidatures const CANDIDATURE_QUESTIONS = [ 'Pourquoi souhaites-tu rejoindre le staff ?', 'Quelles sont tes disponibilités ?', 'As-tu déjà de l\'expérience en modération ?', 'Comment gérerais-tu un conflit entre membres ?', 'Qu\'est-ce qui te motive à aider la communauté ?' ]; // Mapping des types de tickets vers des préfixes d'ID const TICKET_TYPE_PREFIXES = { 'Support': 'SUPP', 'Plainte': 'PLNT', 'Plainte Staff': 'PLST', 'Candidature': 'CAND', 'Problème Technique': 'TECH' }; function generateTicketId(type = 'Support') { const prefix = TICKET_TYPE_PREFIXES[type] || 'TICK'; const timestamp = Date.now().toString(36).toUpperCase(); const random = Math.random().toString(36).substring(2, 6).toUpperCase(); return `${prefix}-${timestamp}-${random}`; } async function getCandidatureResponses(ticketId) { const [responses] = await db.query( 'SELECT * FROM candidature_responses WHERE ticketId = ? ORDER BY questionNumber ASC', [ticketId] ); return responses; } function generateTranscriptHTML(ticket, messages, candidatureResponses, useRelativeCss = false) { const date = new Date(ticket.createdAt); const dateStr = date.toLocaleDateString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit' }); const closedDateStr = ticket.closedAt ? new Date(ticket.closedAt).toLocaleDateString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit' }) : null; const typeEmojis = { 'Support': '💬', 'Plainte': '📢', 'Plainte Staff': '⚠️', 'Candidature': '📝', 'Problème Technique': '🔧' }; const statusColors = { 'Ouvert': '#10b981', 'Fermé': '#ef4444', 'En attente': '#f59e0b' }; let messagesHTML = ''; if (messages.length === 0) { messagesHTML = '

Aucun message enregistré.

'; } else { messagesHTML = messages.map(msg => { const msgDate = new Date(msg.timestamp).toLocaleString('fr-FR'); const attachmentsHTML = msg.attachments ? `
📎 Pièces jointes: ${msg.attachments.split(', ').map(url => `${url}`).join(', ')}
` : ''; return `
${escapeHtml(msg.userTag)} ${msgDate}
${escapeHtml(msg.content || '*Message vide*').replace(/\n/g, '
')}
${attachmentsHTML}
`; }).join(''); } let candidatureHTML = ''; if (candidatureResponses && candidatureResponses.length > 0) { candidatureHTML = `

📝 Réponses aux Questions de Candidature

${candidatureResponses.map(response => `
Question ${response.questionNumber}
${escapeHtml(response.question)}
${escapeHtml(response.response || '*Pas de réponse*').replace(/\n/g, '
')}
`).join('')}
`; } // Déterminer l'URL du CSS let cssUrl; if (useRelativeCss) { // Utiliser un chemin relatif pour l'ouverture locale cssUrl = '../../transcript.css'; } else { // Utiliser l'URL du serveur web const webUrl = process.env.TRANSCRIPT_WEB_URL || 'transcript.syxpi.fr'; cssUrl = `https://${webUrl}/static/transcript.css`; } return ` Transcription - ${ticket.ticketId}

${typeEmojis[ticket.type] || '🎫'} Transcription du Ticket

${ticket.ticketId}
Type
${typeEmojis[ticket.type] || '🎫'} ${ticket.type}
Créé par
${escapeHtml(ticket.userTag)}
Créé le
${dateStr}
Statut
${ticket.status}
${closedDateStr ? `
Fermé le
${closedDateStr}
` : ''}

💬 Messages

${messagesHTML}
${candidatureHTML}
`; } function escapeHtml(text) { if (!text) return ''; const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } const commandModule = { category: 'ticket', data: new SlashCommandBuilder() .setName('ticket') .setDescription('Gérer les tickets') .addSubcommand(subcommand => subcommand .setName('setup') .setDescription('Configurer le système de tickets (Admin uniquement)') .addChannelOption(option => option.setName('channel') .setDescription('Le salon où afficher le message de création de tickets') .setRequired(false))) .addSubcommand(subcommand => subcommand .setName('close') .setDescription('Fermer un ticket') .addStringOption(option => option.setName('reason') .setDescription('Raison de la fermeture (optionnel)') .setRequired(false))) .addSubcommand(subcommand => subcommand .setName('transcript') .setDescription('Générer la transcription d\'un ticket')) .addSubcommand(subcommand => subcommand .setName('reopen') .setDescription('Rouvrir un ticket fermé')) .addSubcommand(subcommand => subcommand .setName('add') .setDescription('Ajouter un utilisateur au ticket') .addUserOption(option => option.setName('user') .setDescription('L\'utilisateur à ajouter au ticket') .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('claim') .setDescription('Réclamer un ticket (modérateurs uniquement)')), async execute(interaction) { const subcommand = interaction.options.getSubcommand(); if (subcommand === 'setup') { await handleSetup(interaction); } else if (subcommand === 'close') { await handleClose(interaction); } else if (subcommand === 'transcript') { await handleTranscript(interaction); } else if (subcommand === 'reopen') { await handleReopen(interaction); } else if (subcommand === 'add') { await handleAdd(interaction); } else if (subcommand === 'claim') { await handleClaim(interaction); } }, }; async function handleSetup(interaction) { await interaction.deferReply({ ephemeral: true }); // Vérifier les permissions (Admin uniquement) if (!interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { return interaction.editReply({ content: '❌ Tu n\'as pas la permission d\'utiliser cette commande. (Administrateur requis)' }); } const channel = interaction.options.getChannel('channel') || interaction.channel; if (!channel.isTextBased()) { return interaction.editReply({ content: '❌ Le salon doit être un canal texte.' }); } try { // Créer l'embed avec les boutons const embed = new EmbedBuilder() .setTitle('🎫 Créer un Ticket') .setColor(colors.primary) .setDescription('Clique sur un des boutons ci-dessous pour créer un ticket.\n\n' + '**💬 Support** - Pour toute question ou problème\n' + '**📢 Plainte** - Pour signaler un problème avec un membre\n' + '**⚠️ Plainte Staff** - Pour signaler un problème avec un staff\n' + '**📝 Candidature** - Pour postuler au staff\n' + '**🔧 Problème Technique** - Pour signaler un bug ou problème technique') .setFooter({ text: 'France Femboy Bot • Système de Tickets' }) .setTimestamp(); // Créer les boutons pour chaque type de ticket const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_create_support') .setLabel('Support') .setStyle(ButtonStyle.Primary) .setEmoji('💬'), new ButtonBuilder() .setCustomId('ticket_create_plainte') .setLabel('Plainte') .setStyle(ButtonStyle.Secondary) .setEmoji('📢'), new ButtonBuilder() .setCustomId('ticket_create_plainte_staff') .setLabel('Plainte Staff') .setStyle(ButtonStyle.Danger) .setEmoji('⚠️') ); const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('ticket_create_candidature') .setLabel('Candidature') .setStyle(ButtonStyle.Success) .setEmoji('📝'), new ButtonBuilder() .setCustomId('ticket_create_probleme_technique') .setLabel('Problème Technique') .setStyle(ButtonStyle.Secondary) .setEmoji('🔧') ); await channel.send({ embeds: [embed], components: [row, row2] }); await interaction.editReply({ content: `✅ Message de création de tickets envoyé dans ${channel.toString()} !` }); } catch (err) { console.error('Erreur lors de la configuration du système de tickets:', err); await interaction.editReply({ content: `❌ Erreur lors de la configuration: ${err.message}` }); } } async function handleCreate(interaction, ticketType) { await interaction.deferReply({ ephemeral: true }); // Mapping des customId vers les types const typeMapping = { 'ticket_create_support': 'Support', 'ticket_create_plainte': 'Plainte', 'ticket_create_plainte_staff': 'Plainte Staff', 'ticket_create_candidature': 'Candidature', 'ticket_create_probleme_technique': 'Problème Technique' }; const type = ticketType || typeMapping[interaction.customId] || 'Support'; try { // Vérifier si l'utilisateur a déjà un ticket ouvert const [existingTickets] = await db.query( 'SELECT * FROM tickets WHERE userId = ? AND guildId = ? AND status = ?', [interaction.user.id, interaction.guild.id, 'Ouvert'] ); if (existingTickets.length > 0) { const existingTicket = existingTickets[0]; const channel = interaction.guild.channels.cache.get(existingTicket.channelId); return interaction.editReply({ content: `❌ Tu as déjà un ticket ouvert : ${channel ? channel.toString() : `ID: ${existingTicket.ticketId}`}` }); } // Trouver ou créer la catégorie pour les tickets let ticketCategory = interaction.guild.channels.cache.find( ch => ch.type === ChannelType.GuildCategory && ch.name.toLowerCase().includes('ticket') ); if (!ticketCategory) { // Créer la catégorie si elle n'existe pas try { ticketCategory = await interaction.guild.channels.create({ name: '🎫 Tickets', type: ChannelType.GuildCategory, permissionOverwrites: [ { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel], }, { id: interaction.client.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], }, ], }); } catch (err) { console.error('Erreur lors de la création de la catégorie:', err); // Si on ne peut pas créer la catégorie, on créera le canal sans parent ticketCategory = null; } } else { // Vérifier si le bot a les permissions nécessaires dans la catégorie existante try { const botMember = await interaction.guild.members.fetch(interaction.client.user.id); const categoryPermissions = ticketCategory.permissionsFor(botMember); if (!categoryPermissions || !categoryPermissions.has([ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ManageChannels, ])) { // Essayer de donner les permissions au bot dans la catégorie try { await ticketCategory.permissionOverwrites.edit(interaction.client.user.id, { ViewChannel: true, ManageChannels: true, SendMessages: true, ReadMessageHistory: true, }); console.log('✅ Permissions données au bot dans la catégorie'); } catch (permErr) { console.warn('⚠️ Impossible de donner les permissions au bot dans la catégorie:', permErr.message); // Si on ne peut pas donner les permissions, on créera le canal sans parent ticketCategory = null; } } } catch (err) { console.warn('⚠️ Erreur lors de la vérification des permissions:', err.message); // En cas d'erreur, on créera le canal sans parent ticketCategory = null; } } // Générer un ID de ticket avec le préfixe du type const ticketId = generateTicketId(type); // Nettoyer le nom d'utilisateur pour le nom du canal (Discord limite à 100 caractères, pas de caractères spéciaux) const cleanUsername = interaction.user.username .toLowerCase() .replace(/[^a-z0-9-]/g, '') .substring(0, 20); const typeName = type.toLowerCase().replace(/\s+/g, '-').substring(0, 20); const channelName = `${typeName}-${cleanUsername}`.substring(0, 100); // Préparer les permissions pour le canal const permissionOverwrites = [ { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel], }, { id: interaction.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], }, { id: interaction.client.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.ManageMessages, ], }, ]; // Créer le canal du ticket let channel; try { // Essayer de créer le canal avec la catégorie channel = await interaction.guild.channels.create({ name: channelName, type: ChannelType.GuildText, parent: ticketCategory?.id, permissionOverwrites: permissionOverwrites, reason: `Ticket créé par ${interaction.user.tag} (${ticketId})`, }); // Si le canal a été créé sans catégorie mais qu'on voulait une catégorie, essayer de le déplacer if (ticketCategory && !channel.parentId) { try { await channel.setParent(ticketCategory.id, { reason: `Déplacement du ticket ${ticketId} dans la catégorie` }); console.log(`✅ Canal ${channel.id} déplacé dans la catégorie ${ticketCategory.name}`); } catch (moveErr) { console.warn('⚠️ Impossible de déplacer le canal dans la catégorie:', moveErr.message); } } } catch (createErr) { // Si la création échoue avec la catégorie, essayer sans catégorie if (ticketCategory && createErr.code === 50013) { console.warn('⚠️ Impossible de créer le canal dans la catégorie, création sans catégorie...'); try { channel = await interaction.guild.channels.create({ name: channelName, type: ChannelType.GuildText, permissionOverwrites: permissionOverwrites, reason: `Ticket créé par ${interaction.user.tag} (${ticketId}) - Sans catégorie (permissions insuffisantes)`, }); console.log('✅ Canal créé sans catégorie'); } catch (retryErr) { console.error('❌ Erreur lors de la création du canal (sans catégorie):', retryErr); throw retryErr; } } else { throw createErr; } } // Ajouter les permissions pour les modérateurs (rôles avec ManageMessages) const modRoles = interaction.guild.roles.cache.filter(role => role.permissions.has(PermissionFlagsBits.ManageMessages) && !role.managed // Exclure les rôles gérés par des bots ); for (const role of modRoles.values()) { await channel.permissionOverwrites.edit(role.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, }); } // Enregistrer le ticket dans la DB await db.query( `INSERT INTO tickets (ticketId, channelId, userId, userTag, guildId, type, status, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ticketId, channel.id, interaction.user.id, interaction.user.tag, interaction.guild.id, type, 'Ouvert', Date.now()] ); // Créer l'embed de bienvenue const typeEmojis = { 'Support': '💬', 'Plainte': '📢', 'Plainte Staff': '⚠️', 'Candidature': '📝', 'Problème Technique': '🔧' }; const embed = new EmbedBuilder() .setTitle(`${typeEmojis[type] || '🎫'} Ticket ${type} - ${ticketId}`) .setColor(colors.primary) .setDescription(`**Créé par:** <@${interaction.user.id}> (${interaction.user.tag})`) .addFields( { name: '📋 Instructions', value: 'Utilisez les boutons ci-dessous pour gérer ce ticket.\nLes modérateurs peuvent utiliser `/ticket transcript` pour générer la transcription.', inline: false } ) .setFooter({ text: `Ticket ID: ${ticketId}` }) .setTimestamp(); // Boutons pour fermer et supprimer le ticket (uniquement pour modérateurs/admin) const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`ticket_close_${ticketId}`) .setLabel('Fermer') .setStyle(ButtonStyle.Danger) .setEmoji('🔒'), new ButtonBuilder() .setCustomId(`ticket_delete_${ticketId}`) .setLabel('Supprimer') .setStyle(ButtonStyle.Danger) .setEmoji('🗑️') ); await channel.send({ embeds: [embed], components: [row] }); // Si c'est une candidature, ouvrir le modal dans le salon if (type === 'Candidature') { const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = require('discord.js'); const questions = require('./ticket.js').CANDIDATURE_QUESTIONS; const modal = new ModalBuilder() .setCustomId('candidature_modal') .setTitle('Candidature Staff (5 max)'); for(let i = 0; i < Math.min(questions.length,5); i++) { const input = new TextInputBuilder() .setCustomId(`cand_q${i+1}`) .setLabel(questions[i].slice(0, 45)) .setStyle(TextInputStyle.Paragraph) .setRequired(true) .setMaxLength(1000); modal.addComponents(new ActionRowBuilder().addComponents(input)); } await interaction.showModal(modal); return; } await interaction.editReply({ content: `✅ Ticket créé avec succès ! ${channel.toString()}` }); } catch (err) { console.error('Erreur lors de la création du ticket:', err); await interaction.editReply({ content: `❌ Erreur lors de la création du ticket: ${err.message}` }); } } async function handleClose(interaction) { await interaction.deferReply({ ephemeral: true }); // Récupérer la raison depuis les options (peut être null si appelé depuis un bouton) let reason = 'Aucune raison fournie'; if (interaction.options && typeof interaction.options.getString === 'function') { reason = interaction.options.getString('reason') || 'Aucune raison fournie'; } try { // Trouver le ticket associé à ce canal const [tickets] = await db.query( 'SELECT * FROM tickets WHERE channelId = ?', [interaction.channel.id] ); if (tickets.length === 0) { return interaction.editReply({ content: '❌ Ce canal n\'est pas un ticket.' }); } const ticket = tickets[0]; if (ticket.status === 'Fermé') { return interaction.editReply({ content: '❌ Ce ticket est déjà fermé.' }); } // Vérifier les permissions (MODÉRATEURS/ADMIN UNIQUEMENT - les utilisateurs ne peuvent plus fermer) if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages) && !interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { return interaction.editReply({ content: '❌ Seuls les modérateurs et administrateurs peuvent fermer un ticket.' }); } // Mettre à jour le ticket await db.query( 'UPDATE tickets SET status = ?, closedAt = ?, closedBy = ? WHERE ticketId = ?', ['Fermé', Date.now(), interaction.user.id, ticket.ticketId] ); const embed = new EmbedBuilder() .setAuthor({ name: `${interaction.user.displayName}`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }) .setTitle('🔒 Ticket Fermé') .setColor(colors.warning) .setDescription(`Ce ticket a été fermé.`) .addFields( { name: '👮 Fermé par', value: `${interaction.user.toString()}\n\`${interaction.user.tag}\``, inline: true }, { name: '📝 Raison', value: reason, inline: false } ) .setFooter({ text: `Ticket ID: ${ticket.ticketId} • ${interaction.guild.name}` }) .setTimestamp(); await interaction.channel.send({ embeds: [embed] }); // Générer automatiquement le transcript try { // Récupérer tous les messages du ticket const [messages] = await db.query( 'SELECT * FROM ticket_messages WHERE ticketId = ? ORDER BY timestamp ASC', [ticket.ticketId] ); // Générer la transcription let transcript = `# Transcription du Ticket ${ticket.ticketId}\n\n`; transcript += `**Type:** ${ticket.type}\n`; transcript += `**Créé par:** ${ticket.userTag} (${ticket.userId})\n`; transcript += `**Créé le:** \n`; if (ticket.closedAt) { transcript += `**Fermé le:** \n`; transcript += `**Fermé par:** ${ticket.closedBy ? `<@${ticket.closedBy}>` : 'N/A'}\n`; } transcript += `\n---\n\n`; if (messages.length === 0) { transcript += '*Aucun message enregistré.*\n'; } else { for (const msg of messages) { const date = new Date(msg.timestamp).toLocaleString('fr-FR'); transcript += `**[${date}] ${msg.userTag}:**\n${msg.content || '*Message vide*'}\n`; if (msg.attachments) { transcript += `*Pièces jointes: ${msg.attachments}*\n`; } transcript += `\n`; } } // Générer le fichier HTML const fs = require('fs'); const path = require('path'); // Organiser par type et date const date = new Date(ticket.createdAt); const dateFolder = date.toISOString().split('T')[0]; const typeFolder = ticket.type.toLowerCase().replace(/\s+/g, '-'); const cleanUsername = ticket.userTag.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase(); const transcriptsBaseDir = path.join(process.cwd(), 'transcripts'); const typeDir = path.join(transcriptsBaseDir, typeFolder); const dateDir = path.join(typeDir, dateFolder); // Créer les dossiers si nécessaire if (!fs.existsSync(transcriptsBaseDir)) { fs.mkdirSync(transcriptsBaseDir, { recursive: true }); } if (!fs.existsSync(typeDir)) { fs.mkdirSync(typeDir, { recursive: true }); } if (!fs.existsSync(dateDir)) { fs.mkdirSync(dateDir, { recursive: true }); } // Copier le CSS dans le dossier transcripts pour l'ouverture locale const cssSourcePath = path.join(__dirname, '../../server/public/transcript.css'); const cssDestPath = path.join(transcriptsBaseDir, 'transcript.css'); if (fs.existsSync(cssSourcePath) && !fs.existsSync(cssDestPath)) { fs.copyFileSync(cssSourcePath, cssDestPath); } const candidatureResponses = ticket.type === 'Candidature' ? await getCandidatureResponses(ticket.ticketId) : null; const htmlContent = generateTranscriptHTML(ticket, messages, candidatureResponses, true); // Nom du fichier const fileName = `${dateFolder}-${cleanUsername}.html`; const filePath = path.join(dateDir, fileName); // Sauvegarder le fichier HTML fs.writeFileSync(filePath, htmlContent, 'utf8'); // Sauvegarder le chemin web dans la DB (utiliser transcripts/ pour correspondre au dossier réel) const webPath = `transcripts/${typeFolder}/${dateFolder}/${fileName}`; await db.query( 'UPDATE tickets SET transcript = ?, transcriptPath = ? WHERE ticketId = ?', [transcript, webPath, ticket.ticketId] ); console.log(`✅ Transcript généré automatiquement pour le ticket ${ticket.ticketId}`); } catch (transcriptErr) { console.error('⚠️ Erreur lors de la génération automatique du transcript:', transcriptErr); // On continue même si le transcript échoue } // Supprimer les permissions de l'utilisateur await interaction.channel.permissionOverwrites.edit(ticket.userId, { ViewChannel: false, }); // Renommer le canal avec le préfixe "🔒-" si ce n'est pas déjà fait try { const currentName = interaction.channel.name; if (!currentName.startsWith('🔒-')) { await interaction.channel.setName(`🔒-${currentName}`); } } catch (renameErr) { console.error('⚠️ Erreur lors du renommage du canal:', renameErr); // On continue même si le renommage échoue } await interaction.editReply({ content: '✅ Ticket fermé avec succès. Le transcript a été généré automatiquement.' }); } catch (err) { console.error('Erreur lors de la fermeture du ticket:', err); await interaction.editReply({ content: `❌ Erreur lors de la fermeture du ticket: ${err.message}` }); } } async function handleTranscript(interaction) { await interaction.deferReply({ ephemeral: true }); try { // Trouver le ticket const [tickets] = await db.query( 'SELECT * FROM tickets WHERE channelId = ?', [interaction.channel.id] ); if (tickets.length === 0) { return interaction.editReply({ content: '❌ Ce canal n\'est pas un ticket.' }); } const ticket = tickets[0]; // Récupérer tous les messages du ticket const [messages] = await db.query( 'SELECT * FROM ticket_messages WHERE ticketId = ? ORDER BY timestamp ASC', [ticket.ticketId] ); // Générer la transcription let transcript = `# Transcription du Ticket ${ticket.ticketId}\n\n`; transcript += `**Type:** ${ticket.type}\n`; transcript += `**Créé par:** ${ticket.userTag} (${ticket.userId})\n`; transcript += `**Créé le:** \n`; if (ticket.closedAt) { transcript += `**Fermé le:** \n`; transcript += `**Fermé par:** ${ticket.closedBy ? `<@${ticket.closedBy}>` : 'N/A'}\n`; } transcript += `\n---\n\n`; if (messages.length === 0) { transcript += '*Aucun message enregistré.*\n'; } else { for (const msg of messages) { const date = new Date(msg.timestamp).toLocaleString('fr-FR'); transcript += `**[${date}] ${msg.userTag}:**\n${msg.content || '*Message vide*'}\n`; if (msg.attachments) { transcript += `*Pièces jointes: ${msg.attachments}*\n`; } transcript += `\n`; } } // Les réponses de candidature seront incluses dans le HTML // Sauvegarder la transcription dans la DB await db.query( 'UPDATE tickets SET transcript = ? WHERE ticketId = ?', [transcript, ticket.ticketId] ); // Générer le fichier HTML const fs = require('fs'); const path = require('path'); // Organiser par type et date (ex: transcripts/Support/2025-11-08/) const date = new Date(ticket.createdAt); const dateFolder = date.toISOString().split('T')[0]; // Format: YYYY-MM-DD const typeFolder = ticket.type.toLowerCase().replace(/\s+/g, '-'); // Format: support, plainte-staff, etc. const cleanUsername = ticket.userTag.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase(); const transcriptsBaseDir = path.join(process.cwd(), 'transcripts'); const typeDir = path.join(transcriptsBaseDir, typeFolder); const dateDir = path.join(typeDir, dateFolder); // Créer les dossiers si nécessaire if (!fs.existsSync(transcriptsBaseDir)) { fs.mkdirSync(transcriptsBaseDir, { recursive: true }); } if (!fs.existsSync(typeDir)) { fs.mkdirSync(typeDir, { recursive: true }); } if (!fs.existsSync(dateDir)) { fs.mkdirSync(dateDir, { recursive: true }); } // Copier le CSS dans le dossier transcripts pour l'ouverture locale const cssSourcePath = path.join(__dirname, '../../server/public/transcript.css'); const cssDestPath = path.join(transcriptsBaseDir, 'transcript.css'); if (fs.existsSync(cssSourcePath) && !fs.existsSync(cssDestPath)) { fs.copyFileSync(cssSourcePath, cssDestPath); } // Générer le HTML const htmlContent = generateTranscriptHTML(ticket, messages, ticket.type === 'Candidature' ? await getCandidatureResponses(ticket.ticketId) : null, true); // Nom de fichier : date-username.html (ex: 2025-11-08-syxpi.html) const fileName = `${dateFolder}-${cleanUsername}.html`; const filePath = path.join(dateDir, fileName); // Sauvegarder le chemin relatif dans la DB pour accès web (utiliser transcripts/ pour correspondre au dossier réel) const webPath = `transcripts/${typeFolder}/${dateFolder}/${fileName}`; await db.query( 'UPDATE tickets SET transcript = ?, transcriptPath = ? WHERE ticketId = ?', [transcript, webPath, ticket.ticketId] ); fs.writeFileSync(filePath, htmlContent, 'utf8'); // Envoyer le fichier avec info sur le chemin web const webUrl = process.env.TRANSCRIPT_WEB_URL || 'transcript.syxpi.fr'; const fullWebPath = `https://${webUrl}/${webPath}`; await interaction.editReply({ content: `✅ Transcription générée !\n\n📄 **Fichier local:** \`${fileName}\`\n🌐 **URL web:** ${fullWebPath}\n\n*Note: L'URL web nécessite une authentification Discord.*`, files: [{ attachment: filePath, name: fileName }] }); } catch (err) { console.error('Erreur lors de la génération de la transcription:', err); await interaction.editReply({ content: `❌ Erreur lors de la génération de la transcription: ${err.message}` }); } } async function handleReopen(interaction) { await interaction.deferReply({ ephemeral: true }); // Vérifier les permissions if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { return interaction.editReply({ content: '❌ Tu n\'as pas la permission de rouvrir un ticket.' }); } try { const [tickets] = await db.query( 'SELECT * FROM tickets WHERE channelId = ?', [interaction.channel.id] ); if (tickets.length === 0) { return interaction.editReply({ content: '❌ Ce canal n\'est pas un ticket.' }); } const ticket = tickets[0]; if (ticket.status !== 'Fermé') { return interaction.editReply({ content: '❌ Ce ticket n\'est pas fermé.' }); } // Rouvrir le ticket await db.query( 'UPDATE tickets SET status = ?, closedAt = NULL, closedBy = NULL WHERE ticketId = ?', ['Ouvert', ticket.ticketId] ); // Restaurer les permissions await interaction.channel.permissionOverwrites.edit(ticket.userId, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, }); // Retirer le préfixe "🔒-" du nom du canal si présent try { const currentName = interaction.channel.name; if (currentName.startsWith('🔒-')) { await interaction.channel.setName(currentName.replace('🔒-', '')); } } catch (renameErr) { console.error('⚠️ Erreur lors du renommage du canal:', renameErr); // On continue même si le renommage échoue } const embed = new EmbedBuilder() .setAuthor({ name: `${interaction.user.displayName}`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }) .setTitle('🔓 Ticket Rouvert') .setColor(colors.success) .setDescription(`Ce ticket a été rouvert.`) .addFields( { name: '👮 Rouvert par', value: `${interaction.user.toString()}\n\`${interaction.user.tag}\``, inline: true } ) .setFooter({ text: `Ticket ID: ${ticket.ticketId} • ${interaction.guild.name}` }) .setTimestamp(); await interaction.channel.send({ embeds: [embed] }); await interaction.editReply({ content: '✅ Ticket rouvert avec succès.' }); } catch (err) { console.error('Erreur lors de la réouverture du ticket:', err); await interaction.editReply({ content: `❌ Erreur lors de la réouverture du ticket: ${err.message}` }); } } async function handleAdd(interaction) { await interaction.deferReply({ ephemeral: true }); try { // Trouver le ticket associé à ce canal const [tickets] = await db.query( 'SELECT * FROM tickets WHERE channelId = ?', [interaction.channel.id] ); if (tickets.length === 0) { return interaction.editReply({ content: '❌ Ce canal n\'est pas un ticket.' }); } const ticket = tickets[0]; // Vérifier que le ticket n'est pas fermé if (ticket.status === 'Fermé' || ticket.status === 'Supprimé') { return interaction.editReply({ content: '❌ Vous ne pouvez pas ajouter un utilisateur à un ticket fermé ou supprimé.' }); } // Récupérer l'utilisateur à ajouter const userToAdd = interaction.options.getUser('user'); if (!userToAdd) { return interaction.editReply({ content: '❌ Utilisateur introuvable.' }); } // Vérifier que l'utilisateur n'est pas un bot (optionnel, mais recommandé) if (userToAdd.bot) { return interaction.editReply({ content: '❌ Vous ne pouvez pas ajouter un bot au ticket.' }); } // Vérifier que l'utilisateur n'est pas déjà dans le ticket const member = await interaction.guild.members.fetch(userToAdd.id).catch(() => null); if (!member) { return interaction.editReply({ content: '❌ Cet utilisateur n\'est pas sur le serveur.' }); } // Vérifier si l'utilisateur a déjà accès au canal try { const channelPermissions = interaction.channel.permissionsFor(member); if (channelPermissions && channelPermissions.has(PermissionFlagsBits.ViewChannel)) { return interaction.editReply({ content: `❌ ${userToAdd.toString()} a déjà accès à ce ticket.` }); } } catch (permErr) { // Si on ne peut pas vérifier les permissions, on continue quand même console.warn('⚠️ Erreur lors de la vérification des permissions:', permErr); } // Ajouter les permissions pour l'utilisateur await interaction.channel.permissionOverwrites.edit(userToAdd.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, }); const embed = new EmbedBuilder() .setAuthor({ name: `${interaction.user.displayName}`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }) .setTitle('✅ Utilisateur Ajouté') .setColor(colors.success) .setDescription(`${userToAdd.toString()} a été ajouté au ticket.`) .setThumbnail(userToAdd.displayAvatarURL({ dynamic: true })) .addFields( { name: '👤 Utilisateur ajouté', value: `${userToAdd.toString()}\n\`${userToAdd.tag}\``, inline: true }, { name: '👮 Ajouté par', value: `${interaction.user.toString()}\n\`${interaction.user.tag}\``, inline: true } ) .setFooter({ text: `Ticket ID: ${ticket.ticketId} • ${interaction.guild.name}` }) .setTimestamp(); await interaction.channel.send({ embeds: [embed] }); await interaction.editReply({ content: `✅ ${userToAdd.toString()} a été ajouté au ticket avec succès.` }); } catch (err) { console.error('Erreur lors de l\'ajout d\'un utilisateur au ticket:', err); if (err.code === 50013) { await interaction.editReply({ content: '❌ Je n\'ai pas les permissions nécessaires pour ajouter cet utilisateur au ticket.' }); } else { await interaction.editReply({ content: `❌ Erreur lors de l'ajout de l'utilisateur: ${err.message}` }); } } } async function handleClaim(interaction) { await interaction.deferReply({ ephemeral: true }); // Vérifier les permissions (MODÉRATEURS/ADMIN UNIQUEMENT) if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages) && !interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { return interaction.editReply({ content: '❌ Seuls les modérateurs et administrateurs peuvent réclamer un ticket.' }); } try { // Trouver le ticket associé à ce canal const [tickets] = await db.query( 'SELECT * FROM tickets WHERE channelId = ?', [interaction.channel.id] ); if (tickets.length === 0) { return interaction.editReply({ content: '❌ Ce canal n\'est pas un ticket.' }); } const ticket = tickets[0]; // Vérifier que le ticket n'est pas fermé ou supprimé if (ticket.status === 'Fermé' || ticket.status === 'Supprimé') { return interaction.editReply({ content: '❌ Vous ne pouvez pas réclamer un ticket fermé ou supprimé.' }); } // Vérifier si le ticket est déjà claim if (ticket.claimedBy && ticket.claimedBy !== interaction.user.id) { const claimedByUser = await interaction.client.users.fetch(ticket.claimedBy).catch(() => null); return interaction.editReply({ content: `❌ Ce ticket est déjà réclamé par ${claimedByUser ? claimedByUser.toString() : 'un autre modérateur'}.` }); } // Si l'utilisateur a déjà claim le ticket, lui permettre de le "unclaim" if (ticket.claimedBy === interaction.user.id) { await db.query( 'UPDATE tickets SET claimedBy = NULL, claimedAt = NULL, claimedByTag = NULL WHERE ticketId = ?', [ticket.ticketId] ); const embed = new EmbedBuilder() .setAuthor({ name: `${interaction.user.displayName}`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }) .setTitle('✅ Ticket Non Réclamé') .setColor(colors.info) .setDescription(`Vous avez libéré ce ticket.`) .setFooter({ text: `Ticket ID: ${ticket.ticketId} • ${interaction.guild.name}` }) .setTimestamp(); await interaction.channel.send({ embeds: [embed] }); return interaction.editReply({ content: '✅ Ticket libéré avec succès.' }); } // Claim le ticket (on stocke aussi le tag du modérateur pour l'affichage dans la WebUI) await db.query( 'UPDATE tickets SET claimedBy = ?, claimedAt = ?, claimedByTag = ? WHERE ticketId = ?', [interaction.user.id, Date.now(), interaction.user.tag, ticket.ticketId] ); const embed = new EmbedBuilder() .setAuthor({ name: `${interaction.user.displayName}`, iconURL: interaction.user.displayAvatarURL({ dynamic: true }) }) .setTitle('✅ Ticket Réclamé') .setColor(colors.success) .setDescription(`Ce ticket a été réclamé par ${interaction.user.toString()}.`) .addFields( { name: '👮 Réclamé par', value: `${interaction.user.toString()}\n\`${interaction.user.tag}\``, inline: true } ) .setFooter({ text: `Ticket ID: ${ticket.ticketId} • ${interaction.guild.name}` }) .setTimestamp(); await interaction.channel.send({ embeds: [embed] }); await interaction.editReply({ content: '✅ Ticket réclamé avec succès.' }); } catch (err) { console.error('Erreur lors de la réclamation du ticket:', err); await interaction.editReply({ content: `❌ Erreur lors de la réclamation du ticket: ${err.message}` }); } } async function handleDelete(interaction) { await interaction.deferReply({ ephemeral: true }); try { // Trouver le ticket associé à ce canal const [tickets] = await db.query( 'SELECT * FROM tickets WHERE channelId = ?', [interaction.channel.id] ); if (tickets.length === 0) { return interaction.editReply({ content: '❌ Ce canal n\'est pas un ticket.' }); } const ticket = tickets[0]; // Vérifier les permissions (MODÉRATEURS/ADMIN UNIQUEMENT) if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages) && !interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { return interaction.editReply({ content: '❌ Seuls les modérateurs et administrateurs peuvent supprimer un ticket.' }); } // Générer le transcript avant de supprimer (si pas déjà fait) if (ticket.status !== 'Fermé') { try { // Générer le transcript avant suppression const [messages] = await db.query( 'SELECT * FROM ticket_messages WHERE ticketId = ? ORDER BY timestamp ASC', [ticket.ticketId] ); const fs = require('fs'); const path = require('path'); const date = new Date(ticket.createdAt); const dateFolder = date.toISOString().split('T')[0]; const typeFolder = ticket.type.toLowerCase().replace(/\s+/g, '-'); const cleanUsername = ticket.userTag.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase(); const transcriptsBaseDir = path.join(process.cwd(), 'transcripts'); const typeDir = path.join(transcriptsBaseDir, typeFolder); const dateDir = path.join(typeDir, dateFolder); if (!fs.existsSync(dateDir)) { fs.mkdirSync(dateDir, { recursive: true }); } const candidatureResponses = ticket.type === 'Candidature' ? await getCandidatureResponses(ticket.ticketId) : null; const htmlContent = generateTranscriptHTML({ ...ticket, status: 'Fermé', closedAt: Date.now(), closedBy: interaction.user.id }, messages, candidatureResponses, true); const fileName = `${dateFolder}-${cleanUsername}.html`; const filePath = path.join(dateDir, fileName); fs.writeFileSync(filePath, htmlContent, 'utf8'); const webPath = `transcripts/${typeFolder}/${dateFolder}/${fileName}`; await db.query( 'UPDATE tickets SET transcriptPath = ?, status = ?, closedAt = ?, closedBy = ? WHERE ticketId = ?', [webPath, 'Fermé', Date.now(), interaction.user.id, ticket.ticketId] ); } catch (transcriptErr) { console.error('⚠️ Erreur lors de la génération du transcript avant suppression:', transcriptErr); } } // Supprimer le canal await interaction.channel.delete(`Ticket supprimé par ${interaction.user.tag}`); // Mettre à jour le ticket dans la DB (marquer comme supprimé) await db.query( 'UPDATE tickets SET status = ?, closedAt = ?, closedBy = ? WHERE ticketId = ?', ['Supprimé', Date.now(), interaction.user.id, ticket.ticketId] ); // (Assure-toi que la valeur Supprimé est autorisée dans ENUM) // Note: On ne peut pas envoyer de réponse car le canal est supprimé // L'utilisateur verra la réponse ephemeral avant la suppression } catch (err) { console.error('Erreur lors de la suppression du ticket:', err); if (err.code === 50013) { await interaction.editReply({ content: '❌ Je n\'ai pas les permissions nécessaires pour supprimer ce canal.' }); } else { await interaction.editReply({ content: `❌ Erreur lors de la suppression du ticket: ${err.message}` }); } } } // Exporter handleCreate, handleClose et handleDelete pour l'utiliser dans interactionCreate commandModule.handleCreate = handleCreate; commandModule.handleClose = handleClose; commandModule.handleDelete = handleDelete; module.exports.CANDIDATURE_QUESTIONS = CANDIDATURE_QUESTIONS; module.exports.generateTicketId = generateTicketId; // Handler pour la soumission du modal de candidature commandModule.handleCandidatureModalSubmit = async function(interaction) { const db = require('../../functions/database/db.js'); // Pas de require/référence module ici, tout est local dans ce fichier ! const { EmbedBuilder, ChannelType, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); try { // Générer ticketId, permissions, etc const ticketId = generateTicketId('Candidature'); const cleanUsername = interaction.user.username.toLowerCase().replace(/[^a-z0-9-]/g, '').substring(0, 20); const channelName = `candidature-${cleanUsername}`.substring(0, 100); const permissionOverwrites = [ { id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel], }, { id: interaction.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], }, { id: interaction.client.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.EmbedLinks, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.ManageMessages, ], }, ]; // Chercher ou créer la catégorie "Tickets" let ticketCategory = interaction.guild.channels.cache.find(ch => ch.type === ChannelType.GuildCategory && ch.name.toLowerCase().includes('ticket')); let channel; if (!ticketCategory) { ticketCategory = null; // Créer sans catégorie si non trouvée } channel = await interaction.guild.channels.create({ name: channelName, type: ChannelType.GuildText, parent: ticketCategory?.id, permissionOverwrites, reason: `Ticket candidature créé via Modal by ${interaction.user.tag} (${ticketId})`, }); // Ajout BDD await db.query( `INSERT INTO tickets (ticketId, channelId, userId, userTag, guildId, type, status, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ticketId, channel.id, interaction.user.id, interaction.user.tag, interaction.guild.id, 'Candidature', 'Ouvert', Date.now()] ); // Réponses modal -> BDD for (let i = 0; i < Math.min(CANDIDATURE_QUESTIONS.length,5); i++) { const qLabel = CANDIDATURE_QUESTIONS[i]; const field = interaction.fields.getTextInputValue(`cand_q${i+1}`); await db.query( 'INSERT INTO candidature_responses (ticketId, questionNumber, question, response, timestamp) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE response = VALUES(response), timestamp = VALUES(timestamp)', [ticketId, i+1, qLabel, field, Date.now()] ); } // Staff perms const modRoles = interaction.guild.roles.cache.filter(role => role.permissions.has(PermissionFlagsBits.ManageMessages) && !role.managed ); for (const role of modRoles.values()) { await channel.permissionOverwrites.edit(role.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, }); } // Embed recap dans le ticket const embed = new EmbedBuilder() .setTitle('✅ Nouvelle candidature staff !') .setColor(0x10b981) .setDescription(`<@${interaction.user.id}> a complété sa candidature. Voici ses réponses :`) .addFields(...CANDIDATURE_QUESTIONS.map((q, idx) => ({ name: `Question ${idx+1}`, value: `**${q}**\n${interaction.fields.getTextInputValue(`cand_q${idx+1}`)}`, }))) .setFooter({ text: `Ticket ID: ${ticketId}` }) .setTimestamp(); await channel.send({ embeds: [embed] }); await interaction.reply({ content: `✅ Salon de candidature créé ! ${channel.toString()}\nLe staff va étudier ta demande.`, ephemeral: true }); } catch (err) { console.error('Erreur candidature modal:', err); try { await interaction.reply({ content: '❌ Une erreur est survenue lors de la soumission de ta candidature.', ephemeral: true }); } catch {} } }; module.exports = commandModule; module.exports.CANDIDATURE_QUESTIONS = CANDIDATURE_QUESTIONS;