/** * /setup wizard – multi-step panel configuration (panel name, support role, * ticket category, transcript channel, panel channel). */ const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ChannelType, ModalBuilder, TextInputBuilder, TextInputStyle, RoleSelectMenuBuilder, ChannelSelectMenuBuilder } = require('discord.js'); const { CONFIG } = require('../config'); const TOTAL_STEPS = 5; const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes /** @type {Map} */ const setupState = new Map(); const PREFIX = 'setup_'; const PREFIX_BUTTON = PREFIX; const PREFIX_MODAL = PREFIX + 'modal_'; const PREFIX_SELECT = PREFIX + 'select_'; function getState(userId) { const s = setupState.get(userId); if (!s) return null; if (Date.now() - s.createdAt > WIZARD_TIMEOUT_MS) { setupState.delete(userId); return null; } return s; } function setState(userId, data) { const existing = setupState.get(userId) || { createdAt: Date.now() }; setupState.set(userId, { ...existing, ...data }); } function clearState(userId) { setupState.delete(userId); } function step1Embed(panelName) { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 1/5 Set the panel name') .setDescription( 'Use the button to set the panel name and continue.\n(This can be changed later.)' ) .addFields({ name: 'Current Name', value: panelName ? `\`${panelName}\`` : 'Not set' }); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'setname') .setLabel('Set name') .setStyle(ButtonStyle.Primary) .setEmoji('⚙️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'continue_1') .setLabel('Save & Continue') .setStyle(ButtonStyle.Success) .setDisabled(!panelName) ); return { embeds: [embed], components: [row] }; } function step2Embed(roleLabels) { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 2/5 Select the support team role(s)') .setDescription( 'The support roles will be automatically added to this panel\'s tickets so they can assist people as needed.\n' + 'Use the dropdown to select roles.\n' + 'Not seeing your role? Try searching for it inside the dropdown.' ) .addFields({ name: 'Selected Role(s)', value: roleLabels && roleLabels.length ? roleLabels.join(', ') : 'None selected' }); const select = new RoleSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'roles') .setPlaceholder('Select all the roles for your support team') .setMinValues(1) .setMaxValues(5); const row1 = new ActionRowBuilder().addComponents(select); const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_2') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'continue_2') .setLabel('Save & Continue') .setStyle(ButtonStyle.Success) .setDisabled(!roleLabels || roleLabels.length === 0) ); return { embeds: [embed], components: [row1, row2] }; } function step3Embed(state) { const ticketType = state.ticketType; const categoryName = state.categoryName; const threadChannelName = state.threadChannelName; if (!ticketType) { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 3/5 How should tickets be created?') .setDescription( '**Channels:** Each ticket is a channel in a category (classic layout).\n' + '**Threads:** Each ticket is a private thread under a text channel (compact).\n' + '**Both:** Create one panel with two buttons (thread + category).' ) .addFields({ name: 'Choice', value: 'Select below' }); const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_channel') .setLabel('Channels in category') .setStyle(ButtonStyle.Primary) .setEmoji('📁'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_thread') .setLabel('Private threads') .setStyle(ButtonStyle.Primary) .setEmoji('🧵'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_both') .setLabel('Both (thread + category)') .setStyle(ButtonStyle.Primary) .setEmoji('📋'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_3') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️') ); return { embeds: [embed], components: [row] }; } if (ticketType === 'both') { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 3/5 Select category and thread channel (both)') .setDescription( 'The panel will have two buttons: one creates ticket **threads**, one creates ticket **channels**.\n' + 'Select the category for channels and the text channel for threads.' ) .addFields( { name: 'Category (for channels)', value: categoryName ? `\`${categoryName}\`` : 'None selected', inline: true }, { name: 'Channel (for threads)', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected', inline: true } ); const row1 = new ActionRowBuilder().addComponents( new ChannelSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'category') .setPlaceholder('Select category for channels') .addChannelTypes(ChannelType.GuildCategory) .setMaxValues(1) ); const row2 = new ActionRowBuilder().addComponents( new ChannelSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'thread_channel') .setPlaceholder('Select channel for threads') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1) ); const row3 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_clear_both_channel') .setLabel('Channels only') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_clear_thread') .setLabel('Threads only') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_3') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'continue_3') .setLabel('Save & Continue') .setStyle(ButtonStyle.Success) .setDisabled(!(categoryName && threadChannelName)) ); return { embeds: [embed], components: [row1, row2, row3] }; } if (ticketType === 'channel') { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 3/5 Select the ticket category') .setDescription( 'The selected category is where ticket **channels** will be created.\n' + 'Use the dropdown to select the category.' ) .addFields({ name: 'Selected Category', value: categoryName ? `\`${categoryName}\`` : 'None selected' }); const select = new ChannelSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'category') .setPlaceholder('Select a category') .addChannelTypes(ChannelType.GuildCategory) .setMaxValues(1); const row1 = new ActionRowBuilder().addComponents(select); const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_clear') .setLabel('Change to Threads') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_3') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'continue_3') .setLabel('Save & Continue') .setStyle(ButtonStyle.Success) .setDisabled(!categoryName) ); return { embeds: [embed], components: [row1, row2] }; } // ticketType === 'thread' const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 3/5 Select the channel for ticket threads') .setDescription( 'Ticket **threads** will be created as private threads under the selected text channel.\n' + 'Use the dropdown to select the channel.' ) .addFields({ name: 'Selected Channel', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected' }); const select = new ChannelSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'thread_channel') .setPlaceholder('Select a text channel') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); const row1 = new ActionRowBuilder().addComponents(select); const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'tickettype_clear') .setLabel('Change to Channels') .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_3') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'continue_3') .setLabel('Save & Continue') .setStyle(ButtonStyle.Success) .setDisabled(!threadChannelName) ); return { embeds: [embed], components: [row1, row2] }; } function step4Embed(channelName) { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 4/5 Select the transcript channel') .setDescription( 'The selected channel is where transcripts will be saved when tickets are closed.\n' + 'Use the dropdown to select the channel.\n' + 'Not seeing your channel? Try searching for it inside the dropdown.' ) .addFields({ name: 'Selected Channel', value: channelName ? `\`${channelName}\`` : 'Not selected' }); const select = new ChannelSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'transcript') .setPlaceholder('Select a channel') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); const row1 = new ActionRowBuilder().addComponents(select); const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_4') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'continue_4') .setLabel('Save & Continue') .setStyle(ButtonStyle.Success) .setDisabled(!channelName) ); return { embeds: [embed], components: [row1, row2] }; } function step5Embed(channelName) { const embed = new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Step 5/5 Send the panel into a channel') .setDescription( 'The ticket creation panel is what the community will use to create tickets.\n' + 'Use the dropdown to select the channel to send the panel into.\n' + 'Not seeing your channel? Try searching for it inside the dropdown.\n' + 'Sending not working? Run `/panel` in the channel directly.' ) .addFields({ name: 'Selected Channel', value: channelName ? `\`${channelName}\`` : 'Not selected' }); const select = new ChannelSelectMenuBuilder() .setCustomId(PREFIX_SELECT + 'panel_channel') .setPlaceholder('Select a channel') .addChannelTypes(ChannelType.GuildText) .setMaxValues(1); const row1 = new ActionRowBuilder().addComponents(select); const row2 = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'back_5') .setLabel('Back') .setStyle(ButtonStyle.Secondary) .setEmoji('⬅️'), new ButtonBuilder() .setCustomId(PREFIX_BUTTON + 'finish') .setLabel('Finish') .setStyle(ButtonStyle.Success) .setDisabled(!channelName) ); return { embeds: [embed], components: [row1, row2] }; } /** * Handle /setup slash command – send Step 1. */ async function handleSetupCommand(interaction) { await interaction.deferReply({ ephemeral: true }); setState(interaction.user.id, { step: 1, panelName: null }); const payload = step1Embed(null); await interaction.editReply(payload); } /** * Handle setup button (Set name, Back, Save & Continue, Finish). */ async function handleSetupButton(interaction) { const customId = interaction.customId; if (!customId.startsWith(PREFIX_BUTTON)) return false; const userId = interaction.user.id; const state = getState(userId); if (!state) { await interaction.reply({ content: 'This setup session has expired. Run `/setup` again.', ephemeral: true }).catch(() => {}); return true; } // Set name → show modal if (customId === PREFIX_BUTTON + 'setname') { const modal = new ModalBuilder() .setCustomId(PREFIX_MODAL + 'name') .setTitle('Panel name'); const input = new TextInputBuilder() .setCustomId('panel_name') .setLabel('Panel name') .setStyle(TextInputStyle.Short) .setPlaceholder('e.g. New Panel') .setRequired(true) .setMaxLength(100); if (state.panelName) input.setValue(state.panelName); modal.addComponents(new ActionRowBuilder().addComponents(input)); await interaction.showModal(modal); return true; } // Back if (customId.startsWith(PREFIX_BUTTON + 'back_')) { const step = parseInt(customId.replace(PREFIX_BUTTON + 'back_', ''), 10); const nextStep = step - 1; setState(userId, { step: nextStep }); let payload; if (nextStep === 1) payload = step1Embed(state.panelName); else if (nextStep === 2) payload = step2Embed(state.roleLabels); else if (nextStep === 3) payload = step3Embed(state); else if (nextStep === 4) payload = step4Embed(state.transcriptChannelName); else payload = step5Embed(state.panelChannelName); await interaction.update(payload); return true; } // Save & Continue (steps 1–4) if (customId === PREFIX_BUTTON + 'continue_1') { setState(userId, { step: 2 }); await interaction.update(step2Embed(state.roleLabels)); return true; } if (customId === PREFIX_BUTTON + 'continue_2') { setState(userId, { step: 3 }); await interaction.update(step3Embed({ ...state, step: 3 })); return true; } if (customId === PREFIX_BUTTON + 'tickettype_channel') { setState(userId, { ticketType: 'channel', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_BUTTON + 'tickettype_thread') { setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_BUTTON + 'tickettype_both') { setState(userId, { ticketType: 'both', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_BUTTON + 'tickettype_clear') { setState(userId, { ticketType: null, categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_BUTTON + 'tickettype_clear_thread') { setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_BUTTON + 'tickettype_clear_both_channel') { setState(userId, { ticketType: 'channel', threadChannelId: null, threadChannelName: null }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_BUTTON + 'continue_3') { setState(userId, { step: 4 }); await interaction.update(step4Embed(state.transcriptChannelName)); return true; } if (customId === PREFIX_BUTTON + 'continue_4') { setState(userId, { step: 5 }); await interaction.update(step5Embed(state.panelChannelName)); return true; } // Finish if (customId === PREFIX_BUTTON + 'finish') { const hasTicketTarget = (state.ticketType === 'channel' && state.categoryId) || (state.ticketType === 'thread' && state.threadChannelId) || (state.ticketType === 'both' && state.categoryId && state.threadChannelId); if (!state.panelChannelId || !hasTicketTarget || !state.roleIds?.length) { await interaction.reply({ content: 'Please complete all steps (panel name, support role, ticket type + category/channel, transcript channel, panel channel).', ephemeral: true }).catch(() => {}); return true; } try { const channel = await interaction.client.channels.fetch(state.panelChannelId); const title = state.panelName || 'Indifferent Broccoli Tickets'; const description = 'Need help? Click below to create a ticket. 🎟'; const embed = new EmbedBuilder() .setTitle(title) .setDescription(description) .setColor(0x2ecc71) .setThumbnail(CONFIG.LOGO_URL || null) .setFooter({ text: 'Indifferent Broccoli Tickets' }); let row; if (state.ticketType === 'both') { row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('open_ticket_thread') .setLabel('Create ticket (thread)') .setStyle(ButtonStyle.Success) .setEmoji('🧵'), new ButtonBuilder() .setCustomId('open_ticket_channel') .setLabel('Create ticket (channel)') .setStyle(ButtonStyle.Success) .setEmoji('📁') ); } else { row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('open_ticket') .setLabel('Create ticket') .setStyle(ButtonStyle.Success) .setEmoji('✅') ); } await channel.send({ embeds: [embed], components: [row] }); const envLines = state.ticketType === 'both' ? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`] : [state.ticketType === 'thread' ? `DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}` : `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`]; const envSnippet = [ '**Add these to your `.env` file** (optional – only if you want to use these values for new Discord tickets):', '```', ...envLines, `ROLE_ID_TO_PING=${state.roleIds[0]}`, `TRANSCRIPT_CHANNEL_ID=${state.transcriptChannelId}`, `LOGGING_CHANNEL_ID=${state.transcriptChannelId}`, '```' ].join('\n'); await interaction.update({ embeds: [ new EmbedBuilder() .setColor(0x2ecc71) .setTitle('Setup complete') .setDescription( `Panel **${title}** has been sent to ${channel}.\n\n` + envSnippet ) ], components: [] }); } catch (err) { console.error('Setup finish error:', err); await interaction.reply({ content: `Failed to send panel: ${err.message}`, ephemeral: true }).catch(() => {}); } clearState(userId); return true; } return false; } /** * Handle setup modal submit (panel name). */ async function handleSetupModal(interaction) { if (!interaction.customId.startsWith(PREFIX_MODAL)) return false; const userId = interaction.user.id; const state = getState(userId); if (!state) { await interaction.reply({ content: 'This setup session has expired. Run `/setup` again.', ephemeral: true }).catch(() => {}); return true; } if (interaction.customId === PREFIX_MODAL + 'name') { const panelName = interaction.fields.getTextInputValue('panel_name').trim(); setState(userId, { panelName, step: 1 }); await interaction.deferReply({ ephemeral: true }); const payload = step1Embed(panelName); await interaction.editReply(payload); return true; } return false; } /** * Handle setup select menus (roles, category, transcript channel, panel channel). */ async function handleSetupSelect(interaction) { const customId = interaction.customId; if (!customId.startsWith(PREFIX_SELECT)) return false; const userId = interaction.user.id; const state = getState(userId); if (!state) { await interaction.reply({ content: 'This setup session has expired. Run `/setup` again.', ephemeral: true }).catch(() => {}); return true; } if (customId === PREFIX_SELECT + 'roles') { const roles = interaction.roles; const roleIds = [...roles.keys()]; const roleLabels = [...roles.values()].map(r => r.name); setState(userId, { roleIds, roleLabels }); await interaction.update(step2Embed(roleLabels)); return true; } if (customId === PREFIX_SELECT + 'category') { const channel = interaction.channels.first(); setState(userId, { categoryId: channel?.id, categoryName: channel?.name }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_SELECT + 'thread_channel') { const channel = interaction.channels.first(); setState(userId, { threadChannelId: channel?.id, threadChannelName: channel?.name }); await interaction.update(step3Embed(getState(userId))); return true; } if (customId === PREFIX_SELECT + 'transcript') { const channel = interaction.channels.first(); setState(userId, { transcriptChannelId: channel?.id, transcriptChannelName: channel?.name }); await interaction.update(step4Embed(channel?.name)); return true; } if (customId === PREFIX_SELECT + 'panel_channel') { const channel = interaction.channels.first(); setState(userId, { panelChannelId: channel?.id, panelChannelName: channel?.name }); await interaction.update(step5Embed(channel?.name)); return true; } return false; } module.exports = { PREFIX_BUTTON, PREFIX_MODAL, PREFIX_SELECT, handleSetupCommand, handleSetupButton, handleSetupModal, handleSetupSelect };