Files
Femboy-Croissant-Bot/commands/ticket/ticket.js
2026-03-15 11:58:43 +01:00

1373 lines
48 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '<p class="no-messages">Aucun message enregistré.</p>';
} else {
messagesHTML = messages.map(msg => {
const msgDate = new Date(msg.timestamp).toLocaleString('fr-FR');
const attachmentsHTML = msg.attachments
? `<div class="attachments">📎 Pièces jointes: ${msg.attachments.split(', ').map(url => `<a href="${url}" target="_blank">${url}</a>`).join(', ')}</div>`
: '';
return `
<div class="message">
<div class="message-header">
<span class="message-author">${escapeHtml(msg.userTag)}</span>
<span class="message-date">${msgDate}</span>
</div>
<div class="message-content">${escapeHtml(msg.content || '*Message vide*').replace(/\n/g, '<br>')}</div>
${attachmentsHTML}
</div>
`;
}).join('');
}
let candidatureHTML = '';
if (candidatureResponses && candidatureResponses.length > 0) {
candidatureHTML = `
<section class="candidature-section">
<h2>📝 Réponses aux Questions de Candidature</h2>
${candidatureResponses.map(response => `
<div class="question-item">
<div class="question-label">Question ${response.questionNumber}</div>
<div class="question-text">${escapeHtml(response.question)}</div>
<div class="response">${escapeHtml(response.response || '*Pas de réponse*').replace(/\n/g, '<br>')}</div>
</div>
`).join('')}
</section>
`;
}
// 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 `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transcription - ${ticket.ticketId}</title>
<link rel="stylesheet" href="${cssUrl}">
</head>
<body>
<div class="container">
<div class="header">
<h1>${typeEmojis[ticket.type] || '🎫'} Transcription du Ticket</h1>
<div class="ticket-id">${ticket.ticketId}</div>
</div>
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<div class="info-label">Type</div>
<div class="info-value">${typeEmojis[ticket.type] || '🎫'} ${ticket.type}</div>
</div>
<div class="info-item">
<div class="info-label">Créé par</div>
<div class="info-value">${escapeHtml(ticket.userTag)}</div>
</div>
<div class="info-item">
<div class="info-label">Créé le</div>
<div class="info-value">${dateStr}</div>
</div>
<div class="info-item">
<div class="info-label">Statut</div>
<div class="info-value">
<span class="status-badge" style="background: ${statusColors[ticket.status]}; color: white;">
${ticket.status}
</span>
</div>
</div>
${closedDateStr ? `
<div class="info-item">
<div class="info-label">Fermé le</div>
<div class="info-value">${closedDateStr}</div>
</div>
` : ''}
</div>
</div>
<div class="messages-section">
<h2>💬 Messages</h2>
${messagesHTML}
</div>
${candidatureHTML}
<div class="footer">
<p>Transcription générée le ${new Date().toLocaleString('fr-FR')}</p>
<p>France Femboy Bot • Système de Tickets</p>
</div>
</div>
</body>
</html>`;
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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:** <t:${Math.floor(ticket.createdAt / 1000)}:F>\n`;
if (ticket.closedAt) {
transcript += `**Fermé le:** <t:${Math.floor(ticket.closedAt / 1000)}:F>\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:** <t:${Math.floor(ticket.createdAt / 1000)}:F>\n`;
if (ticket.closedAt) {
transcript += `**Fermé le:** <t:${Math.floor(ticket.closedAt / 1000)}:F>\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;