const { Client, GatewayIntentBits, Collection, Partials, Events, PermissionsBitField, InteractionType, ButtonBuilder, ButtonStyle, ActionRowBuilder, EmbedBuilder } = require('discord.js'); const fs = require('node:fs'); const path = require('path'); const config = require('./config.json'); const { getOrderEmbed, getOrderTypeDropdown, getOrderPictureEmbed } = require('./orders/embed'); const { getOrderModal } = require('./orders/modals'); const { getRoleId, createOrderChannel, renameOrderChannel } = require('./orders/utils'); const { getOrderTicketEmbed, getClaimedEmbed, getClaimActionRow, getUnclaimActionRow, getCloseActionRow } = require('./orders/orderEmbedGenerator'); const { logOrderClosure } = require('./orders/logging/orderLogger'); const { generateTranscript } = require('./orders/logging/transcript'); // --- Tax calculator commands --- const taxSlashCommand = require('./taxcalculator/slashcommand.js'); const taxPrefixCommand = require('./taxcalculator/prefixcommand.js'); // --- Setup client --- const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers ], partials: [ Partials.Message, Partials.Channel, Partials.Reaction ] }); // --- Slash command handler --- client.commands = new Collection(); const commandsPath = path.join(__dirname, 'orders', 'commands'); const commandFiles = fs.existsSync(commandsPath) ? fs.readdirSync(commandsPath).filter(file => file.endsWith('.js')) : []; for (const file of commandFiles) { const filePath = path.join(commandsPath, file); const command = require(filePath); if ('data' in command && 'execute' in command) { client.commands.set(command.data.name, command); } } client.commands.set(taxSlashCommand.data.name, taxSlashCommand); // --- Order channel state --- const orderChannels = new Map(); client.once(Events.ClientReady, () => { console.log(`Logged in as ${client.user.tag}!`); }); // --- MAIN INTERACTION HANDLER --- client.on(Events.InteractionCreate, async interaction => { // Handle slash commands if (interaction.isChatInputCommand()) { const command = client.commands.get(interaction.commandName); if (!command) return; try { await command.execute(interaction); } catch (error) { console.error(error); if (interaction.replied || interaction.deferred) { await interaction.followUp({ content: 'There was an error executing this command.', ephemeral: true }); } else { await interaction.reply({ content: 'There was an error executing this command.', ephemeral: true }); } } return; } // Dropdown order type selection (prevent unavailable) if (interaction.isStringSelectMenu() && interaction.customId === 'order-type-select') { const type = interaction.values[0]; // Map to config key (PascalCase) function toConfigKey(val) { switch (val) { case "graphics": return "Graphics"; case "liveries": return "Liveries"; case "clothing": return "Clothing"; case "discordservers": return "DiscordServers"; case "photography": return "Photography"; case "els": return "ELS"; case "custombots": return "CustomBots"; default: return null; } } const configKey = toConfigKey(type); if (!configKey || config.orderStatus[configKey] === "unavailable") { return await interaction.reply({ content: "Sorry, this order type is currently unavailable!", ephemeral: true }); } const modal = getOrderModal(type); return await interaction.showModal(modal); } // Modal submission: create order channel if (interaction.type === InteractionType.ModalSubmit && interaction.customId.startsWith('order-modal-')) { const type = interaction.customId.replace('order-modal-', ''); let values = {}; if (type === 'graphics') { values.graphics_type = interaction.fields.getTextInputValue('graphics_type'); } values.description = interaction.fields.getTextInputValue('description'); values.quantity = interaction.fields.getTextInputValue('quantity'); const { channel, orderNumber } = await createOrderChannel(interaction, type); orderChannels.set(channel.id, { orderNumber, type, openedBy: interaction.user.id, modalFields: values, createdAt: Date.now(), claimedBy: null, description: values.description, channelId: channel.id, ticketMsgId: null, claimedMsgId: null, unclaimMsgId: null }); const roleId = getRoleId(type); const embed = getOrderTicketEmbed(type, values, interaction.user, roleId, orderNumber); const ticketMsg = await channel.send({ content: `<@&${roleId}> <@${interaction.user.id}>`, embeds: [embed], components: [getClaimActionRow(), getCloseActionRow()] }); orderChannels.get(channel.id).ticketMsgId = ticketMsg.id; return await interaction.reply({ content: `Order created: <#${channel.id}>`, ephemeral: true }); } // Claim order if (interaction.isButton() && interaction.customId === 'claim-order') { const channelId = interaction.channel.id; const order = orderChannels.get(channelId); if (!order) return interaction.reply({ content: 'Order data missing.', ephemeral: true }); const requiredRoleId = getRoleId(order.type); if (!interaction.member.roles.cache.has(requiredRoleId)) { return interaction.reply({ content: 'You do not have permission to claim this order.', ephemeral: true }); } order.claimedBy = interaction.user.id; orderChannels.set(channelId, order); await renameOrderChannel(interaction.channel, order.type, order.orderNumber, 'claimed'); try { const ticketMsg = await interaction.channel.messages.fetch(order.ticketMsgId); await ticketMsg.edit({ components: [getCloseActionRow()] }); } catch (err) { // If ticketMsg not found, ignore (maybe deleted) } // Send claimed embed const claimedEmbed = getClaimedEmbed( order.type, order.modalFields, { id: order.openedBy }, requiredRoleId, order.orderNumber, interaction.user.id ); const claimedMsg = await interaction.channel.send({ embeds: [claimedEmbed], components: [getUnclaimActionRow()] }); order.claimedMsgId = claimedMsg.id; orderChannels.set(channelId, order); await interaction.reply({ content: 'Order claimed!', ephemeral: true }); await interaction.channel.setTopic(`Claimed by ${interaction.user.tag}`); return; } // Unclaim order (only by claimer) if (interaction.isButton() && interaction.customId === 'unclaim-order') { const channelId = interaction.channel.id; const order = orderChannels.get(channelId); if (!order) return interaction.reply({ content: 'Order data missing.', ephemeral: true }); if (order.claimedBy !== interaction.user.id) { return interaction.reply({ content: 'Only the user who claimed this order can unclaim it.', ephemeral: true }); } await interaction.deferReply({ ephemeral: true }); try { order.claimedBy = null; // Send reply and new embed BEFORE renaming await interaction.editReply({ content: 'Order unclaimed and now available for claim.' }); // Use EmbedBuilder, not plain object const newEmbed = new EmbedBuilder() .setColor(0x0099ff) .setTitle('Order Available') .setDescription(`Order unclaimed by <@${interaction.user.id}>. The order is now available for claim again.`) .addFields( { name: 'Order Number', value: order.orderNumber.toString(), inline: true }, { name: 'Order Type', value: order.type, inline: true }, { name: 'Description', value: order.modalFields?.description || 'No description' }, { name: 'Quantity', value: order.modalFields?.quantity || '1', inline: true }, { name: 'Opened By', value: `<@${order.openedBy}>`, inline: true } ) .setTimestamp(new Date()) .setFooter({ text: 'Order System' }); const sent = await interaction.channel.send({ embeds: [newEmbed], components: [getClaimActionRow(), getCloseActionRow()] }); order.ticketMsgId = sent.id; orderChannels.set(channelId, order); await interaction.channel.setTopic('Not claimed yet.'); // Optionally delete all previous claimable embeds to avoid multiple claim buttons try { const fetched = await interaction.channel.messages.fetch(); for (const msg of fetched.values()) { if ( msg.id !== sent.id && msg.author.id === interaction.client.user.id && msg.components.length && msg.components[0].components.find(c => c.customId === 'claim-order') ) { await msg.delete().catch(() => {}); } } } catch (e) {} // Rename channel in background renameOrderChannel(interaction.channel, order.type, order.orderNumber, 'pending') .catch(console.error); } catch (err) { console.error('[Unclaim] Error:', err); try { await interaction.editReply({ content: `An error occurred while unclaiming the order: ${err.message}` }); } catch (e) {} } } // Close order confirmation if (interaction.isButton() && interaction.customId === 'close-order') { const confirmRow = new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId('close-order-yes') .setLabel('Yes') .setStyle(ButtonStyle.Danger), new ButtonBuilder() .setCustomId('close-order-no') .setLabel('No') .setStyle(ButtonStyle.Secondary) ); return await interaction.reply({ content: 'Are you sure you want to close this order?', components: [confirmRow], ephemeral: false }); } if (interaction.isButton() && interaction.customId === 'close-order-yes') { const channelId = interaction.channel.id; const order = orderChannels.get(channelId); if (!order) return interaction.reply({ content: 'Order data missing.', ephemeral: true }); const transcriptFile = `order-${order.orderNumber}.html`; await generateTranscript(interaction.channel, transcriptFile); await logOrderClosure({ orderData: order, closer: interaction.user, transcriptUrl: transcriptFile, client }); await interaction.reply({ content: 'Order will be closed in 5 seconds.', ephemeral: true }); setTimeout(async () => { orderChannels.delete(channelId); await interaction.channel.delete('Order closed'); }, 5000); return; } if (interaction.isButton() && interaction.customId === 'close-order-no') { return await interaction.reply({ content: 'Order closure cancelled.', ephemeral: true }); } // View transcript button handler if (interaction.isButton() && interaction.customId.startsWith('view-transcript-')) { const orderNumber = interaction.customId.replace('view-transcript-', ''); const transcriptFile = `order-${orderNumber}.html`; const transcriptPath = path.join(__dirname, 'transcripts', transcriptFile); if (!fs.existsSync(transcriptPath)) { return await interaction.reply({ content: "Transcript file not found.", ephemeral: true }); } await interaction.reply({ content: `Here is the transcript for order ${orderNumber}:`, files: [{ attachment: transcriptPath, name: transcriptFile }] }); } }); // --- Message handler for prefix command and legacy order embed --- client.on(Events.MessageCreate, async message => { if (!message.guild || message.author.bot) return; if (message.member.permissions.has(PermissionsBitField.Flags.Administrator) && message.content === '!do order embed') { const pictureEmbed = getOrderPictureEmbed(); const infoEmbed = getOrderEmbed(); const dropdown = getOrderTypeDropdown(); await message.guild.channels.fetch(config.orderchannel) .then(channel => channel.send({ embeds: [pictureEmbed, infoEmbed], components: [dropdown] }) ); return; } if (message.content.startsWith('!tax')) { const args = message.content.trim().split(/\s+/).slice(1); await taxPrefixCommand.execute(message, args); } }); client.login(config.token);