From 2aeacc0a773dc5f62c07faf7bb7dbb1194d11596 Mon Sep 17 00:00:00 2001 From: Snazzah Date: Thu, 24 Jun 2021 00:28:26 -0500 Subject: [PATCH] Add pager and confirmation prompt --- config/_default.js | 1 + package.json | 2 + src/util/index.ts | 89 +++++++++++++++++++++++ src/util/pager.ts | 174 +++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 17 +++++ 5 files changed, 283 insertions(+) create mode 100644 src/util/pager.ts diff --git a/config/_default.js b/config/_default.js index 625d6b9..1e66f75 100644 --- a/config/_default.js +++ b/config/_default.js @@ -36,6 +36,7 @@ module.exports = { messageLimit: 0, intents: [ "guilds", + "guildMembers", "guildMessages", "guildMessageReactions", "directMessages", diff --git a/package.json b/package.json index 663226a..d4a7774 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "config": "^3.3.6", "dexare": "^2.0.1", "eventemitter3": "^4.0.7", + "lodash.chunk": "^4.2.0", "quick.db": "^7.1.3", "steno": "^2.0.0" }, @@ -28,6 +29,7 @@ "@types/common-tags": "^1.8.0", "@types/config": "^0.0.38", "@types/cron": "^1.7.2", + "@types/lodash.chunk": "^4.2.6", "@types/needle": "^2.5.1", "@types/node": "^15.12.4", "@typescript-eslint/eslint-plugin": "^4.28.0", diff --git a/src/util/index.ts b/src/util/index.ts index 9317cf0..ccc148a 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,3 +1,6 @@ +import { CommandContext } from 'dexare'; +import Eris from 'eris'; + /** * Make a promise that resolves after some time * @param ms The time to resolve at @@ -70,3 +73,89 @@ export function splitMessage( } return messages.concat(msg).filter((m) => m); } + +export enum ConfirmEmoji { + YES = '✔️', + NO = '❌' +} + +/** + * Create a confirmation prompt. + * @param ctx The context to use + * @param content The content to send + * @param file The file(s) + */ +export async function confirm( + ctx: CommandContext, + content: Eris.MessageContent, + file?: Eris.MessageFile | Eris.MessageFile[] +): Promise { + const botUser = ctx.client.bot.user.id; + const message = await ctx.reply(content, file); + const group = `confirm:${message.id}`; + + return new Promise((resolve) => { + const timeout = setTimeout(() => cb(false), 30000); + const cb = (result: boolean, destroyed = false) => { + clearTimeout(timeout); + if (!destroyed) message.delete().catch(() => {}); + ctx.client.events.unregisterGroup(group); + resolve(result); + }; + + /* #region events */ + ctx.client.events.register(group, 'messageReactionAdd', (_, { id }, emoji, member) => { + if (message.id === id && member.id === ctx.author.id) { + if (emoji.name === ConfirmEmoji.YES) cb(true); + if (emoji.name === ConfirmEmoji.NO) cb(false); + } + }); + + ctx.client.events.register( + group, + 'messageCreate', + (event, reply) => { + if (reply.channel.id === message.channel.id && reply.author.id === ctx.author.id) { + if (reply.content.toLowerCase() === 'yes') { + event.skip('commands'); + cb(true); + } else if (reply.content.toLowerCase() === 'no') { + event.skip('commands'); + cb(false); + } + } + }, + { before: ['commands'] } + ); + + ctx.client.events.register(group, 'messageDelete', (_, { id }) => { + if (message.id === id) cb(false, true); + }); + + ctx.client.events.register(group, 'messageDeleteBulk', (_, messages) => { + for (const { id } of messages) if (message.id === id) return cb(false, true); + }); + + ctx.client.events.register(group, 'channelDelete', (_, channel) => { + if (message.channel.id === channel.id) return cb(false, true); + }); + + if (message.guildID) { + ctx.client.events.register(group, 'guildDelete', (_, guild) => { + if (message.guildID === guild.id) return cb(false, true); + }); + + ctx.client.events.register(group, 'guildMemberRemove', (_, guild, member) => { + if (message.guildID === guild.id && member.id === ctx.author.id) return cb(false); + }); + + ctx.client.events.register(group, 'guildBanAdd', (_, guild, user) => { + if (message.guildID === guild.id && user.id === ctx.author.id) return cb(false); + }); + } + /* #endregion */ + + if ('permissionsOf' in ctx.channel ? ctx.channel.permissionsOf(botUser).has('addReactions') : true) + Promise.all([ConfirmEmoji.YES, ConfirmEmoji.NO].map(message.addReaction)).catch(() => {}); + }); +} diff --git a/src/util/pager.ts b/src/util/pager.ts new file mode 100644 index 0000000..17f4aae --- /dev/null +++ b/src/util/pager.ts @@ -0,0 +1,174 @@ +import { ClientEvent, CommandContext } from 'dexare'; +import Eris from 'eris'; +import chunk from 'lodash.chunk'; +import { splitMessage } from '.'; + +export enum PagerEmoji { + STOP = '🛑', + NEXT = '➡️', + PREVIOUS = '⬅️', + FIRST = '⏮️', + LAST = '⏭️' +} + +export interface PagerOptions { + items: string[]; + itemsPerPage?: number | 'auto'; + itemSeparator?: string; + characterLimit?: number; + idleTime?: number; + startPage?: number; + title?: string; +} + +export interface InternalPagerStopOptions { + destroy?: boolean; + destroyed?: boolean; +} + +export interface InternalPagerTurnOptions { + emoji?: Eris.Emoji; + event?: ClientEvent; + reply?: Eris.Message; +} + +/** + * Create a pagination. + */ +export async function paginate( + ctx: CommandContext, + { + items, + itemsPerPage = 'auto', + itemSeparator = '\n', + characterLimit = 2048, + idleTime = 30000, + startPage = 1, + title = 'Items' + }: PagerOptions, + embed?: Eris.EmbedOptions +) { + /* #region pages */ + const pages: string[] = + itemsPerPage === 'auto' + ? splitMessage(items.join(itemSeparator), { maxLength: characterLimit }) + : chunk(items, itemsPerPage).map((page) => page.join(itemSeparator)); + if (isNaN(startPage) || startPage <= 1) startPage = 1; + else if (startPage > pages.length) startPage = pages.length; + let page = startPage; + + const render = (page: number): Eris.MessageContent => ({ + embed: { + ...(embed || {}), + title: `${title} [${page}/${pages.length}]`, + description: pages[page + 1] + } + }); + /* #endregion */ + + const botUser = ctx.client.bot.user.id; + const canReact = + 'permissionsOf' in ctx.channel ? ctx.channel.permissionsOf(botUser).has('addReactions') : true; + const canManage = + 'permissionsOf' in ctx.channel ? ctx.channel.permissionsOf(botUser).has('manageMessages') : false; + const message = await ctx.reply(render(page)); + const group = `pager:${message.id}`; + let reacted: string[] = []; + + /* #region page functions */ + let idleTimeout = setTimeout(() => stop(), idleTime); + const stop = ({ destroy = false, destroyed = false }: InternalPagerStopOptions = {}) => { + clearTimeout(idleTimeout); + if (!destroyed && destroy) message.delete().catch(() => {}); + // Remove reactions + if (!destroyed && !destroy) { + if (canManage) message.removeReactions().catch(() => {}); + else Promise.all(reacted.map((emoji) => message.removeReaction(emoji))).catch(() => {}); + } + ctx.client.events.unregisterGroup(group); + }; + const turn = (toPage: number, { emoji, event, reply }: InternalPagerTurnOptions) => { + if (emoji && canManage) message.removeReaction(emoji.name, ctx.author.id).catch(() => {}); + if (event) event.skip('commands'); + if (reply && canManage) reply.delete().catch(() => {}); + clearTimeout(idleTimeout); + idleTimeout = setTimeout(() => stop(), idleTime); + page = toPage; + message.edit(render(page)).catch(() => {}); + }; + /* #endregion */ + + /* #region events */ + ctx.client.events.register(group, 'messageReactionAdd', (_, { id }, emoji, member) => { + if (message.id === id && member.id === ctx.author.id) { + if (emoji.name === PagerEmoji.FIRST && page !== 1) turn(1, { emoji }); + if (emoji.name === PagerEmoji.LAST && page !== pages.length) turn(pages.length, { emoji }); + if (emoji.name === PagerEmoji.NEXT && page > pages.length) turn(page + 1, { emoji }); + if (emoji.name === PagerEmoji.PREVIOUS && page <= 1) turn(page - 1, { emoji }); + if (emoji.name === PagerEmoji.STOP) stop({ destroy: true }); + } + }); + + ctx.client.events.register( + group, + 'messageCreate', + (event, reply) => { + if (reply.channel.id === message.channel.id && reply.author.id === ctx.author.id) { + if (['>>', 'first'].includes(reply.content.toLowerCase()) && page !== 1) turn(1, { event, reply }); + if (['<<', 'last'].includes(reply.content.toLowerCase()) && page !== pages.length) + turn(pages.length, { event, reply }); + if (['>', 'next', 'forward'].includes(reply.content.toLowerCase()) && page > pages.length) + turn(page + 1, { event, reply }); + if (['<', 'previous', 'prev', 'back'].includes(reply.content.toLowerCase()) && page <= 1) + turn(page - 1, { event, reply }); + + if (reply.content.toLowerCase().startsWith('page ') && reply.content.length > 5) { + let newPage = parseInt(reply.content.slice(4).trim()); + if (isNaN(startPage)) return; + if (newPage <= 1) newPage = 1; + else if (startPage > pages.length) startPage = pages.length; + if (page !== newPage) { + page = newPage; + turn(newPage, { event, reply }); + } + } + } + }, + { before: ['commands'] } + ); + + ctx.client.events.register(group, 'messageDelete', (_, { id }) => { + if (message.id === id) stop({ destroyed: true }); + }); + + ctx.client.events.register(group, 'messageDeleteBulk', (_, messages) => { + for (const { id } of messages) if (message.id === id) stop({ destroyed: true }); + }); + + ctx.client.events.register(group, 'channelDelete', (_, channel) => { + if (message.channel.id === channel.id) stop({ destroyed: true }); + }); + + if (message.guildID) { + ctx.client.events.register(group, 'guildDelete', (_, guild) => { + if (message.guildID === guild.id) stop({ destroyed: true }); + }); + + ctx.client.events.register(group, 'guildMemberRemove', (_, guild, member) => { + if (message.guildID === guild.id && member.id === ctx.author.id) stop({ destroy: true }); + }); + + ctx.client.events.register(group, 'guildBanAdd', (_, guild, user) => { + if (message.guildID === guild.id && user.id === ctx.author.id) stop({ destroy: true }); + }); + } + /* #endregion */ + + if (canReact && pages.length > 1) { + reacted = + pages.length === 2 + ? [PagerEmoji.PREVIOUS, PagerEmoji.STOP, PagerEmoji.NEXT] + : [PagerEmoji.FIRST, PagerEmoji.PREVIOUS, PagerEmoji.STOP, PagerEmoji.NEXT, PagerEmoji.LAST]; + Promise.all(reacted.map(message.addReaction)).catch(() => {}); + } +} diff --git a/yarn.lock b/yarn.lock index 1cea006..93d4944 100644 --- a/yarn.lock +++ b/yarn.lock @@ -151,6 +151,18 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== +"@types/lodash.chunk@^4.2.6": + version "4.2.6" + resolved "https://registry.yarnpkg.com/@types/lodash.chunk/-/lodash.chunk-4.2.6.tgz#9d35f05360b0298715d7f3d9efb34dd4f77e5d2a" + integrity sha512-SPlusB7jxXyGcTXYcUdWr7WmhArO/rmTq54VN88iKMxGUhyg79I4Q8n4riGn3kjaTjOJrVlHhxgX/d7woak5BQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.170" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6" + integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q== + "@types/needle@^2.5.1": version "2.5.1" resolved "https://registry.yarnpkg.com/@types/needle/-/needle-2.5.1.tgz#2923f4a63a66048aed3038d76e8bc83b6905c784" @@ -1508,6 +1520,11 @@ lodash.camelcase@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= +lodash.chunk@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc" + integrity sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw= + lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"