Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-02-10 08:22:19 -06:00
commit 519788c633
39 changed files with 17121 additions and 0 deletions

655
handlers/setup.js Normal file
View File

@@ -0,0 +1,655 @@
/**
* /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<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 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
};