@reaxion/core
Version:
Core utilities for Reaxion bots including modal and paginator systems.
509 lines (450 loc) • 15.7 kB
JavaScript
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 };