`;
}).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;