UNPKG

@getsolara/solara.js

Version:

A lightweight and modular Discord bot framework built on discord.js v14, with truly optional feature packages.

558 lines (528 loc) 27.6 kB
const { EmbedBuilder, Collection, InteractionType, ActionRowBuilder, Message, BaseInteraction, GuildMember, User, Role, BaseGuildTextChannel, BaseGuildVoiceChannel, ForumChannel, StageChannel, ThreadChannel, CategoryChannel, Guild, VoiceState, Typing, Presence, MessageReaction, GuildBan, Invite, GuildScheduledEvent, ComponentType, ButtonBuilder, StringSelectMenuBuilder, UserSelectMenuBuilder, RoleSelectMenuBuilder, MentionableSelectMenuBuilder, ChannelSelectMenuBuilder, ModalBuilder, AutoModerationActionExecution } = require('discord.js'); const StopExecutionError = require('../errors/StopExecutionError'); const { COMMAND_TYPES } = require('../utils/constants'); function createBaseExecutionContext(client, commandOrEvent, context, eventArgs, eventName) { const isEvent = !!eventName; return { client: client, command: isEvent ? null : commandOrEvent, eventData: isEvent ? commandOrEvent : null, eventName: eventName, rawContext: context, rawEventArgs: eventArgs, guild: null, channel: null, user: null, member: null, message: null, interaction: null, args: [], options: null, values: null, fields: null, variables: client.variables, localVariables: new Map(), embedData: {}, components: [], attachments: [], modal: null, auditLogReason: null, parsedJson: null, messageSent: false, replied: false, deferred: false, lastMessageID: null, lastMessage: null, }; } function identifyContext(executionContext, context, eventArgs, eventName) { const { client } = executionContext; executionContext.eventName = eventName || executionContext.eventName; if (context instanceof BaseInteraction) { executionContext.interaction = context; executionContext.user = context.user; executionContext.member = context.member instanceof GuildMember ? context.member : null; executionContext.guild = context.guild; executionContext.channel = context.channel; executionContext.replied = context.replied ?? false; executionContext.deferred = context.deferred ?? false; if (context.isChatInputCommand?.()) { executionContext.options = context.options; } else if (context.isAnySelectMenu?.()) { executionContext.values = context.values; } else if (context.isModalSubmit?.()) { executionContext.fields = context.fields; } else if (context.isAutocomplete?.()) { executionContext.options = context.options; } return; } if (context instanceof Message) { executionContext.message = context; executionContext.user = context.author; executionContext.member = context.member; executionContext.guild = context.guild; executionContext.channel = context.channel; if (eventName === 'messageCreate' && !executionContext.command) { executionContext.args = context.content?.split(/ +/) ?? []; } else if (executionContext.command && executionContext.command.type !== COMMAND_TYPES.EVENT) { executionContext.args = eventArgs; } if (eventName === 'messageCreate') executionContext.createdMessage = context; if (eventName === 'messageDelete') executionContext.deletedMessage = context; if (eventName === 'messageUpdate' && eventArgs[0] instanceof Message) { executionContext.oldMessage = context; executionContext.newMessage = eventArgs[0]; } if (eventName === 'messageReactionRemoveAll') executionContext.reactionsMessage = context; return; } switch (eventName) { case 'guildCreate': if (context instanceof Guild) { executionContext.createdGuild = context; executionContext.guild = context; } break; case 'guildDelete': if (context instanceof Guild) { executionContext.deletedGuild = context; executionContext.guild = context; } break; case 'guildUpdate': if (context instanceof Guild && eventArgs[0] instanceof Guild) { executionContext.oldGuild = context; executionContext.newGuild = eventArgs[0]; executionContext.guild = eventArgs[0]; } break; case 'guildMemberAdd': case 'guildMemberRemove': if (context instanceof GuildMember) { executionContext.member = context; executionContext.user = context.user; executionContext.guild = context.guild; if (eventName === 'guildMemberAdd') executionContext.newMember = context; if (eventName === 'guildMemberRemove') executionContext.removedMember = context; } break; case 'guildMemberUpdate': if (context instanceof GuildMember && eventArgs[0] instanceof GuildMember) { executionContext.oldMember = context; executionContext.newMember = eventArgs[0]; executionContext.member = eventArgs[0]; executionContext.user = eventArgs[0].user; executionContext.guild = eventArgs[0].guild; } break; case 'userUpdate': if (context instanceof User && eventArgs[0] instanceof User) { executionContext.oldUser = context; executionContext.newUser = eventArgs[0]; executionContext.user = eventArgs[0]; } break; case 'guildBanAdd': case 'guildBanRemove': if (context instanceof GuildBan) { executionContext.ban = context; executionContext.user = context.user; executionContext.guild = context.guild; } break; case 'roleCreate': case 'roleDelete': if (context instanceof Role) { executionContext.role = context; executionContext.guild = context.guild; if (eventName === 'roleCreate') executionContext.createdRole = context; if (eventName === 'roleDelete') executionContext.deletedRole = context; } break; case 'roleUpdate': if (context instanceof Role && eventArgs[0] instanceof Role) { executionContext.oldRole = context; executionContext.newRole = eventArgs[0]; executionContext.role = eventArgs[0]; executionContext.guild = eventArgs[0].guild; } break; case 'channelCreate': case 'channelDelete': if (context instanceof BaseGuildTextChannel || context instanceof BaseGuildVoiceChannel || context instanceof ForumChannel || context instanceof StageChannel || context instanceof ThreadChannel || context instanceof CategoryChannel) { executionContext.channel = context; executionContext.guild = context.guild; if (eventName === 'channelCreate') executionContext.createdChannel = context; if (eventName === 'channelDelete') executionContext.deletedChannel = context; } break; case 'channelUpdate': const oldCh = context; const newCh = eventArgs[0]; if ((oldCh instanceof BaseGuildTextChannel || oldCh instanceof BaseGuildVoiceChannel || oldCh instanceof ForumChannel || oldCh instanceof StageChannel || oldCh instanceof ThreadChannel || oldCh instanceof CategoryChannel) && (newCh instanceof BaseGuildTextChannel || newCh instanceof BaseGuildVoiceChannel || newCh instanceof ForumChannel || newCh instanceof StageChannel || newCh instanceof ThreadChannel || newCh instanceof CategoryChannel)) { executionContext.oldChannel = oldCh; executionContext.newChannel = newCh; executionContext.channel = newCh; executionContext.guild = newCh.guild; } break; case 'channelPinsUpdate': if (context instanceof BaseGuildTextChannel && typeof eventArgs[0] === 'object') { executionContext.pinnedChannel = context; executionContext.channel = context; executionContext.pinsUpdateTime = eventArgs[0]; executionContext.guild = context.guild; } break; case 'threadCreate': if (context instanceof ThreadChannel) { executionContext.createdThread = context; executionContext.channel = context; executionContext.guild = context.guild; } break; case 'threadDelete': if (context instanceof ThreadChannel) { executionContext.deletedThread = context; executionContext.channel = context; executionContext.guild = context.guild; } break; case 'threadUpdate': if (context instanceof ThreadChannel && eventArgs[0] instanceof ThreadChannel) { executionContext.oldThread = context; executionContext.newThread = eventArgs[0]; executionContext.channel = eventArgs[0]; executionContext.guild = eventArgs[0].guild; } break; case 'voiceStateUpdate': if (context instanceof VoiceState && eventArgs[0] instanceof VoiceState) { executionContext.oldState = context; executionContext.newState = eventArgs[0]; executionContext.member = eventArgs[0].member; executionContext.user = eventArgs[0].member?.user; executionContext.guild = eventArgs[0].guild; executionContext.channel = eventArgs[0].channel; } break; case 'presenceUpdate': const oldPresence = context instanceof Presence ? context : null; const newPresence = eventArgs[0] instanceof Presence ? eventArgs[0] : null; if (newPresence) { executionContext.oldPresence = oldPresence; executionContext.newPresence = newPresence; executionContext.member = newPresence.member; executionContext.user = newPresence.user ?? newPresence.member?.user; executionContext.guild = newPresence.guild; } break; case 'typingStart': if (context instanceof Typing) { executionContext.typing = context; executionContext.user = context.user; executionContext.member = context.member; executionContext.guild = context.guild; executionContext.channel = context.channel; } break; case 'inviteCreate': case 'inviteDelete': if (context instanceof Invite) { executionContext.invite = context; executionContext.guild = context.guild; executionContext.channel = context.channel; executionContext.user = context.inviter; if (context.inviter) { executionContext.member = context.guild?.members.resolve(context.inviter.id); } } break; case 'messageReactionAdd': case 'messageReactionRemove': if (context instanceof MessageReaction && eventArgs[0] instanceof User) { const reaction = context; const user = eventArgs[0]; executionContext.reaction = reaction; executionContext.reactionUser = user; executionContext.message = reaction.message; executionContext.channel = reaction.message.channel; executionContext.guild = reaction.message.guild; executionContext.member = reaction.message.guild?.members.resolve(user.id); executionContext.user = user; if (eventName === 'messageReactionAdd') executionContext.addedReaction = reaction; if (eventName === 'messageReactionRemove') executionContext.removedReaction = reaction; } break; case 'messageReactionRemoveEmoji': if (context instanceof MessageReaction) { executionContext.emojiReaction = context; executionContext.message = context.message; executionContext.channel = context.message.channel; executionContext.guild = context.message.guild; } break; case 'messageDeleteBulk': if (context instanceof Collection && context.first() instanceof Message && eventArgs[0] instanceof BaseGuildTextChannel) { executionContext.deletedMessages = context; executionContext.channel = eventArgs[0]; executionContext.guild = eventArgs[0].guild; } break; case 'guildScheduledEventCreate': case 'guildScheduledEventDelete': if (context instanceof GuildScheduledEvent) { executionContext.scheduledEvent = context; executionContext.guild = context.guild; } break; case 'guildScheduledEventUpdate': if (context instanceof GuildScheduledEvent && eventArgs[0] instanceof GuildScheduledEvent) { executionContext.oldScheduledEvent = context; executionContext.newScheduledEvent = eventArgs[0]; executionContext.scheduledEvent = eventArgs[0]; executionContext.guild = eventArgs[0].guild; } break; case 'guildScheduledEventUserAdd': case 'guildScheduledEventUserRemove': if (context instanceof GuildScheduledEvent && eventArgs[0] instanceof User) { executionContext.scheduledEvent = context; executionContext.user = eventArgs[0]; executionContext.guild = context.guild; } break; case 'autoModerationActionExecution': if (context instanceof AutoModerationActionExecution) { executionContext.autoModExecution = context; executionContext.guild = context.guild; executionContext.user = context.user; executionContext.member = context.member; executionContext.channel = client.channels.resolve(context.channelId); } break; } } function buildResponsePayload(context) { const payload = {}; const finalResult = typeof context.finalResult === 'string' ? context.finalResult.trim() : ''; if (finalResult) { payload.content = finalResult; } if (context.embedData && Object.keys(context.embedData).length > 0) { try { const embed = new EmbedBuilder(context.embedData); if (embed.data.title || embed.data.description || embed.data.fields?.length || embed.data.image || embed.data.thumbnail || embed.data.author || embed.data.footer) { payload.embeds = [embed]; } } catch (e) { console.error(`CommandHandler: Error building embed for command/event:`, e); payload.content = `[Error building embed: ${e.message}]` + (payload.content ? `\n${payload.content}` : ''); } } if (context.components && context.components.length > 0) { payload.components = []; let currentRow = new ActionRowBuilder(); for (const componentData of context.components) { if (!componentData || typeof componentData !== 'object') { console.error("CommandHandler Response: Skipping invalid component data:", componentData); continue; } if (currentRow.components.length >= 5) { payload.components.push(currentRow); currentRow = new ActionRowBuilder(); } try { let componentToAdd; if (typeof componentData.toJSON === 'function' && componentData.data) { componentToAdd = componentData; } else if (componentData.type) { switch (componentData.type) { case ComponentType.Button: componentToAdd = new ButtonBuilder(componentData); break; case ComponentType.StringSelect: componentToAdd = new StringSelectMenuBuilder(componentData); break; case ComponentType.UserSelect: componentToAdd = new UserSelectMenuBuilder(componentData); break; case ComponentType.RoleSelect: componentToAdd = new RoleSelectMenuBuilder(componentData); break; case ComponentType.MentionableSelect: componentToAdd = new MentionableSelectMenuBuilder(componentData); break; case ComponentType.ChannelSelect: componentToAdd = new ChannelSelectMenuBuilder(componentData); break; default: console.warn(`CommandHandler Response: Unsupported component type ${componentData.type} found.`); componentToAdd = null; } } else { console.error("CommandHandler Response: Component data lacks 'type' or is not a builder:", componentData); componentToAdd = null; } if (componentToAdd) { currentRow.addComponents(componentToAdd); } } catch (e) { console.error(`CommandHandler Response: Error processing component. Data: ${JSON.stringify(componentData)}. Error:`, e); } } if (currentRow.components.length > 0) { payload.components.push(currentRow); } payload.components = payload.components.filter(row => row.components.length > 0); if (payload.components.length === 0) { delete payload.components; } } if (context.attachments && context.attachments.length > 0) { payload.files = context.attachments; } payload.hasContent = !!(payload.content || payload.embeds?.length || payload.components?.length || payload.files?.length); return payload; } async function sendResponse(context, payload) { if (!payload.hasContent) { return; } let sentMessage = null; try { if (context.interaction && context.interaction.isRepliable()) { if (context.deferred || context.replied) { sentMessage = await context.interaction.followUp(payload).catch(e => { console.error(`CommandHandler Response: Error sending followUp for interaction ${context.interaction.id}:`, e.message); }); } else { sentMessage = await context.interaction.reply({ ...payload, fetchReply: true }).catch(e => { console.error(`CommandHandler Response: Error sending reply for interaction ${context.interaction.id}:`, e.message); }); context.replied = true; } context.messageSent = true; } else if (context.channel?.isTextBased()) { sentMessage = await context.channel.send(payload).catch(e => { console.error(`CommandHandler Response: Error sending message to channel ${context.channel.id}:`, e.message); }); context.messageSent = true; } else if (context.eventName && !context.channel && context.guild?.systemChannel?.isTextBased()) { console.warn(`CommandHandler Response: Event "${context.eventName}" context lacks a specific channel. Attempting to send to system channel ${context.guild.systemChannel.id}.`); sentMessage = await context.guild.systemChannel.send(payload).catch(e => { console.error(`CommandHandler Response: Error sending message to system channel ${context.guild.systemChannel.id}:`, e.message); }); context.messageSent = true; } else if (context.eventName) { console.warn(`CommandHandler Response: Cannot implicitly send response for event "${context.eventName}" - no usable channel context found.`); } else { console.warn(`CommandHandler Response: Cannot implicitly send response - no usable interaction or channel context found.`); } } catch (error) { console.error(`CommandHandler Response: Unexpected error during sending:`, error); context.messageSent = true; } if (sentMessage instanceof Message) { context.lastMessage = sentMessage; context.lastMessageID = sentMessage.id; } } async function handleExecutionError(context, error) { const commandName = context.command?.name || context.eventData?.name || context.eventName || 'unknown process'; if (error instanceof StopExecutionError) { const stopMessage = error.message; if (!context.messageSent && stopMessage) { const payload = { content: stopMessage, ephemeral: true, embeds: [], components: [], files: [] }; try { if (context.interaction && context.interaction.isRepliable()) { if (context.deferred || context.replied) { await context.interaction.followUp(payload).catch(console.error); } else { await context.interaction.reply(payload).catch(console.error); context.replied = true; } } else if (context.channel?.isTextBased()) { payload.ephemeral = false; await context.channel.send({content: stopMessage}).catch(console.error); } context.messageSent = true; } catch(e) { console.error(`CommandHandler StopError Handler: Error sending stop message for "${commandName}":`, e)} } else if (!context.messageSent) { context.messageSent = true; } } else { console.error(`CommandHandler: Unhandled error during execution of "${commandName}":`, error); const errorMessage = `❌ An internal error occurred while processing this. (${error.message || 'Unknown Error'})`; if (!context.messageSent) { const payload = { content: errorMessage, ephemeral: true, embeds: [], components: [], files: [] }; try { if (context.interaction && context.interaction.isRepliable()) { if (context.deferred || context.replied) { await context.interaction.followUp(payload).catch(console.error); } else { await context.interaction.reply(payload).catch(console.error); context.replied = true; } } else if (context.channel?.isTextBased()) { payload.ephemeral = false; await context.channel.send({content: errorMessage}).catch(console.error); } context.messageSent = true; } catch (replyError) { console.error(`CommandHandler Error Handler: Failed to send execution error message for "${commandName}":`, replyError); } } } } class CommandHandler { constructor(client) { this.client = client; } async executeCommand(commandOrEvent, context, eventArgs = [], eventName = null, isAutocomplete = false) { const executionContext = createBaseExecutionContext(this.client, commandOrEvent, context, eventArgs, eventName); identifyContext(executionContext, context, eventArgs, eventName || commandOrEvent?.event); try { const codeToParse = commandOrEvent.code; if (!codeToParse) { console.warn(`CommandHandler: No 'code' found for ${executionContext.command ? `command "${executionContext.command.name}"` : `event "${executionContext.eventName}"`}. Skipping execution.`); return; } const parseResult = await this.client.functionParser.parse(codeToParse, executionContext); executionContext.finalResult = parseResult; if (isAutocomplete) { if (!executionContext.messageSent) { console.warn(`CommandHandler Autocomplete: Execution finished for "${executionContext.command?.name || 'autocomplete'}", but no autocomplete response was sent (use $autocompleteResult).`); } return; } if (executionContext.modal instanceof ModalBuilder && executionContext.interaction?.isRepliable() && !executionContext.messageSent && !executionContext.replied && !executionContext.deferred) { try { await executionContext.interaction.showModal(executionContext.modal); executionContext.messageSent = true; executionContext.replied = true; } catch (modalError) { console.error(`CommandHandler: Failed to show modal for interaction ${executionContext.interaction.id}:`, modalError); await handleExecutionError(executionContext, new Error("Failed to display the requested modal form.")); } } if (!executionContext.messageSent) { const responsePayload = buildResponsePayload(executionContext); await sendResponse(executionContext, responsePayload); } else { if (!executionContext.finalResult && ( (executionContext.embedData && Object.keys(executionContext.embedData).length > 0) || executionContext.components?.length > 0 || executionContext.attachments?.length > 0)) { const commandName = executionContext.command?.name || executionContext.eventData?.name || executionContext.eventName || 'unknown process'; console.warn(`CommandHandler: Embed/Component/Attachment data prepared for "${commandName}" but was ignored due to an earlier explicit message send, reply, or modal.`); } } } catch (error) { await handleExecutionError(executionContext, error); } } } module.exports = CommandHandler;