@novelbrasil/discord.js-embed-menu
Version:
Easily create Discord.js embed menus with reactions and unlimited customisable pages.
344 lines (304 loc) • 15.5 kB
text/typescript
import { EventEmitter } from 'events';
import { TextChannel, User, Message, EmbedBuilder, PermissionsString, MessageReaction, ReactionCollector, Collection, ChannelType, ButtonBuilder, BaseInteraction, TextBasedChannel, CommandInteraction, InteractionResponse, ActionRowBuilder, ComponentType, InteractionCollector, ButtonInteraction, MessageComponentInteraction, BaseGuildTextChannel } from 'discord.js';
import { ButtonType, DiscordEmbedMenuPage } from './discord_embed_menu_page';
type DiscordEmbedMenuType = {
name: string,
content: EmbedBuilder,
reactions: { [key: string]: string },
buttons?: Record<string, ButtonType>
index: number
}
type StartOptions = {
send: boolean,
followUp: boolean,
reply: boolean
}
export class DiscordEmbedMenu extends EventEmitter {
private static readonly REQUIRED_PERMS: PermissionsString[] = ['SendMessages', 'EmbedLinks', 'AddReactions', 'ManageMessages'];
private static LOADING_MESSAGE: string = 'Carregando, por favor seja paciente...';
public interaction: CommandInteraction;
public channel: TextBasedChannel;
public user: User;
public pages: any[];
public timeout: number;
public deleteOnTimeout: boolean;
public mention: boolean;
public keepUserReactionOnStop: boolean;
public loadingMessage: string;
private isDM: boolean;
private userTag: string;
public currentPage: DiscordEmbedMenuPage;
public pageIndex: number;
public menu: Message | InteractionResponse | null = null;
public reactionCollector: ReactionCollector | null = null;
public componentCollector: MessageComponentInteraction | null = null;
public data: any = {};
public constructor(interaction: BaseInteraction, pages: (DiscordEmbedMenuType | DiscordEmbedMenuPage)[], timeout: number = 300000, deleteOnTimeout: boolean = true, mention: boolean = false, keepUserReactionOnStop: boolean = true, loadingMessage?: string) {
super();
this.interaction = interaction as CommandInteraction;
this.channel = interaction.channel as TextBasedChannel;
this.user = interaction.user;
this.timeout = timeout;
this.deleteOnTimeout = deleteOnTimeout;
this.mention = mention;
this.keepUserReactionOnStop = keepUserReactionOnStop;
this.loadingMessage = loadingMessage || DiscordEmbedMenu.LOADING_MESSAGE;
this.isDM = !this.channel || this.channel.isDMBased();
this.userTag = '<@' + this.user.id + '>';
this.pages = [];
for (let i = 0, l = pages.length; i < l; i++) {
let page = pages[i];
this.pages.push(page instanceof DiscordEmbedMenuPage ? page : new DiscordEmbedMenuPage(page.name, page.content, page.reactions, i, page.buttons));
}
this.currentPage = this.pages[0];
this.pageIndex = 0;
}
public async start(options: StartOptions): Promise<void> {
return await this.setPage(0, options);
}
public async stop(): Promise<void | Message> {
this.stopReactions(false);
if (this.menu && this.keepUserReactionOnStop) {
if (this.menu instanceof Message)
this.menu.reactions.cache.forEach(async (reaction: MessageReaction) => {
if (this.menu && this.menu.client && this.menu.client.user) {
await reaction.users.remove(this.menu.client.user.id);
}
});
} else if (!this.isDM) {
return await this.clearReactions();
}
}
public async delete(): Promise<void | Message> {
this.stopReactions(false);
if (this.menu) {
return await this.menu.delete();
}
}
private async clearReactions(): Promise<void | Message> {
if (this.menu && !this.isDM && this.menu instanceof Message) {
return this.menu.reactions.removeAll();
}
}
public async setPage(page: number | string = 0, options?: StartOptions): Promise<void> {
if (typeof(page) === 'string') {
let pageIndex = this.pages.findIndex(p => p.name === page);
if (pageIndex != -1) {
page = pageIndex;
} else {
throw new Error(`Page "${page}" not found!`);
}
}
this.emit('page-changing', this.pageIndex, this.currentPage, page, this.pages[page]);
this.pageIndex = page;
this.currentPage = this.pages[this.pageIndex];
let content = (!this.isDM && this.mention ? this.userTag : '');
let loadingEmbed = new EmbedBuilder({
title: this.currentPage.content.data.title as string,
description: this.loadingMessage
});
if (this.isDM) {
if (this.menu) {
await this.menu.delete();
this.menu = null;
}
if (this.channel && this.channel instanceof BaseGuildTextChannel) {
this.menu = await this.channel.send({ content, embeds: [loadingEmbed] });
} else {
this.menu = await this.user.send({ content, embeds: [loadingEmbed] });
this.channel = this.menu.channel as TextChannel;
}
} else {
if (this.menu) {
await this.menu.edit({ content, embeds: [loadingEmbed], components: [] });
} else {
if (options?.reply)
this.menu = await this.interaction.reply({ content, embeds: [loadingEmbed], components: [] });
else if (options?.followUp)
this.menu = await this.interaction.followUp({ content, embeds: [loadingEmbed], components: [] });
else if (this.channel instanceof BaseGuildTextChannel) this.menu = await this.channel.send({ content, embeds: [loadingEmbed], components: [] });
}
}
if (this.currentPage.reactions) {
this.stopReactions(true);
await this.addReactions();
this.awaitReactions();
}
const components: ActionRowBuilder<ButtonBuilder>[] = [];
if (this.currentPage.buttons) {
const buttons = this.currentPage.buttons as Record<string, ButtonType>
for (let i = 0; i < Object.keys(buttons).length; i++)
if (i % 5 == 0)
components.push(new ActionRowBuilder<ButtonBuilder>())
for (const row of components) {
for (const button of Object.values(buttons)) {
row.addComponents(button.button)
}
}
}
if (!this.menu)
throw new Error('Menu is not defined, cannot edit it.');
this.menu = await this.menu.edit({content, embeds: [this.currentPage.content], components });
if (this.currentPage.buttons) {
await this.awaitButtons()
}
this.emit('page-changed', this.pageIndex, this.currentPage);
}
private async addReactions(): Promise<MessageReaction[]> {
let reactions: MessageReaction[] = [];
if (this.menu && this.menu instanceof Message) {
for (let reaction in this.currentPage.reactions) {
reactions.push(await this.menu.react(reaction));
}
}
return reactions;
}
private stopReactions(triggerEnd: boolean = true) {
if (this.reactionCollector) {
if (!triggerEnd) {
this.reactionCollector.removeAllListeners();
}
this.reactionCollector.stop();
this.reactionCollector = null;
}
}
private async awaitButtons() {
if (this.menu && this.menu instanceof Message) {
const filter = (_interaction: any): boolean => {
return _interaction.user.id === this.user.id
}
try {
const component = await this.menu.awaitMessageComponent({ filter, time: this.timeout, componentType: ComponentType.Button })
if (!component) return
await component.deferUpdate()
const key = Object.prototype.hasOwnProperty.call(this.currentPage.buttons, component.customId as string)
? component.customId
: Object.prototype.hasOwnProperty.call(this.currentPage.buttons, component.id as string) ? component.id : null;
if (key && this.menu && this.menu instanceof Message) {
if (component.user.id !== this.user.id) return
if (!this.currentPage.buttons) return
if (typeof this.currentPage.buttons[key].action !== 'string') {
this.currentPage.buttons[key].action(this);
}
switch (this.currentPage.buttons[key].action) {
case "first":
this.setPage(0);
break
case "last":
this.setPage(this.pages.length - 1);
break
case 'previous': {
if (this.pageIndex > 0) {
this.setPage(this.pageIndex - 1);
}
break;
}
case 'next': {
if (this.pageIndex < this.pages.length - 1) {
this.setPage(this.pageIndex + 1);
}
break;
}
case 'delete': {
this.delete()
break
}
default: {
this.setPage(
this.pages.findIndex(p => this.currentPage.buttons && p.name === this.currentPage.buttons[key].action)
);
break;
}
}
}
} catch (error) {
if (!this.isDM) {
if (this.deleteOnTimeout) {
await this.delete();
} else {
await this.menu?.edit({ components: [] });
}
}
}
}
}
private awaitReactions() {
if (this.menu && this.menu instanceof Message) {
const filter = (_reaction: MessageReaction, user: User): boolean => {
return this.menu != null && this.menu.client != null && this.menu.client.user != null && user.id != this.menu.client.user.id;
}
this.reactionCollector = this.menu.createReactionCollector({ filter, time: this.timeout });
let reactionsChanged: boolean;
this.reactionCollector.on('end', async (reactions: Collection<string, MessageReaction>) => {
if (!this.isDM) {
if (reactions) {
if (reactionsChanged) {
return await this.clearReactions();
} else if (this.menu) {
await reactions.at(0)?.users.remove(this.menu.client.users.cache.get(this.user.id));
}
} else if (this.deleteOnTimeout) {
return await this.delete();
} else {
return await this.clearReactions();
}
}
})
this.reactionCollector.on('collect', (reaction: MessageReaction, user: User) => {
const reactionName = Object.prototype.hasOwnProperty.call(this.currentPage.reactions, reaction.emoji.name as string)
? reaction.emoji.name
: Object.prototype.hasOwnProperty.call(this.currentPage.reactions, reaction.emoji.id as string) ? reaction.emoji.id : null;
if (user.id !== this.user.id || !Object.keys(this.currentPage.reactions).includes(reactionName as string)) {
return reaction.users.remove(user);
}
if (reactionName && this.menu && this.menu instanceof Message) {
if (typeof this.currentPage.reactions[reactionName] === 'function') {
// this this flag is not true then the clearReaction() at ligne 188 will be never call when try to change page
// also test when no page change it works too
reactionsChanged=true;
return this.currentPage.reactions[reactionName](this);
}
switch (this.currentPage.reactions[reactionName]) {
case 'first': {
reactionsChanged = JSON.stringify(this.menu.reactions.cache.keys()) != JSON.stringify(Object.keys(this.pages[0].reactions));
this.setPage(0);
break;
}
case 'last': {
reactionsChanged = JSON.stringify(this.menu.reactions.cache.keys()) != JSON.stringify(Object.keys(this.pages[this.pages.length - 1].reactions));
this.setPage(this.pages.length - 1);
break;
}
case 'previous': {
if (this.pageIndex > 0) {
reactionsChanged = JSON.stringify(this.menu.reactions.cache.keys()) != JSON.stringify(Object.keys(this.pages[this.pageIndex - 1].reactions));
this.setPage(this.pageIndex - 1);
}
break;
}
case 'next': {
if (this.pageIndex < this.pages.length - 1) {
reactionsChanged = JSON.stringify(this.menu.reactions.cache.keys()) != JSON.stringify(Object.keys(this.pages[this.pageIndex + 1].reactions));
this.setPage(this.pageIndex + 1);
}
break;
}
case 'stop': {
this.stop();
break;
}
case 'delete': {
this.delete();
break;
}
default: {
reactionsChanged = JSON.stringify(this.menu.reactions.cache.keys()) != JSON.stringify(Object.keys(this.pages.find(p => p.name === this.currentPage.reactions[reactionName]).reactions));
this.setPage(this.pages.findIndex(p => p.name === this.currentPage.reactions[reactionName]));
break;
}
}
}
});
}
}
}