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'); const express = require('express'); // --- 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.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); } } // --- Transcript server --- const transcriptApp = express(); const TRANSCRIPT_PORT = 3000; transcriptApp.use('/transcripts', express.static(path.join(__dirname, 'transcripts'))); transcriptApp.get('/', (req, res) => res.send('Transcript server is running!')); transcriptApp.listen(TRANSCRIPT_PORT, '0.0.0.0', () => { console.log(`Transcript server listening at http://0.0.0.0:${TRANSCRIPT_PORT}`); }); // --- 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) // Unclaim order (only by claimer) // Unclaim order (only by claimer) // 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 }); } // Delete previous claimed embed if (order.claimedMsgId) { try { const claimedMsg = await interaction.channel.messages.fetch(order.claimedMsgId); if (claimedMsg) await claimedMsg.delete(); } catch (e) {} order.claimedMsgId = null; } // Set claimedBy to null order.claimedBy = null; // Rename channel back to pending await renameOrderChannel(interaction.channel, order.type, order.orderNumber, 'pending'); // 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); await interaction.channel.setTopic('Not claimed yet.'); return await interaction.reply({ content: 'Order unclaimed and now available for claim.', ephemeral: true }); } // 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); const transcriptUrl = `http://${process.env.PUBLIC_IP || 'YOUR.PUBLIC.IP'}:3000/transcripts/${transcriptFile}`; await logOrderClosure({ orderData: order, closer: interaction.user, transcriptUrl, 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 }); } }); client.on(Events.MessageCreate, async message => { // For legacy testing: send order embed with dropdown on text command if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return; if (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] })); } }); client.login(config.token);