UNPKG

@reaxion/core

Version:

Core utilities for Reaxion bots including modal and paginator systems.

509 lines (450 loc) 15.7 kB
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ComponentType } from 'discord.js'; /** * A flexible, chainable modal builder with enhanced features */ class ModalHandler { /** * Create a new modal builder instance */ constructor() { this.reset(); this.globalHandlers = new Map(); this._defaultTimeout = 300000; // 5 minutes this._waitingPromises = new Map(); } /** * Reset the modal builder to default state * @returns {ModalHandler} The handler instance for chaining */ reset() { this.modalData = { customId: '', title: '', fields: [], handlers: new Map(), validator: null, transformers: new Map(), timeout: this._defaultTimeout, ephemeral: false }; return this; } /** * Create a new modal with basic configuration * @param {String} customId The modal's custom ID * @param {String} title The modal's title * @param {Object} options Additional modal options * @param {Number} [options.timeout] Timeout for awaitSubmit in milliseconds * @param {Boolean} [options.ephemeral] Whether to send ephemeral responses by default * @returns {ModalHandler} The handler instance for chaining */ create(customId, title, options = {}) { this.reset(); this.modalData.customId = customId; this.modalData.title = title; if (options.timeout) this.modalData.timeout = options.timeout; if (options.ephemeral !== undefined) this.modalData.ephemeral = options.ephemeral; return this; } /** * Add a text input field to the modal * @param {Object} field Field configuration * @param {String} field.id Unique field ID * @param {String} field.label Field label text * @param {String} [field.style='paragraph'] Field style ('short' or 'paragraph') * @param {String} [field.placeholder] Placeholder text * @param {String} [field.value] Default value * @param {Number} [field.minLength] Minimum input length * @param {Number} [field.maxLength] Maximum input length * @param {Boolean} [field.required=true] Whether the field is required * @param {Function} [field.transformer] Transform the input value before returning * @returns {ModalHandler} The handler instance for chaining */ addField({ id, label, style = 'paragraph', placeholder = '', value = '', minLength, maxLength, required = true, transformer }) { const fieldStyle = style === 'short' ? TextInputStyle.Short : TextInputStyle.Paragraph; const field = new TextInputBuilder() .setCustomId(id) .setLabel(label) .setStyle(fieldStyle) .setRequired(required); if (placeholder) field.setPlaceholder(placeholder); if (value) field.setValue(value); if (minLength !== undefined) field.setMinLength(minLength); if (maxLength !== undefined) field.setMaxLength(maxLength); this.modalData.fields.push(field); // Add transformer if provided if (typeof transformer === 'function') { this.modalData.transformers.set(id, transformer); } return this; } /** * Add a short text input field (convenience method) * @param {String} id Field ID * @param {String} label Field label * @param {Object} options Field options * @returns {ModalHandler} The handler instance for chaining */ addShortField(id, label, options = {}) { return this.addField({ id, label, style: 'short', ...options }); } /** * Add a paragraph text input field (convenience method) * @param {String} id Field ID * @param {String} label Field label * @param {Object} options Field options * @returns {ModalHandler} The handler instance for chaining */ addParagraph(id, label, options = {}) { return this.addField({ id, label, style: 'paragraph', ...options }); } /** * Add multiple fields at once * @param {Object[]} fields Array of field configurations * @returns {ModalHandler} The handler instance for chaining */ addFields(fields) { fields.forEach(field => this.addField(field)); return this; } /** * Set a validator function to validate form submissions * @param {Function} validator Function that returns true if valid, or error message if invalid * @returns {ModalHandler} The handler instance for chaining */ setValidator(validator) { if (typeof validator === 'function') { this.modalData.validator = validator; } return this; } /** * Set a field transformer function * @param {String} fieldId The field ID * @param {Function} transformer Function to transform the field value * @returns {ModalHandler} The handler instance for chaining */ setTransformer(fieldId, transformer) { if (typeof transformer === 'function') { this.modalData.transformers.set(fieldId, transformer); } return this; } /** * Set a handler function for when the modal is submitted * @param {Function} handler Function to call when modal is submitted * @returns {ModalHandler} The handler instance for chaining */ onSubmit(handler) { if (typeof handler === 'function') { this.modalData.handlers.set(this.modalData.customId, handler); } return this; } /** * Register a global handler for a modal ID * @param {String} modalId Modal custom ID * @param {Function} handler Handler function * @returns {ModalHandler} The handler instance for chaining */ registerHandler(modalId, handler) { if (typeof handler === 'function') { this.globalHandlers.set(modalId, handler); } return this; } /** * Show the modal to the user * @param {Object} interaction The interaction to respond with the modal * @returns {Promise<ModalHandler>} */ async show(interaction) { const modal = new ModalBuilder() .setCustomId(this.modalData.customId) .setTitle(this.modalData.title); // Add fields to modal (each in its own action row) this.modalData.fields.forEach(field => { const row = new ActionRowBuilder().addComponents(field); modal.addComponents(row); }); // Show the modal await interaction.showModal(modal); return this; } /** * Show the modal and wait for submission * @param {Object} interaction The interaction to respond with * @param {Object} options Wait options * @param {Number} [options.timeout] Time to wait for response in ms * @returns {Promise<Object|null>} Submitted values or null if timed out */ async showAndWait(interaction, options = {}) { const timeout = options.timeout || this.modalData.timeout; // Show the modal await this.show(interaction); try { // Create a promise that will resolve when the form is submitted const result = await new Promise((resolve, reject) => { // Store the resolver function this._waitingPromises.set(this.modalData.customId, { interaction, // Store the original interaction resolve, reject, timeout: setTimeout(() => { this._waitingPromises.delete(this.modalData.customId); resolve(null); // Return null on timeout }, timeout) }); }); return result; } catch (error) { console.error('Error waiting for modal submission:', error); return null; } } /** * Handle a modal submit interaction * @param {Object} interaction Modal submit interaction * @returns {Promise<boolean>} Whether the interaction was handled */ async handleSubmit(interaction) { if (!interaction.isModalSubmit()) return false; // Process values with transformers const values = this.getValuesWithTransform(interaction); // Check if we have a promise waiting for this modal const waiting = this._waitingPromises.get(interaction.customId); if (waiting) { clearTimeout(waiting.timeout); this._waitingPromises.delete(interaction.customId); waiting.resolve(values); // We've handled this interaction internally for the Promise // We need to acknowledge it to prevent the "interaction failed" error try { // Only send a defer if this is a fresh interaction without a response if (!interaction.replied && !interaction.deferred) { await interaction.deferUpdate().catch(() => { // If deferUpdate fails, try a different acknowledgment method interaction.deferReply({ ephemeral: true }).catch(e => { console.warn('Failed to acknowledge modal submission:', e); }); }); } return true; } catch (err) { console.warn('Error acknowledging modal interaction:', err); } } // Run validator if present if (this.modalData.validator) { const validationResult = this.modalData.validator(values, interaction); if (validationResult !== true) { const errorMessage = typeof validationResult === 'string' ? validationResult : 'Invalid form submission'; if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: `❌ ${errorMessage}`, ephemeral: this.modalData.ephemeral }).catch(console.error); } return true; } } // Try the local handler first const localHandler = this.modalData.handlers.get(interaction.customId); if (localHandler) { try { await localHandler(interaction, values); return true; } catch (error) { console.error('Error in modal handler:', error); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: '❌ An error occurred while processing your submission.', ephemeral: true }).catch(console.error); } return true; } } // Try the global handler const globalHandler = this.globalHandlers.get(interaction.customId); if (globalHandler) { try { await globalHandler(interaction, values); return true; } catch (error) { console.error('Error in global modal handler:', error); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: '❌ An error occurred while processing your submission.', ephemeral: true }).catch(console.error); } return true; } } // No handler found, but still acknowledge the interaction to prevent error if (!interaction.replied && !interaction.deferred) { try { await interaction.deferUpdate().catch(() => { interaction.deferReply({ ephemeral: true }).catch(console.error); }); } catch (err) { console.warn('Failed to acknowledge unhandled modal submission:', err); } } return false; } /** * Register the modal handler with a client or command * @param {Object} client Discord.js client or interaction handler * @returns {ModalHandler} The handler instance for chaining */ register(client) { // Save the previous interactionCreate handler const prevHandler = client.listeners('interactionCreate').find( listener => listener.name === 'modalHandlerInteractionCreate' ); if (prevHandler) { client.off('interactionCreate', prevHandler); } // Register the new handler const handler = async (interaction) => { if (interaction.isModalSubmit()) { await this.handleSubmit(interaction); } }; // Name the function for later reference Object.defineProperty(handler, 'name', { value: 'modalHandlerInteractionCreate' }); client.on('interactionCreate', handler); return this; } /** * Extract values from a modal submission with transformations applied * @param {Object} interaction Modal submit interaction * @returns {Object} Key-value pairs of field IDs and transformed values */ getValuesWithTransform(interaction) { const values = {}; for (const [id, field] of interaction.fields.fields) { // Apply transformer if one exists for this field const transformer = this.modalData.transformers.get(id); values[id] = transformer ? transformer(field.value) : field.value; } return values; } /** * Extract values from a modal submission * @param {Object} interaction Modal submit interaction * @returns {Object} Key-value pairs of field IDs and values */ static getValues(interaction) { if (!interaction.isModalSubmit()) return {}; const values = {}; for (const [id, field] of interaction.fields.fields) { values[id] = field.value; } return values; } /** * Create a quick feedback modal * @param {String} title Modal title * @param {Object} options Modal options * @returns {ModalHandler} The handler instance for chaining */ feedback(title = 'Provide Feedback', options = {}) { const customId = options.customId || `feedback_${Date.now()}`; return this.create(customId, title, options) .addShortField('subject', 'Subject', { placeholder: 'Brief summary of your feedback', maxLength: 100 }) .addParagraph('feedback', 'Your Feedback', { placeholder: 'Please provide detailed feedback...', minLength: 10, maxLength: 1000 }); } /** * Create a quick confirmation modal * @param {String} title Modal title * @param {String} question Question to ask * @param {Object} options Modal options * @returns {ModalHandler} The handler instance for chaining */ confirm(title = 'Confirmation', question = 'Are you sure?', options = {}) { const customId = options.customId || `confirm_${Date.now()}`; return this.create(customId, title, options) .addParagraph('confirmation', question, { placeholder: 'Type YES to confirm', minLength: 2, maxLength: 100, transformer: value => value.toUpperCase() === 'YES' }) .setValidator(values => { if (!values.confirmation) { return 'Please type YES to confirm'; } return true; }); } /** * Create a pre-configured report modal * @param {String} title Modal title * @param {Object} options Modal options * @returns {ModalHandler} The handler instance for chaining */ reportForm(title = 'Report Form', options = {}) { const customId = options.customId || `report_${Date.now()}`; return this.create(customId, title, {ephemeral: true, ...options}) .addShortField('reportType', 'Type of Report', { placeholder: 'Spam, harassment, inappropriate content, etc.', required: true }) .addShortField('reportedUser', 'User Being Reported (ID or name)', { placeholder: 'User ID or username', required: true }) .addParagraph('description', 'Describe the Issue', { placeholder: 'Please provide details about what happened', minLength: 10, required: true }) .addShortField('evidence', 'Evidence (links or message IDs)', { placeholder: 'Any links or message IDs as evidence', required: false }); } } // Create a singleton instance const modal = new ModalHandler(); export { modal, ModalHandler, TextInputStyle };