const { Client, GatewayIntentBits, Collection, Partials, Events, PermissionsBitField, InteractionType, ButtonBuilder, ButtonStyle, ActionRowBuilder } = require('discord.js'); const fs = require('node:fs'); const path = require('node:path'); const config = require('./config.json'); const { getOrderEmbed, getOrderTypeDropdown } = 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(); // Load order slash commands 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); } } // Add tax slash 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}!`); }); 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 if (interaction.isStringSelectMenu() && interaction.customId === 'order-type-select') { const type = interaction.values[0]; 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'); // Create private order channel + get order number const { channel, orderNumber } = await createOrderChannel(interaction, type); // Save order meta for logging/claim 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 }); // Send order ticket embed w/ claim and close button 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'); // Remove claim button from ticket const ticketMsg = await interaction.channel.messages.fetch(order.ticketMsgId); await ticketMsg.edit({ components: [getCloseActionRow()] }); // Send claimed embed (new message) with unclaim button 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 }); } // Defer reply quickly! await interaction.deferReply({ ephemeral: true }); // Set claimedBy to null order.claimedBy = null; // Debug: Track where the slowness occurs console.log('Unclaim: Renaming channel...'); await renameOrderChannel(interaction.channel, order.type, order.orderNumber, 'pending'); console.log('Unclaim: Sending embed...'); // Create and send a new embed with claim and close buttons const newEmbed = { color: 0x0099ff, title: 'Order Available', description: `Order unclaimed by <@${interaction.user.id}>. The order is now available for claim again.`, fields: [ { 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 } ], timestamp: new Date().toISOString(), footer: { text: 'Order System' } }; const sent = await interaction.channel.send({ embeds: [newEmbed], components: [getClaimActionRow(), getCloseActionRow()] }); order.ticketMsgId = sent.id; orderChannels.set(channelId, order); console.log('Unclaim: Setting topic...'); await interaction.channel.setTopic('Not claimed yet.'); // Edit the deferred reply console.log('Unclaim: Editing reply...'); await interaction.editReply({ content: 'Order unclaimed and now available for claim.' }); console.log('Unclaim: Done!'); } // 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); // Pass the transcriptFile name, not a link, so logger can use it as customId await logOrderClosure({ orderData: order, closer: interaction.user, transcriptUrl: transcriptFile, // <- just use filename 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; // For legacy testing: send order embed with dropdown on text command if (message.member.permissions.has(PermissionsBitField.Flags.Administrator) && message.content === '!do order embed') { const embed = getOrderEmbed(); const dropdown = getOrderTypeDropdown(); await message.guild.channels.fetch(config.orderchannel) .then(channel => channel.send({ embeds: [embed], components: [dropdown] })); return; } // Tax calculator prefix command if (message.content.startsWith('!tax')) { const args = message.content.trim().split(/\s+/).slice(1); await taxPrefixCommand.execute(message, args); } }); client.login(config.token);