From 30cfbd73fc46a935e7fde50c4883a1449563a695 Mon Sep 17 00:00:00 2001 From: UltraLionFr Date: Tue, 26 Aug 2025 01:01:34 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20=F0=9F=8E=89=20initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SlashCommand/general/help.js | 32 +++++++ SlashCommand/general/ping.js | 11 +++ SlashCommand/tickets/add.js | 70 ++++++++++++++++ SlashCommand/tickets/alert.js | 106 +++++++++++++++++++++++ SlashCommand/tickets/close.js | 39 +++++++++ SlashCommand/tickets/remove.js | 70 ++++++++++++++++ config.yml | 130 +++++++++++++++++++++++++++++ events/clients/deploy-commands.js | 23 +++++ events/clients/panel.js | 99 ++++++++++++++++++++++ events/clients/ready.js | 9 ++ events/guilds/interactionCreate.js | 61 ++++++++++++++ events/guilds/messageCreate.js | 26 ++++++ handlers/alertManager.js | 27 ++++++ handlers/buttonHandler.js | 36 ++++++++ handlers/commandHandler.js | 42 ++++++++++ handlers/configLoader.js | 14 ++++ handlers/database.js | 59 +++++++++++++ handlers/eventHandler.js | 30 +++++++ handlers/logger.js | 27 ++++++ handlers/menuHandler.js | 32 +++++++ handlers/modalHandler.js | 24 ++++++ interactions/cancelClosure.js | 21 +++++ interactions/closeTicket.js | 24 ++++++ interactions/ticketButton.js | 100 ++++++++++++++++++++++ interactions/ticketMenu.js | 87 +++++++++++++++++++ interactions/ticketModal.js | 96 +++++++++++++++++++++ lang.yml | 46 ++++++++++ 27 files changed, 1341 insertions(+) create mode 100644 SlashCommand/general/help.js create mode 100644 SlashCommand/general/ping.js create mode 100644 SlashCommand/tickets/add.js create mode 100644 SlashCommand/tickets/alert.js create mode 100644 SlashCommand/tickets/close.js create mode 100644 SlashCommand/tickets/remove.js create mode 100644 config.yml create mode 100644 events/clients/deploy-commands.js create mode 100644 events/clients/panel.js create mode 100644 events/clients/ready.js create mode 100644 events/guilds/interactionCreate.js create mode 100644 events/guilds/messageCreate.js create mode 100644 handlers/alertManager.js create mode 100644 handlers/buttonHandler.js create mode 100644 handlers/commandHandler.js create mode 100644 handlers/configLoader.js create mode 100644 handlers/database.js create mode 100644 handlers/eventHandler.js create mode 100644 handlers/logger.js create mode 100644 handlers/menuHandler.js create mode 100644 handlers/modalHandler.js create mode 100644 interactions/cancelClosure.js create mode 100644 interactions/closeTicket.js create mode 100644 interactions/ticketButton.js create mode 100644 interactions/ticketMenu.js create mode 100644 interactions/ticketModal.js create mode 100644 lang.yml diff --git a/SlashCommand/general/help.js b/SlashCommand/general/help.js new file mode 100644 index 0000000..3353930 --- /dev/null +++ b/SlashCommand/general/help.js @@ -0,0 +1,32 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("help") + .setDescription("Affiche la liste des commandes disponibles"), + + async execute(interaction, client) { + const globalCommands = await client.application.commands.fetch(); + + const commandList = globalCommands.map( + cmd => ` — ${cmd.description || "Pas de description"}` + ).join("\n"); + + const embed = new EmbedBuilder() + .setTitle("📖 Aide du bot") + .setDescription( + commandList.length > 0 + ? "Voici la liste des commandes disponibles :\n\n" + commandList + : "❌ Aucune commande trouvée." + ) + .setColor(0x5e99ff) + .setThumbnail(client.user.displayAvatarURL()) + .setFooter({ + text: `QuantumCraft Studios • Demandé par ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], flags: 64 }); + } +}; \ No newline at end of file diff --git a/SlashCommand/general/ping.js b/SlashCommand/general/ping.js new file mode 100644 index 0000000..13d9e79 --- /dev/null +++ b/SlashCommand/general/ping.js @@ -0,0 +1,11 @@ +const { SlashCommandBuilder } = require("discord.js"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("ping") + .setDescription("Répond avec Pong !"), + + async execute(interaction) { + await interaction.reply("🏓 Pong !"); + } +}; \ No newline at end of file diff --git a/SlashCommand/tickets/add.js b/SlashCommand/tickets/add.js new file mode 100644 index 0000000..6d3e741 --- /dev/null +++ b/SlashCommand/tickets/add.js @@ -0,0 +1,70 @@ +const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js"); +const { config, lang } = require("../../handlers/configLoader"); +const { getTicketByChannel } = require("../../handlers/database"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("add") + .setDescription(lang.commands.add.description) + .addUserOption(option => + option.setName("utilisateur") + .setDescription("L'utilisateur à ajouter au ticket") + .setRequired(true) + ), + + async execute(interaction) { + const member = interaction.member; + + const staffRoles = (Array.isArray(config.staffRoles) ? config.staffRoles : [config.staffRole || config.staffRoles]) + .filter(r => r && r.trim() !== ""); + + if (!staffRoles.some(roleId => member.roles.cache.has(roleId))) { + return interaction.reply({ + content: lang.permissions.staff_only, + flags: 64 + }); + } + + const ticket = getTicketByChannel(interaction.channel.id); + if (!ticket) { + return interaction.reply({ + content: lang.ticket.not_in_ticket, + flags: 64 + }); + } + + const userToAdd = interaction.options.getUser("utilisateur"); + + const existingOverwrite = interaction.channel.permissionOverwrites.cache.get(userToAdd.id); + if (existingOverwrite && existingOverwrite.allow.has(PermissionFlagsBits.ViewChannel)) { + return interaction.reply({ + content: lang.commands.add.already_added.replace("{user}", userToAdd), + flags: 64 + }); + } + + try { + await interaction.channel.permissionOverwrites.edit(userToAdd.id, { + [PermissionFlagsBits.ViewChannel]: true, + [PermissionFlagsBits.SendMessages]: true, + [PermissionFlagsBits.ReadMessageHistory]: true, + }); + + await interaction.reply({ + content: lang.commands.add.success.replace("{user}", userToAdd), + flags: 64 + }); + + await interaction.channel.send( + lang.commands.add.welcome.replace("{user}", userToAdd) + ); + + } catch (err) { + console.error(err); + await interaction.reply({ + content: lang.commands.add.error, + flags: 64 + }); + } + } +}; \ No newline at end of file diff --git a/SlashCommand/tickets/alert.js b/SlashCommand/tickets/alert.js new file mode 100644 index 0000000..62196c7 --- /dev/null +++ b/SlashCommand/tickets/alert.js @@ -0,0 +1,106 @@ +const { + SlashCommandBuilder, + EmbedBuilder, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder +} = require("discord.js"); +const { config, lang } = require("../../handlers/configLoader"); +const { getTicketByChannel, closeTicket } = require("../../handlers/database"); +const { scheduleTicketClosure } = require("../../handlers/alertManager"); +const ms = require("ms"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("alert") + .setDescription(lang.commands.alert.description), + + async execute(interaction, client) { + const ticket = getTicketByChannel(interaction.channel.id); + if (!ticket) { + return interaction.reply({ + content: lang.ticket.not_in_ticket, + flags: 64 + }); + } + + const member = interaction.member; + const isStaff = config.staffRoles.some(roleId => member.roles.cache.has(roleId)); + if (!isStaff) { + return interaction.reply({ + content: lang.permissions.staff_only, + flags: 64 + }); + } + + const alertDuration = ms(config.TicketAlert.Time || "1h"); + const now = Date.now(); + const inactiveTime = ``; + + // === Boutons === + const closeBtn = new ButtonBuilder() + .setCustomId("closeTicket") + .setLabel(lang.commands.alert.close_now_label || "🔒 Fermer maintenant") + .setStyle(ButtonStyle.Danger); + + const cancelBtn = new ButtonBuilder() + .setCustomId("cancelClosure") + .setLabel(lang.commands.alert.cancel_label || "🚫 Annuler la fermeture") + .setStyle(ButtonStyle.Secondary); + + const linkBtn = new ButtonBuilder() + .setLabel("🔗 Voir le ticket") + .setStyle(ButtonStyle.Link) + .setURL(`https://discord.com/channels/${interaction.guild.id}/${interaction.channel.id}`); + + const row1 = new ActionRowBuilder().addComponents(closeBtn, cancelBtn); + const row2 = new ActionRowBuilder().addComponents(linkBtn); + + // === Embed principal === + const alertEmbed = new EmbedBuilder() + .setColor(0xe67e22) + .setDescription( + lang.commands.alert.sent + .replace("{time}", ``) + .replace("{inactive-time}", inactiveTime) + ) + .setTimestamp(); + + const dmEmbed = new EmbedBuilder() + .setColor(0xe67e22) + .setDescription( + lang.commands.alert.sent + .replace("{time}", ``) + .replace("{inactive-time}", inactiveTime) + ) + .setTimestamp(); + + // === Envoi DM si activé === + if (config.TicketAlert?.DMUser && ticket.userId) { + const ticketCreator = await client.users.fetch(ticket.userId).catch(() => null); + if (ticketCreator) { + try { + await ticketCreator.send({ embeds: [dmEmbed], components: [row2] }); + } catch { + await interaction.reply({ + content: lang.commands.alert.dm_unreachable, + flags: 64 + }); + } + } + } + + // === Envoi dans le ticket (non éphémère) === + await interaction.reply({ + content: ticket.userId ? `<@${ticket.userId}>` : null, + embeds: [alertEmbed], + components: [row1] + }); + + // === Planification de la fermeture auto === + scheduleTicketClosure(interaction.channel, alertDuration, async () => { + closeTicket(interaction.channel.id); + await interaction.channel.delete().catch(() => {}); + }); + } +}; diff --git a/SlashCommand/tickets/close.js b/SlashCommand/tickets/close.js new file mode 100644 index 0000000..948da7b --- /dev/null +++ b/SlashCommand/tickets/close.js @@ -0,0 +1,39 @@ +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require("discord.js"); +const { getTicketByChannel, closeTicket } = require("../../handlers/database"); +const { config, lang } = require("../../handlers/configLoader"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("close") + .setDescription("Fermer un ticket"), + + async execute(interaction) { + const ticket = getTicketByChannel(interaction.channel.id); + + if (!ticket) { + return interaction.reply({ content: lang.ticket.not_in_ticket, flags: MessageFlags.Ephemeral }); + } + + closeTicket(interaction.channel.id); + + await interaction.reply(lang.ticket.closing); + + // Log fermeture + const logChannel = await interaction.client.channels.fetch(config.logsChannel).catch(() => null); + if (logChannel) { + const embed = new EmbedBuilder() + .setTitle("🔒 Ticket fermé") + .addFields( + { name: "Salon", value: `${interaction.channel.name}`, inline: true }, + { name: "Fermé par", value: `${interaction.user.tag} (${interaction.user.id})`, inline: true } + ) + .setColor(0xe74c3c) + .setTimestamp(); + logChannel.send({ embeds: [embed] }); + } + + setTimeout(() => { + interaction.channel.delete().catch(() => {}); + }, 5000); + } +}; diff --git a/SlashCommand/tickets/remove.js b/SlashCommand/tickets/remove.js new file mode 100644 index 0000000..da4903b --- /dev/null +++ b/SlashCommand/tickets/remove.js @@ -0,0 +1,70 @@ +const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js"); +const { config, lang } = require("../../handlers/configLoader"); +const { getTicketByChannel } = require("../../handlers/database"); + +module.exports = { + data: new SlashCommandBuilder() + .setName("remove") + .setDescription(lang.commands.remove.description) + .addUserOption(option => + option.setName("utilisateur") + .setDescription("L'utilisateur à retirer du ticket") + .setRequired(true) + ), + + async execute(interaction) { + const member = interaction.member; + + const staffRoles = (Array.isArray(config.staffRoles) ? config.staffRoles : [config.staffRole || config.staffRoles]) + .filter(r => r && r.trim() !== ""); + + if (!staffRoles.some(roleId => member.roles.cache.has(roleId))) { + return interaction.reply({ + content: lang.permissions.staff_only, + flags: 64 + }); + } + + const ticket = getTicketByChannel(interaction.channel.id); + if (!ticket) { + return interaction.reply({ + content: lang.ticket.not_in_ticket, + flags: 64 + }); + } + + const userToRemove = interaction.options.getUser("utilisateur"); + + const existingOverwrite = interaction.channel.permissionOverwrites.cache.get(userToRemove.id); + if (!existingOverwrite || !existingOverwrite.allow.has(PermissionFlagsBits.ViewChannel)) { + return interaction.reply({ + content: lang.commands.remove.not_in_ticket.replace("{user}", userToRemove), + flags: 64 + }); + } + + try { + await interaction.channel.permissionOverwrites.edit(userToRemove.id, { + [PermissionFlagsBits.ViewChannel]: false, + [PermissionFlagsBits.SendMessages]: false, + [PermissionFlagsBits.ReadMessageHistory]: false, + }); + + await interaction.reply({ + content: lang.commands.remove.success.replace("{user}", userToRemove), + flags: 64 + }); + + await interaction.channel.send( + lang.commands.remove.goodbye.replace("{user}", userToRemove) + ); + + } catch (err) { + console.error(err); + await interaction.reply({ + content: lang.commands.remove.error, + flags: 64 + }); + } + } +}; \ No newline at end of file diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..c072e0c --- /dev/null +++ b/config.yml @@ -0,0 +1,130 @@ +# ========================= +# Configuration générale +# ========================= + +logsChannel: "1409149170131931237" + +# Liste des rôles qui auront les permissions "staff" (accès aux tickets, commandes /add /remove, etc.) +# Tu peux mettre un seul rôle : +# staffRoles: +# - "ID_DU_ROLE" +# +# Ou plusieurs rôles : +# staffRoles: +# - "ID_ROLE_1" +# - "ID_ROLE_2" +# - "ID_ROLE_3" +staffRoles: + - "1066466155574857884" + +# ========================= +# Configuration du panel de tickets +# ========================= +TicketPanel: + Panel: + # Nom interne du panel (juste informatif, affiché dans les logs) + Name: "Support Panel" + + # Channel textuel où le panel sera envoyé (un seul ID ici) + Channel: ["1409140845738856498"] + + # Catégorie où seront créés les tickets + Categories: ["1368955351512387634"] + + # Méthode d'interaction pour ouvrir un ticket : + # - "buttons" = un bouton par type de ticket + # - "select" = un menu déroulant avec plusieurs choix + InteractionType: "buttons" + + # ========================= + # Paramètres de l'embed du panel + # ========================= + Embed: + Title: "🎟️ Support Tickets" # Titre de l'embed + Description: "> Cliquez sur un bouton ou sélectionnez un type de ticket." + Color: "#5e99ff" # Couleur de l'embed (hexadécimal ou int) + PanelImage: "https://i.imgur.com/wOifew6.png" # Image principale de l'embed + Timestamp: false # true = ajoute un timestamp, false = désactivé + Footer: + Enabled: true # Active/désactive le footer + Text: "QuantumCraft Studios" # Texte affiché en bas de l'embed + + # ========================= + # Boutons du panel (si InteractionType = "buttons") + # ========================= + Buttons: + - Label: "🎟️ Support Général" + Style: "Primary" + CustomId: "ticket_general" + - Label: "⚙️ Support Technique" + Style: "Secondary" + CustomId: "ticket_tech" + - Label: "💰 Facturation" + Style: "Success" + CustomId: "ticket_billing" + + # ========================= + # Menu déroulant (si InteractionType = "select") + # ========================= + SelectMenu: + Placeholder: "Choisis le type de ticket..." + Options: + - Label: "🎟️ Support Général" + Value: "ticket_general" + Description: "Ouvrir un ticket général" + - Label: "⚙️ Support Technique" + Value: "ticket_tech" + Description: "Ouvrir un ticket technique" + - Label: "💰 Facturation" + Value: "ticket_billing" + Description: "Ouvrir un ticket lié à la facturation" + + # ========================= + # Configuration des Modals (formulaires) + # ========================= + Modals: + ticket_general: + Enabled: true + Title: "Support Général" + Inputs: + - CustomId: "subject" + Label: "Sujet du ticket" + Style: "Short" + Required: true + - CustomId: "description" + Label: "Décris ton problème" + Style: "Paragraph" + Required: true + + ticket_billing: + Enabled: true + Title: "Facturation" + Inputs: + - CustomId: "invoice" + Label: "ID de facture" + Style: "Short" + Required: true + - CustomId: "description" + Label: "Explique ton problème" + Style: "Paragraph" + Required: true + + # Modal désactivé : le ticket sera créé directement sans formulaire + ticket_tech: + Enabled: false + +# ========================= +# Configuration des alertes de ticket +# ========================= +TicketAlert: + Enabled: true # true = active la commande /alert, false = désactive totalement la fonctionnalité + DMUser: false # true = envoie aussi une alerte en message privé à l'auteur du ticket (si ses MP sont ouverts) + # false = aucune alerte en MP, uniquement dans le ticket + Time: "1m" # durée avant la fermeture automatique du ticket après une alerte + # ⚠️ format supporté : + # - "1m" = 1 minute + # - "10m" = 10 minutes + # - "1h" = 1 heure + # - "12h" = 12 heures + # - "1d" = 1 jour + # Après ce délai, si personne n'annule le ticket se ferme automatiquement \ No newline at end of file diff --git a/events/clients/deploy-commands.js b/events/clients/deploy-commands.js new file mode 100644 index 0000000..8ece509 --- /dev/null +++ b/events/clients/deploy-commands.js @@ -0,0 +1,23 @@ +const { REST, Routes } = require("discord.js"); +const { getCommandsJSON } = require("../../handlers/commandHandler"); +const logger = require("../../handlers/logger"); + +module.exports = { + name: "clientReady", + once: true, + async execute(client) { + const commands = getCommandsJSON(); + const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN); + + try { + logger.info("⏳ Déploiement des slash commands..."); + await rest.put( + Routes.applicationCommands(process.env.CLIENT_ID), + { body: commands } + ); + logger.success(`✅ ${commands.length} slash commands déployées avec succès !`); + } catch (error) { + logger.error(`❌ Erreur lors du déploiement des commandes : ${error.message}`); + } + } +}; \ No newline at end of file diff --git a/events/clients/panel.js b/events/clients/panel.js new file mode 100644 index 0000000..9d41057 --- /dev/null +++ b/events/clients/panel.js @@ -0,0 +1,99 @@ +const { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + StringSelectMenuBuilder +} = require("discord.js"); +const { config } = require("../../handlers/configLoader"); +const logger = require("../../handlers/logger"); + +function parseColor(raw) { + if (!raw) return 0x5865F2; + if (typeof raw === "number") return raw; + if (typeof raw === "string") { + if (raw.startsWith("0x")) return parseInt(raw, 16); + if (raw.startsWith("#")) return parseInt(raw.slice(1), 16); + const asInt = parseInt(raw); + if (!isNaN(asInt)) return asInt; + } + return 0x5865F2; +} + +module.exports = { + name: "clientReady", + once: true, + async execute(client) { + const panelConfig = config.TicketPanel.Panel; + const channelId = Array.isArray(panelConfig.Channel) + ? panelConfig.Channel[0] + : panelConfig.Channel; + + const channel = await client.channels.fetch(channelId).catch(() => null); + + if (!channel) { + return logger.error("❌ Impossible de trouver le salon défini pour le panel de ticket."); + } + + // === Embed principal === + const embed = new EmbedBuilder() + .setTitle(panelConfig.Embed.Title || "") + .setDescription(panelConfig.Embed.Description || "") + .setColor(parseColor(panelConfig.Embed.Color)); + + if (panelConfig.Embed.PanelImage) embed.setImage(panelConfig.Embed.PanelImage); + if (panelConfig.Embed.CustomThumbnailURL) embed.setThumbnail(panelConfig.Embed.CustomThumbnailURL); + if (panelConfig.Embed.Timestamp) embed.setTimestamp(); + + if (panelConfig.Embed.Footer && panelConfig.Embed.Footer.Enabled) { + embed.setFooter({ + text: panelConfig.Embed.Footer.Text || "", + iconURL: panelConfig.Embed.Footer.CustomIconURL || null + }); + } + + let row; + + // === Gestion des interactions === + if (panelConfig.InteractionType === "select" && panelConfig.SelectMenu) { + // 📌 Mode Select Menu + const menu = new StringSelectMenuBuilder() + .setCustomId("ticket_select") + .setPlaceholder(panelConfig.SelectMenu.Placeholder || "Choisis une option..."); + + for (const opt of panelConfig.SelectMenu.Options) { + const option = { + label: opt.Label, + value: opt.Value, + }; + + if (opt.Description) option.description = opt.Description; + if (opt.Emoji) option.emoji = opt.Emoji; + + menu.addOptions(option); + } + + row = new ActionRowBuilder().addComponents(menu); + + } else { + // 📌 Mode Boutons par défaut + row = new ActionRowBuilder(); + if (panelConfig.Buttons && Array.isArray(panelConfig.Buttons)) { + for (const btn of panelConfig.Buttons) { + row.addComponents( + new ButtonBuilder() + .setCustomId(btn.CustomId) + .setLabel(btn.Label) + .setStyle(ButtonStyle[btn.Style]) + ); + } + } + } + + // === Nettoyage & envoi === + await channel.bulkDelete(10).catch(() => {}); + await channel.send({ embeds: [embed], components: [row] }); + + logger.success(`✅ Panel "${panelConfig.Name}" envoyé avec succès !`); + } +}; \ No newline at end of file diff --git a/events/clients/ready.js b/events/clients/ready.js new file mode 100644 index 0000000..bf78412 --- /dev/null +++ b/events/clients/ready.js @@ -0,0 +1,9 @@ +const logger = require("../../handlers/logger"); + +module.exports = { + name: "clientReady", + once: true, + execute(client) { + logger.success(`Connecté en tant que ${client.user.tag}`); + } +}; \ No newline at end of file diff --git a/events/guilds/interactionCreate.js b/events/guilds/interactionCreate.js new file mode 100644 index 0000000..c3c4b33 --- /dev/null +++ b/events/guilds/interactionCreate.js @@ -0,0 +1,61 @@ +module.exports = { + name: "interactionCreate", + once: false, + async execute(interaction, client) { + try { + // Slash commands + if (interaction.isChatInputCommand()) { + const command = client.commands.get(interaction.commandName); + if (command) return command.execute(interaction, client); + } + + // Buttons + if (interaction.isButton()) { + for (const button of client.buttons.values()) { + if (typeof button.id === "string" && button.id === interaction.customId) { + return button.execute(interaction, client); + } + if (button.id instanceof RegExp && button.id.test(interaction.customId)) { + return button.execute(interaction, client); + } + } + } + + // Modals + if (interaction.isModalSubmit()) { + for (const modal of client.modals.values()) { + if (typeof modal.id === "string" && modal.id === interaction.customId) { + return modal.execute(interaction, client); + } + if (modal.id instanceof RegExp && modal.id.test(interaction.customId)) { + return modal.execute(interaction, client); + } + } + } + + // Menus + if ( + interaction.isStringSelectMenu() || + interaction.isUserSelectMenu() || + interaction.isRoleSelectMenu() || + interaction.isChannelSelectMenu() + ) { + for (const menu of client.menus.values()) { + if (typeof menu.id === "string" && menu.id === interaction.customId) { + return menu.execute(interaction, client); + } + if (menu.id instanceof RegExp && menu.id.test(interaction.customId)) { + return menu.execute(interaction, client); + } + } + } + } catch (error) { + console.error(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: "❌ Une erreur est survenue.", flags: 64 }); + } else { + await interaction.reply({ content: "❌ Une erreur est survenue.", flags: 64 }); + } + } + } +}; \ No newline at end of file diff --git a/events/guilds/messageCreate.js b/events/guilds/messageCreate.js new file mode 100644 index 0000000..bc12bc7 --- /dev/null +++ b/events/guilds/messageCreate.js @@ -0,0 +1,26 @@ +const { getTicketByChannel } = require("../../handlers/database"); +const { cancelTicketClosure } = require("../../handlers/alertManager"); +const { lang } = require("../../handlers/configLoader"); +const { EmbedBuilder } = require("discord.js"); + +module.exports = { + name: "messageCreate", + once: false, + async execute(message) { + if (message.author.bot) return; + + const ticket = getTicketByChannel(message.channel.id); + if (!ticket) return; + + if (message.author.id === ticket.userId) { + if (cancelTicketClosure(message.channel.id)) { + const embed = new EmbedBuilder() + .setDescription(lang.commands.alert.auto_cancelled) + .setColor(0x2ecc71) + .setTimestamp(); + + await message.channel.send({ embeds: [embed] }); + } + } + } +}; diff --git a/handlers/alertManager.js b/handlers/alertManager.js new file mode 100644 index 0000000..252f401 --- /dev/null +++ b/handlers/alertManager.js @@ -0,0 +1,27 @@ +const activeAlerts = new Map(); + + +function scheduleTicketClosure(channel, duration, closeFn) { + if (activeAlerts.has(channel.id)) { + clearTimeout(activeAlerts.get(channel.id)); + } + + const timeout = setTimeout(async () => { + activeAlerts.delete(channel.id); + + await closeFn(); + }, duration); + + activeAlerts.set(channel.id, timeout); +} + +function cancelTicketClosure(channelId) { + if (activeAlerts.has(channelId)) { + clearTimeout(activeAlerts.get(channelId)); + activeAlerts.delete(channelId); + return true; + } + return false; +} + +module.exports = { scheduleTicketClosure, cancelTicketClosure }; \ No newline at end of file diff --git a/handlers/buttonHandler.js b/handlers/buttonHandler.js new file mode 100644 index 0000000..7f0fa12 --- /dev/null +++ b/handlers/buttonHandler.js @@ -0,0 +1,36 @@ +const fs = require("fs"); +const path = require("path"); +const { Collection } = require("discord.js"); + +function loadButtons(client) { + const interactionsPath = path.join(__dirname, "../interactions"); + client.buttons = new Collection(); + + if (!fs.existsSync(interactionsPath)) return client.buttons; + + const files = fs.readdirSync(interactionsPath).filter(f => f.endsWith(".js")); + + for (const file of files) { + const button = require(path.join(interactionsPath, file)); + + if (button.id) { + client.buttons.set(button.id, button); + } + } + + return client.buttons; +} + +async function handleButton(interaction, client) { + const button = client.buttons.get(interaction.customId); + + if (button) return button.execute(interaction, client); + + for (const btn of client.buttons.values()) { + if (btn.id instanceof RegExp && btn.id.test(interaction.customId)) { + return btn.execute(interaction, client); + } + } +} + +module.exports = { loadButtons, handleButton }; \ No newline at end of file diff --git a/handlers/commandHandler.js b/handlers/commandHandler.js new file mode 100644 index 0000000..5c4d592 --- /dev/null +++ b/handlers/commandHandler.js @@ -0,0 +1,42 @@ +const fs = require("fs"); +const path = require("path"); +const { Collection } = require("discord.js"); + +function walkCommands(dir, callback) { + const files = fs.readdirSync(dir, { withFileTypes: true }); + + for (const file of files) { + const filePath = path.join(dir, file.name); + + if (file.isDirectory()) { + walkCommands(filePath, callback); + } else if (file.name.endsWith(".js")) { + const command = require(filePath); + callback(command); + } + } +} + +function loadCommands(client) { + const commandsPath = path.join(__dirname, "../SlashCommand"); + client.commands = new Collection(); + + walkCommands(commandsPath, (command) => { + client.commands.set(command.data.name, command); + }); + + return client.commands; +} + +function getCommandsJSON() { + const commandsPath = path.join(__dirname, "../SlashCommand"); + const commands = []; + + walkCommands(commandsPath, (command) => { + commands.push(command.data.toJSON()); + }); + + return commands; +} + +module.exports = { loadCommands, getCommandsJSON }; \ No newline at end of file diff --git a/handlers/configLoader.js b/handlers/configLoader.js new file mode 100644 index 0000000..d960ecb --- /dev/null +++ b/handlers/configLoader.js @@ -0,0 +1,14 @@ +const fs = require("fs"); +const path = require("path"); +const YAML = require("yaml"); + +function loadYAML(file) { + const filePath = path.join(__dirname, "..", file); + const content = fs.readFileSync(filePath, "utf8"); + return YAML.parse(content); +} + +const config = loadYAML("config.yml"); +const lang = loadYAML("lang.yml"); + +module.exports = { config, lang }; \ No newline at end of file diff --git a/handlers/database.js b/handlers/database.js new file mode 100644 index 0000000..8fbe7b3 --- /dev/null +++ b/handlers/database.js @@ -0,0 +1,59 @@ +const fs = require("fs"); +const path = require("path"); +const initSqlJs = require("sql.js"); + +const dbPath = path.join(__dirname, "../tickets.db"); + +let db; + +async function initDB() { + const SQL = await initSqlJs(); + + if (fs.existsSync(dbPath)) { + const fileBuffer = fs.readFileSync(dbPath); + db = new SQL.Database(fileBuffer); + } else { + db = new SQL.Database(); + db.run(` + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userId TEXT NOT NULL, + channelId TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + ) + `); + saveDB(); + } +} + +function saveDB() { + const data = db.export(); + fs.writeFileSync(dbPath, Buffer.from(data)); +} + +function createTicket(userId, channelId) { + db.run( + "INSERT INTO tickets (userId, channelId, status) VALUES (?, ?, 'open')", + [String(userId), String(channelId)] + ); + saveDB(); +} + +function getTicketByChannel(channelId) { + const stmt = db.prepare( + "SELECT * FROM tickets WHERE channelId = ? AND status = 'open'" + ); + stmt.bind([String(channelId)]); + const row = stmt.step() ? stmt.getAsObject() : null; + stmt.free(); + return row; +} + +function closeTicket(channelId) { + db.run("UPDATE tickets SET status = 'closed' WHERE channelId = ?", [ + String(channelId), + ]); + saveDB(); +} + +module.exports = { initDB, createTicket, getTicketByChannel, closeTicket }; \ No newline at end of file diff --git a/handlers/eventHandler.js b/handlers/eventHandler.js new file mode 100644 index 0000000..85cb94f --- /dev/null +++ b/handlers/eventHandler.js @@ -0,0 +1,30 @@ +const fs = require("fs"); +const path = require("path"); + +function loadEvents(client) { + const eventsPath = path.join(__dirname, "../events"); + + function walk(dir) { + const files = fs.readdirSync(dir, { withFileTypes: true }); + + for (const file of files) { + const filePath = path.join(dir, file.name); + + if (file.isDirectory()) { + walk(filePath); + } else if (file.name.endsWith(".js")) { + const event = require(filePath); + + if (event.once) { + client.once(event.name, (...args) => event.execute(...args, client)); + } else { + client.on(event.name, (...args) => event.execute(...args, client)); + } + } + } + } + + walk(eventsPath); +} + +module.exports = { loadEvents }; \ No newline at end of file diff --git a/handlers/logger.js b/handlers/logger.js new file mode 100644 index 0000000..dce5727 --- /dev/null +++ b/handlers/logger.js @@ -0,0 +1,27 @@ +const chalk = require("chalk"); + +function timestamp() { + return chalk.gray(`[${new Date().toLocaleTimeString("fr-FR")}]`); +} + +const logger = { + info: (msg) => { + console.log(`${timestamp()} ${chalk.blue("[INFO]")} ${msg}`); + }, + success: (msg) => { + console.log(`${timestamp()} ${chalk.green("[SUCCESS]")} ${msg}`); + }, + warn: (msg) => { + console.warn(`${timestamp()} ${chalk.yellow("[WARN]")} ${msg}`); + }, + error: (msg) => { + console.error(`${timestamp()} ${chalk.red("[ERROR]")} ${msg}`); + }, + debug: (msg) => { + if (process.env.DEBUG === "true") { + console.log(`${timestamp()} ${chalk.magenta("[DEBUG]")} ${msg}`); + } + } +}; + +module.exports = logger; \ No newline at end of file diff --git a/handlers/menuHandler.js b/handlers/menuHandler.js new file mode 100644 index 0000000..91c7b2c --- /dev/null +++ b/handlers/menuHandler.js @@ -0,0 +1,32 @@ +const fs = require("fs"); +const path = require("path"); +const { Collection } = require("discord.js"); + +function loadMenus(client) { + const interactionsPath = path.join(__dirname, "../interactions"); + client.menus = new Collection(); + + if (!fs.existsSync(interactionsPath)) return client.menus; + + const files = fs.readdirSync(interactionsPath).filter(f => f.toLowerCase().includes("menu") && f.endsWith(".js")); + + for (const file of files) { + const menu = require(path.join(interactionsPath, file)); + client.menus.set(menu.id, menu); + } + + return client.menus; +} + +async function handleMenu(interaction, client) { + for (const menu of client.menus.values()) { + if (typeof menu.id === "string" && menu.id === interaction.customId) { + return menu.execute(interaction, client); + } + if (menu.id instanceof RegExp && menu.id.test(interaction.customId)) { + return menu.execute(interaction, client); + } + } +} + +module.exports = { loadMenus, handleMenu }; \ No newline at end of file diff --git a/handlers/modalHandler.js b/handlers/modalHandler.js new file mode 100644 index 0000000..c7a4eee --- /dev/null +++ b/handlers/modalHandler.js @@ -0,0 +1,24 @@ +const fs = require("fs"); +const path = require("path"); +const { Collection } = require("discord.js"); +const { config } = require("./configLoader"); + +function loadModals(client) { + const interactionsPath = path.join(__dirname, "../interactions"); + client.modals = new Collection(); + + const files = fs.readdirSync(interactionsPath).filter(f => f.toLowerCase().includes("modal") && f.endsWith(".js")); + + for (const file of files) { + const modal = require(path.join(interactionsPath, file)); + client.modals.set(modal.id, modal); + } + + return client.modals; +} + +function getModal(customId) { + return config.TicketPanel?.Modals?.[customId] || null; +} + +module.exports = { loadModals, getModal }; \ No newline at end of file diff --git a/interactions/cancelClosure.js b/interactions/cancelClosure.js new file mode 100644 index 0000000..0ffc992 --- /dev/null +++ b/interactions/cancelClosure.js @@ -0,0 +1,21 @@ +const { config, lang } = require("../handlers/configLoader"); + +module.exports = { + id: "cancelClosure", + async execute(interaction) { + const member = interaction.member; + const isStaff = config.staffRoles.some(roleId => member.roles.cache.has(roleId)); + + if (!isStaff) { + return interaction.reply({ + content: lang.permissions.staff_only, + flags: 64 + }); + } + + await interaction.reply({ + content: lang.commands.alert.cancelled, + flags: 64 + }); + } +}; diff --git a/interactions/closeTicket.js b/interactions/closeTicket.js new file mode 100644 index 0000000..5815b7c --- /dev/null +++ b/interactions/closeTicket.js @@ -0,0 +1,24 @@ +const { PermissionFlagsBits } = require("discord.js"); +const { closeTicket } = require("../handlers/database"); +const { config, lang } = require("../handlers/configLoader"); + +module.exports = { + id: "closeTicket", + async execute(interaction) { + const member = interaction.member; + const isStaff = member.roles.cache.some(role => config.staffRoles.includes(role.id)); + + if (!isStaff) { + return interaction.reply({ content: lang.permissions.staff_only, flags: 64 }); + } + + const channel = interaction.channel; + + await interaction.reply({ content: lang.ticket.closing }); + + setTimeout(async () => { + closeTicket(channel.id); + await channel.delete().catch(() => {}); + }, 5000); + } +}; diff --git a/interactions/ticketButton.js b/interactions/ticketButton.js new file mode 100644 index 0000000..cf9b756 --- /dev/null +++ b/interactions/ticketButton.js @@ -0,0 +1,100 @@ +const { + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, + PermissionFlagsBits, + EmbedBuilder +} = require("discord.js"); +const { getModal } = require("../handlers/modalHandler"); +const { createTicket } = require("../handlers/database"); +const { config } = require("../handlers/configLoader"); + +module.exports = { + id: /^ticket_.+$/, + async execute(interaction, client) { + const modalConfig = getModal(interaction.customId); + + const staffRoles = (Array.isArray(config.staffRoles) ? config.staffRoles : [config.staffRoles]) + .filter(r => r && r.trim() !== ""); + + if (!modalConfig || modalConfig.Enabled === false) { + const guild = interaction.guild; + const member = interaction.member; + + const categories = config.TicketPanel.Panel.Categories; + const parentCategory = categories && categories.length > 0 ? categories[0] : null; + + const overwrites = [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: member.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + } + ]; + + for (const roleId of staffRoles) { + overwrites.push({ + id: roleId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + }); + } + + const channel = await guild.channels.create({ + name: `ticket-${member.user.username}`.toLowerCase(), + type: 0, + parent: parentCategory || undefined, + permissionOverwrites: overwrites + }); + + createTicket(member.id, channel.id); + + await interaction.reply({ + content: `✅ Ton ticket a été créé : ${channel}`, + flags: 64 + }); + + await channel.send(`🎟️ Bonjour ${member}, un membre du staff va bientôt te répondre.`); + + const logChannel = await client.channels.fetch(config.logsChannel).catch(() => null); + if (logChannel) { + const embed = new EmbedBuilder() + .setTitle("🎟️ Nouveau ticket (sans formulaire)") + .addFields( + { name: "Utilisateur", value: `${member.user.tag} (${member.id})` }, + { name: "Salon", value: `${channel}` } + ) + .setColor(0x2ecc71) + .setTimestamp(); + + await logChannel.send({ embeds: [embed] }); + } + + return; + } + + const modal = new ModalBuilder() + .setCustomId(`ticket_modal_${interaction.customId}`) + .setTitle(modalConfig.Title || "Création de ticket"); + + modalConfig.Inputs.forEach(input => { + const textInput = new TextInputBuilder() + .setCustomId(input.CustomId) + .setLabel(input.Label) + .setStyle(TextInputStyle[input.Style] || TextInputStyle.Short) + .setRequired(input.Required ?? false); + + modal.addComponents(new ActionRowBuilder().addComponents(textInput)); + }); + + await interaction.showModal(modal); + } +}; \ No newline at end of file diff --git a/interactions/ticketMenu.js b/interactions/ticketMenu.js new file mode 100644 index 0000000..f93c3a2 --- /dev/null +++ b/interactions/ticketMenu.js @@ -0,0 +1,87 @@ +const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, PermissionFlagsBits, EmbedBuilder } = require("discord.js"); +const { getModal } = require("../handlers/modalHandler"); +const { createTicket } = require("../handlers/database"); +const { config } = require("../handlers/configLoader"); + +module.exports = { + id: "ticket_select", + async execute(interaction, client) { + const selected = interaction.values[0]; + const modalConfig = getModal(selected); + + if (!modalConfig || modalConfig.Enabled === false) { + const guild = interaction.guild; + const member = interaction.member; + + const categories = config.TicketPanel.Panel.Categories; + const parentCategory = categories && categories.length > 0 ? categories[0] : null; + + const channel = await guild.channels.create({ + name: `ticket-${member.user.username}`.toLowerCase(), + type: 0, + parent: parentCategory || undefined, + permissionOverwrites: [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: member.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + }, + ...config.staffRoles.map(roleId => ({ + id: roleId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + })) + ] + }); + + createTicket(member.id, channel.id); + + await interaction.reply({ + content: `✅ Ton ticket a été créé : ${channel}`, + flags: 64 + }); + + await channel.send(`🎟️ Bonjour ${member}, un membre du staff va bientôt te répondre.`); + + const logChannel = await client.channels.fetch(config.logsChannel).catch(() => null); + if (logChannel) { + const embed = new EmbedBuilder() + .setTitle("🎟️ Nouveau ticket (sans formulaire - via SelectMenu)") + .addFields( + { name: "Utilisateur", value: `${member.user.tag} (${member.id})` }, + { name: "Salon", value: `${channel}` }, + { name: "Type de ticket", value: selected } + ) + .setColor(0x2ecc71) + .setTimestamp(); + + await logChannel.send({ embeds: [embed] }); + } + + return; + } + + const modal = new ModalBuilder() + .setCustomId(`ticket_modal_${selected}`) + .setTitle(modalConfig.Title || "Création de ticket"); + + modalConfig.Inputs.forEach(input => { + const textInput = new TextInputBuilder() + .setCustomId(input.CustomId) + .setLabel(input.Label) + .setStyle(TextInputStyle[input.Style] || TextInputStyle.Short) + .setRequired(input.Required ?? false); + + modal.addComponents(new ActionRowBuilder().addComponents(textInput)); + }); + + await interaction.showModal(modal); + } +}; \ No newline at end of file diff --git a/interactions/ticketModal.js b/interactions/ticketModal.js new file mode 100644 index 0000000..3cc953f --- /dev/null +++ b/interactions/ticketModal.js @@ -0,0 +1,96 @@ +const { EmbedBuilder, PermissionFlagsBits } = require("discord.js"); +const { createTicket } = require("../handlers/database"); +const { config } = require("../handlers/configLoader"); + +module.exports = { + id: /^ticket_modal_.+$/, + async execute(interaction, client) { + const originalId = interaction.customId.replace("ticket_modal_", ""); + const modalConfig = config.TicketPanel?.Modals?.[originalId]; + + if (!modalConfig) { + return interaction.reply({ + content: "❌ Aucun formulaire configuré pour ce ticket.", + flags: 64 + }); + } + + const member = interaction.member; + const guild = interaction.guild; + + const categories = config.TicketPanel.Panel.Categories; + const parentCategory = categories && categories.length > 0 ? categories[0] : null; + + const staffRoles = (Array.isArray(config.staffRoles) ? config.staffRoles : [config.staffRoles]) + .filter(r => r && r.trim() !== ""); + + const overwrites = [ + { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, + { + id: member.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + } + ]; + + for (const roleId of staffRoles) { + overwrites.push({ + id: roleId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory + ] + }); + } + + const channel = await guild.channels.create({ + name: `ticket-${member.user.username}`.toLowerCase(), + type: 0, + parent: parentCategory || undefined, + permissionOverwrites: overwrites + }); + + createTicket(member.id, channel.id); + + const fieldsOutput = modalConfig.Inputs.map(input => { + const value = interaction.fields.getTextInputValue(input.CustomId); + return `**${input.Label}** : ${value}`; + }).join("\n"); + + const embed = new EmbedBuilder() + .setTitle(`🎟️ ${modalConfig.Title || "Nouveau ticket"}`) + .setDescription(`👤 Ouvert par: ${member}\n\n${fieldsOutput}`) + .setColor(0x5e99ff) + .setTimestamp(); + + await channel.send({ + content: staffRoles.map(r => `<@&${r}>`).join(" "), + embeds: [embed] + }); + + await interaction.reply({ + content: `✅ Ton ticket a été créé : ${channel}`, + flags: 64 + }); + + const logChannel = await client.channels.fetch(config.logsChannel).catch(() => null); + if (logChannel) { + const logEmbed = new EmbedBuilder() + .setTitle("🎟️ Nouveau ticket") + .setDescription(`Formulaire utilisé: \`${originalId}\``) + .addFields( + { name: "Utilisateur", value: `${member.user.tag} (${member.id})` }, + { name: "Salon", value: `${channel}` }, + { name: "Réponses", value: fieldsOutput } + ) + .setColor(0x2ecc71) + .setTimestamp(); + + await logChannel.send({ embeds: [logEmbed] }); + } + } +}; \ No newline at end of file diff --git a/lang.yml b/lang.yml new file mode 100644 index 0000000..861aebf --- /dev/null +++ b/lang.yml @@ -0,0 +1,46 @@ +ticket: + already_open: "❌ Tu as déjà un ticket ouvert." + created: "✅ Ticket créé : {channel}" + greeting: "🎟️ Bonjour {user}, un membre du staff va bientôt te répondre." + closing: "⏳ Fermeture du ticket dans 5 secondes..." + closed: "✅ Ticket fermé avec succès." + not_in_ticket: "❌ Cette commande doit être utilisée dans un ticket." + no_permission: "❌ Tu n'as pas la permission de fermer ce ticket." + logs_message: "📝 Ticket fermé par {user} (ID: {userId})" + +errors: + generic: "❌ Une erreur est survenue." + not_found: "❌ Ressource introuvable." + db_error: "❌ Une erreur est survenue avec la base de données." + missing_config: "⚠️ Paramètre manquant dans config.yml." + +commands: + close: + description: "Fermer un ticket ouvert" + success: "✅ Le ticket sera fermé sous peu..." + error: "❌ Impossible de fermer ce ticket." + add: + description: "Ajouter un utilisateur à un ticket" + success: "✅ {user} a bien été ajouté au ticket." + already_added: "⚠️ {user} est déjà présent dans ce ticket." + welcome: "👋 Bienvenue {user}, tu as été ajouté à ce ticket." + error: "❌ Impossible d'ajouter cet utilisateur au ticket." + remove: + description: "Retirer un utilisateur du ticket" + success: "✅ {user} a bien été retiré du ticket." + not_in_ticket: "⚠️ {user} n'est pas présent dans ce ticket." + goodbye: "👋 {user} a été retiré de ce ticket." + error: "❌ Impossible de retirer cet utilisateur." + alert: + description: "Alerter l’auteur que le ticket va être fermé" + cancelled: "✅ La fermeture automatique du ticket a été annulée." + auto_cancelled: "✅ Réponse détectée, la fermeture automatique du ticket a été annulée." + dm_unreachable: "⚠️ Impossible d’envoyer un DM à l’auteur du ticket." + sent: "⏳ Une alerte de fermeture a été envoyée. Le ticket sera fermé {time} si aucune réponse n’est donnée. Inactivité détectée depuis {inactive-time}." + close_now_label: "🔴 Fermer maintenant" + cancel_label: "🚫 Annuler la fermeture" + close_confirm: "✅ Le ticket va être fermé immédiatement." + +permissions: + staff_only: "❌ Cette commande est réservée au staff." + user_only: "❌ Seuls les utilisateurs peuvent utiliser cette commande." \ No newline at end of file