Files
broccolini-bot/handlers/setup.js
2026-04-18 11:10:41 +00:00

657 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* /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 { enqueueSend } = require('../services/channelQueue');
const TOTAL_STEPS = 5;
const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
/** @type {Map<string, { step: number, panelName?: string, roleIds?: string[], ticketType?: 'channel'|'thread', categoryId?: string, categoryName?: string, threadChannelId?: string, threadChannelName?: string, transcriptChannelId?: string, panelChannelId?: string, createdAt: number }>} */
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 14)
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 enqueueSend(channel, { 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
};