import { ActionRowBuilder, ChannelSelectMenuBuilder, Events, MentionableSelectMenuBuilder, ModalBuilder, RoleSelectMenuBuilder, StringSelectMenuBuilder, TextInputBuilder, UserSelectMenuBuilder } from "discord.js"; type ConfigPartType = "text" | "number" | "boolean" | "user" | "role" | "mentionable" | "channel"; // Does not need individual instances export class ConfigPart { public name: string; public partId: string; public description: string; public type: ConfigPartType; public sendIn: "modal" | "message"; constructor(name: string, description: string, type: ConfigPartType, sendIn: "modal" | "message") { this.name = name; this.description = description; this.type = type; this.sendIn = sendIn; this.partId = ""; } public build(partId: string = ""): any { this.partId = partId; return null; } } type InputConfigPartType = "text" | "number"; export class InputConfigPart extends ConfigPart { public placeholder: string; public defaultValue: string | number; public type: InputConfigPartType; public sendIn: "modal"; public constructor(name: string, description: string, placeholder: string, defaultValue: string | number, type: InputConfigPartType) { super(name, description, type, "modal"); this.placeholder = placeholder; this.defaultValue = defaultValue; this.type = type; this.sendIn = "modal"; // Always sent in a modal } public build(partId: string): TextInputBuilder { super.build(partId); return new TextInputBuilder() .setCustomId(this.name + this.partId) .setLabel(this.description) .setPlaceholder(this.placeholder) .setValue(this.defaultValue.toString()); } } type ChannelType = "GUILD_TEXT" | "GUILD_VOICE" | "GUILD_NEWS" | "GUILD_STORE" | "GUILD_STAGE_VOICE" | "GUILD_PUBLIC_THREAD" | "GUILD_PRIVATE_THREAD" | "GUILD_NEWS_THREAD" | "GUILD_CATEGORY" | "GUILD_DIRECTORY" | "GUILD_FORUM"; type SelectConfigPartType = ConfigPartType; export class SelectConfigPart extends ConfigPart { public options: { label: string, value: string, emoji?: string }[]; public type: SelectConfigPartType; public min?: number; // If min/max not provided, only one option can be selected public max?: number; // If min/max not provided, only one option can be selected public channelTypes?: ChannelType[]; // If type is "channel", this specifies which channel types are allowed (otherwise, ignored) public sendIn: "message"; constructor(name: string, description: string, options: { label: string, value: string, emoji?: string }[], type: SelectConfigPartType, min?: number, max?: number, channelTypes?: ChannelType[]) { super(name, description, type, "message"); this.options = options; this.type = type; this.min = min; this.max = max; this.channelTypes = channelTypes; this.sendIn = "message"; // Always sent in a message } public build(partId: string): any { super.build(partId); const customId = this.name + this.partId; switch (this.type) { case "text": // Assuming 'text' type for select means StringSelectMenu case "boolean": // Boolean can also be a string select "Yes"/"No" const stringSelect = new StringSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(this.description) .addOptions(this.options); if (this.min !== undefined) stringSelect.setMinValues(this.min); if (this.max !== undefined) stringSelect.setMaxValues(this.max); return new ActionRowBuilder().addComponents(stringSelect); case "channel": const channelSelect = new ChannelSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(this.description); // TODO: Implement channel type restrictions if (this.min !== undefined) channelSelect.setMinValues(this.min); if (this.max !== undefined) channelSelect.setMaxValues(this.max); return new ActionRowBuilder().addComponents(channelSelect); case "user": const userSelect = new UserSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(this.description); if (this.min !== undefined) userSelect.setMinValues(this.min); if (this.max !== undefined) userSelect.setMaxValues(this.max); return new ActionRowBuilder().addComponents(userSelect); case "role": const roleSelect = new RoleSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(this.description); if (this.min !== undefined) roleSelect.setMinValues(this.min); if (this.max !== undefined) roleSelect.setMaxValues(this.max); return new ActionRowBuilder().addComponents(roleSelect); case "mentionable": const mentionableSelect = new MentionableSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(this.description); if (this.min !== undefined) mentionableSelect.setMinValues(this.min); if (this.max !== undefined) mentionableSelect.setMaxValues(this.max); return new ActionRowBuilder().addComponents(mentionableSelect); default: console.warn(`Unsupported select type: ${this.type}`); return null; } } } // Please make a new instance of this class for each config session (unlike ConfigPart, which can use a single instance) export class ConfigSession { public title: string; public parts: ConfigPart[]; public modalId: string; private modalSubmitHandler: (interaction: any) => void; private selectMenuSubmitHandler: (interaction: any) => void; public constructor(title: string) { this.title = title; this.parts = []; } public send(interaction) { const randomId = Math.floor(Math.random() * 1000000); this.modalId = this.title + randomId; const modal = new ModalBuilder() .setCustomId(this.modalId) .setTitle(this.title); const modalParts = this.parts.filter(p => p.sendIn === "modal"); const selectParts = this.parts.filter(p => p.sendIn === "message"); for (const part of modalParts) { modal.addComponents([new ActionRowBuilder().addComponents([part.build(randomId.toString())])]); } if (modal.components.length === 0) { if (selectParts.length > 0) { // If no modal parts, but there are select parts, send them directly const messageComponents = []; for (const part of selectParts) { const component = part.build(randomId.toString()); if (component) messageComponents.push(component); } if (messageComponents.length > 0) { interaction.reply({ content: "Please configure the following options:", components: messageComponents, ephemeral: true }); // Register select menu listener this.selectMenuSubmitHandler = this.handleSelectMenu.bind(this); interaction.client.on(Events.InteractionCreate, this.selectMenuSubmitHandler); // TODO: Consider a timeout for this listener as well. return; // Stop further execution as we've sent the select menus } else { // This case should ideally not happen if selectParts.length > 0 interaction.reply({content: "No configuration parts to display.", ephemeral: true}); return; } } else { // No modal parts and no select parts interaction.reply({ content: "No configuration parts to display.", ephemeral: true }); return; } } interaction.showModal(modal); // Bind this to ensure correct context in modalSubmit and for listener removal this.modalSubmitHandler = this.modalSubmit.bind(this); interaction.client.on(Events.InteractionCreate, this.modalSubmitHandler); } public modalSubmit(interaction) { if (!interaction.isModalSubmit()) return; if (interaction.customId !== this.modalId) return; const changes: { [key: string]: any } = {}; const modalParts = this.parts.filter(part => part.sendIn === "modal"); for (const part of modalParts) { if (part instanceof InputConfigPart) { const customIdFromModal = part.name + part.partId; // This should be how TextInputBuilder set it changes[part.name] = interaction.fields.getTextInputValue(customIdFromModal); } } console.log(`Modal submitted for ${this.title}. Changes:`, changes); const selectParts = this.parts.filter(part => part.sendIn === "message"); if (selectParts.length > 0) { const messageComponents = []; for (const part of selectParts) { // Pass the randomId (converted to string) to ensure unique partIds for select menus as well const component = part.build(this.modalId.replace(this.title, "")); // Use the randomId part of modalId if (component) messageComponents.push(component); } if (messageComponents.length > 0) { interaction.reply({ content: "Please configure the following options:", components: messageComponents, ephemeral: true }); // Listener for select menus will be added in the next step } else { interaction.reply({ content: "Modal configuration received! No further select options.", ephemeral: true }); } } else { interaction.reply({content: "Configuration saved!", ephemeral: true}); } interaction.client.off(Events.InteractionCreate, this.modalSubmitHandler); // If select menus were sent from modalSubmit, or directly from send(), set up listener for them. if (selectParts.length > 0 && messageComponents.length > 0) { this.selectMenuSubmitHandler = this.handleSelectMenu.bind(this); interaction.client.on(Events.InteractionCreate, this.selectMenuSubmitHandler); // TODO: Consider a timeout for this listener as well, and how to clean it up if no interaction occurs. } } public handleSelectMenu(interaction) { if (!interaction.isAnySelectMenu()) return; const randomId = this.modalId.replace(this.title, ""); const relevantPart = this.parts.find(part => part.sendIn === "message" && (part.name + randomId) === interaction.customId); if (!relevantPart) return; // Not for this session or part const changes: { [key: string]: any } = {}; const selectedValues = interaction.values; // .values is an array of selected string values if (selectedValues.length > 0) { // For single select, values[0]. For multi-select, the array. changes[relevantPart.name] = selectedValues.length === 1 ? selectedValues[0] : selectedValues; } console.log(`Select menu submitted for ${this.title}, part ${relevantPart.name}. Changes:`, changes); interaction.reply({content: `Selection for ${relevantPart.name} received!`, ephemeral: true}); // TODO: Determine if more select menus are pending or if config is complete. if (this.selectMenuSubmitHandler) { interaction.client.off(Events.InteractionCreate, this.selectMenuSubmitHandler); } } }