From a493536a23edcfc7124302f75917216dd1e3a58d Mon Sep 17 00:00:00 2001 From: WatDuhHekBro <44940783+WatDuhHekBro@users.noreply.github.com> Date: Sun, 11 Apr 2021 05:45:50 -0500 Subject: [PATCH] Refactored paginate and added poll to library --- docs/Documentation.md | 11 +- src/commands/fun/modules/eco-bet.ts | 79 ++++---- src/commands/fun/modules/eco-shop.ts | 17 +- src/commands/system/help.ts | 25 +-- src/commands/utility/lsemotes.ts | 23 +-- src/commands/utility/todo.ts | 1 - src/core/eventListeners.ts | 26 ++- src/core/libd.ts | 271 +++++++++++++++------------ 8 files changed, 231 insertions(+), 222 deletions(-) diff --git a/docs/Documentation.md b/docs/Documentation.md index 28c2c32..173f686 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -75,9 +75,16 @@ Because versions are assigned to batches of changes rather than single changes ( ```ts const pages = ["one", "two", "three"]; -paginate(send, page => { +paginate(send, author.id, pages.length, page => { return {content: pages[page]}; -}, pages.length, author.id); +}); +``` + +`poll()` +```ts +const results = await poll(await send("Do you agree with this decision?"), ["✅", "❌"]); +results["✅"]; // number +results["❌"]; // number ``` `confirm()` diff --git a/src/commands/fun/modules/eco-bet.ts b/src/commands/fun/modules/eco-bet.ts index 7b38485..3bcb103 100644 --- a/src/commands/fun/modules/eco-bet.ts +++ b/src/commands/fun/modules/eco-bet.ts @@ -1,4 +1,4 @@ -import {Command, NamedCommand, confirm} from "../../../core"; +import {Command, NamedCommand, confirm, poll} from "../../../core"; import {pluralise} from "../../../lib"; import {Storage} from "../../../structures"; import {isAuthorized, getMoneyEmbed} from "./eco-utils"; @@ -113,54 +113,41 @@ export const BetCommand = new NamedCommand({ const receiver = Storage.getUser(target.id); // [TODO: when D.JSv13 comes out, inline reply to clean up.] // When bet is over, give a vote to ask people their thoughts. - const voteMsg = await send( - `VOTE: do you think that <@${ - target.id - }> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${ - message.id - }` - ); - await voteMsg.react("✅"); - await voteMsg.react("❌"); - // Filter reactions to only collect the pertinent ones. - voteMsg - .awaitReactions( - (reaction, user) => { - return ["✅", "❌"].includes(reaction.emoji.name); - }, - // [Pertinence to make configurable on the fly.] - {time: parseDuration("2m")} - ) - .then((reactions) => { - // Count votes - const okReaction = reactions.get("✅"); - const noReaction = reactions.get("❌"); - const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0; - const no = noReaction ? (noReaction.count ?? 1) - 1 : 0; + const results = await poll( + await send( + `VOTE: do you think that <@${ + target.id + }> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${ + message.id + }` + ), + ["✅", "❌"], + // [Pertinence to make configurable on the fly.] + parseDuration("2m") + ); - if (ok > no) { - receiver.money += amount * 2; - send( - `By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.` - ); - } else if (ok < no) { - sender.money += amount * 2; - send( - `By the people's votes, <@${target.id}> has lost the bet that <@${author.id}> had sent them.` - ); - } else { - sender.money += amount; - receiver.money += amount; - send( - `By the people's votes, <@${target.id}> couldn't be determined to have won or lost the bet that <@${author.id}> had sent them.` - ); - } + // Count votes + const ok = results["✅"]; + const no = results["❌"]; - sender.ecoBetInsurance -= amount; - receiver.ecoBetInsurance -= amount; - Storage.save(); - }); + if (ok > no) { + receiver.money += amount * 2; + send(`By the people's votes, ${target} has won the bet that ${author} had sent them.`); + } else if (ok < no) { + sender.money += amount * 2; + send(`By the people's votes, ${target} has lost the bet that ${author} had sent them.`); + } else { + sender.money += amount; + receiver.money += amount; + send( + `By the people's votes, ${target} couldn't be determined to have won or lost the bet that ${author} had sent them.` + ); + } + + sender.ecoBetInsurance -= amount; + receiver.ecoBetInsurance -= amount; + Storage.save(); }, duration); } else return; } diff --git a/src/commands/fun/modules/eco-shop.ts b/src/commands/fun/modules/eco-shop.ts index 91ac71d..acd0e4a 100644 --- a/src/commands/fun/modules/eco-shop.ts +++ b/src/commands/fun/modules/eco-shop.ts @@ -34,17 +34,12 @@ export const ShopCommand = new NamedCommand({ const shopPages = split(ShopItems, 5); const pageAmount = shopPages.length; - paginate( - send, - (page, hasMultiplePages) => { - return getShopEmbed( - shopPages[page], - hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop" - ); - }, - pageAmount, - author.id - ); + paginate(send, author.id, pageAmount, (page, hasMultiplePages) => { + return getShopEmbed( + shopPages[page], + hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop" + ); + }); } } }); diff --git a/src/commands/system/help.ts b/src/commands/system/help.ts index 7e5139c..7691a5a 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -20,21 +20,16 @@ export default new NamedCommand({ const commands = await getCommandList(); const categoryArray = commands.keyArray(); - paginate( - send, - (page, hasMultiplePages) => { - const category = categoryArray[page]; - const commandList = commands.get(category)!; - let output = `Legend: \`\`, \`[list/of/stuff]\`, \`(optional)\`, \`()\`, \`([optional/list/...])\`\n`; - for (const command of commandList) output += `\n❯ \`${command.name}\`: ${command.description}`; - return new MessageEmbed() - .setTitle(hasMultiplePages ? `${category} (Page ${page + 1} of ${categoryArray.length})` : category) - .setDescription(output) - .setColor(EMBED_COLOR); - }, - categoryArray.length, - author.id - ); + paginate(send, author.id, categoryArray.length, (page, hasMultiplePages) => { + const category = categoryArray[page]; + const commandList = commands.get(category)!; + let output = `Legend: \`\`, \`[list/of/stuff]\`, \`(optional)\`, \`()\`, \`([optional/list/...])\`\n`; + for (const command of commandList) output += `\n❯ \`${command.name}\`: ${command.description}`; + return new MessageEmbed() + .setTitle(hasMultiplePages ? `${category} (Page ${page + 1} of ${categoryArray.length})` : category) + .setDescription(output) + .setColor(EMBED_COLOR); + }); }, any: new Command({ async run({send, message, channel, guild, author, member, client, args}) { diff --git a/src/commands/utility/lsemotes.ts b/src/commands/utility/lsemotes.ts index 7e1e2d9..37840b9 100644 --- a/src/commands/utility/lsemotes.ts +++ b/src/commands/utility/lsemotes.ts @@ -90,22 +90,17 @@ async function displayEmoteList(emotes: GuildEmoji[], send: SendFunction, author // Gather the first page (if it even exists, which it might not if there no valid emotes appear) if (pages > 0) { - paginate( - send, - (page, hasMultiplePages) => { - embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**"); + paginate(send, author.id, pages, (page, hasMultiplePages) => { + embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**"); - let desc = ""; - for (const emote of sections[page]) { - desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`; - } - embed.setDescription(desc); + let desc = ""; + for (const emote of sections[page]) { + desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`; + } + embed.setDescription(desc); - return embed; - }, - pages, - author.id - ); + return embed; + }); } else { send("No valid emotes found by that query."); } diff --git a/src/commands/utility/todo.ts b/src/commands/utility/todo.ts index 9057166..2761db7 100644 --- a/src/commands/utility/todo.ts +++ b/src/commands/utility/todo.ts @@ -26,7 +26,6 @@ export default new NamedCommand({ async run({send, message, channel, guild, author, member, client, args, combined}) { const user = Storage.getUser(author.id); user.todoList[Date.now().toString()] = combined; - console.debug(user.todoList); Storage.save(); send(`Successfully added \`${combined}\` to your todo list.`); } diff --git a/src/core/eventListeners.ts b/src/core/eventListeners.ts index 705647e..bad74cd 100644 --- a/src/core/eventListeners.ts +++ b/src/core/eventListeners.ts @@ -1,22 +1,32 @@ -import {Client, Permissions, Message} from "discord.js"; +import {Client, Permissions, Message, MessageReaction, User, PartialUser} from "discord.js"; import {botHasPermission} from "./libd"; // A list of message ID and callback pairs. You get the emote name and ID of the user reacting. -export const unreactEventListeners: Map void> = new Map(); +// This will handle removing reactions automatically (if the bot has the right permission). +export const reactEventListeners = new Map void>(); +export const emptyReactEventListeners = new Map void>(); // A list of "channel-message" and callback pairs. Also, I imagine that the callback will be much more maintainable when discord.js v13 comes out with a dedicated message.referencedMessage property. -// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there. export const replyEventListeners = new Map void>(); export function attachEventListenersToClient(client: Client) { - // Attached to the client, there can be one event listener attached to a message ID which is executed if present. + client.on("messageReactionAdd", (reaction, user) => { + // The reason this is inside the call is because it's possible to switch a user's permissions halfway and suddenly throw an error. + // This will dynamically adjust for that, switching modes depending on whether it currently has the "Manage Messages" permission. + const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES); + reactEventListeners.get(reaction.message.id)?.(reaction, user); + if (canDeleteEmotes && !user.partial) reaction.users.remove(user); + }); + client.on("messageReactionRemove", (reaction, user) => { const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES); + if (!canDeleteEmotes) reactEventListeners.get(reaction.message.id)?.(reaction, user); + }); - if (!canDeleteEmotes) { - const callback = unreactEventListeners.get(reaction.message.id); - callback && callback(reaction.emoji.name, user.id); - } + client.on("messageReactionRemoveAll", (message) => { + reactEventListeners.delete(message.id); + emptyReactEventListeners.get(message.id)?.(); + emptyReactEventListeners.delete(message.id); }); client.on("message", (message) => { diff --git a/src/core/libd.ts b/src/core/libd.ts index 58338a5..aed4a07 100644 --- a/src/core/libd.ts +++ b/src/core/libd.ts @@ -3,7 +3,6 @@ import { Message, Guild, GuildMember, - Permissions, TextChannel, DMChannel, NewsChannel, @@ -17,9 +16,10 @@ import { APIMessage, StringResolvable, EmojiIdentifierResolvable, - MessageReaction + MessageReaction, + PartialUser } from "discord.js"; -import {unreactEventListeners, replyEventListeners} from "./eventListeners"; +import {reactEventListeners, emptyReactEventListeners, replyEventListeners} from "./eventListeners"; import {client} from "./interface"; export type SingleMessageOptions = MessageOptions & {split?: false}; @@ -33,150 +33,119 @@ export type SendFunction = (( ((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise) & ((content: StringResolvable, options: MessageOptions) => Promise); -const FIVE_BACKWARDS_EMOJI = "⏪"; -const BACKWARDS_EMOJI = "⬅️"; -const FORWARDS_EMOJI = "➡️"; -const FIVE_FORWARDS_EMOJI = "⏩"; +interface PaginateOptions { + multiPageSize?: number; + idleTimeout?: number; +} // Pagination function that allows for customization via a callback. // Define your own pages outside the function because this only manages the actual turning of pages. /** * Takes a message and some additional parameters and makes a reaction page with it. All the pagination logic is taken care of but nothing more, the page index is returned and you have to send a callback to do something with it. + * + * Returns the page number the user left off on in case you want to implement a return to page function. */ -export async function paginate( +export function paginate( send: SendFunction, - onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions, + listenTo: string | null, totalPages: number, - listenTo: string | null = null, - duration = 60000 -): Promise { - const hasMultiplePages = totalPages > 1; - const message = await send(onTurnPage(0, hasMultiplePages)); + onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions, + options?: PaginateOptions +): Promise { + if (totalPages < 1) throw new Error(`totalPages on paginate() must be 1 or more, ${totalPages} given.`); - if (hasMultiplePages) { - let page = 0; - const turn = (amount: number) => { - page += amount; - - if (page >= totalPages) { - page %= totalPages; - } else if (page < 0) { - // Assuming 3 total pages, it's a bit tricker, but if we just take the modulo of the absolute value (|page| % total), we get (1 2 0 ...), and we just need the pattern (2 1 0 ...). It needs to reverse order except for when it's 0. I want to find a better solution, but for the time being... total - (|page| % total) unless (|page| % total) = 0, then return 0. - const flattened = Math.abs(page) % totalPages; - if (flattened !== 0) page = totalPages - flattened; - } - - message.edit(onTurnPage(page, true)); - }; - const handle = (emote: string, reacterID: string) => { - if (reacterID === listenTo || listenTo === null) { - collector.resetTimer(); // The timer refresh MUST be present in both react and unreact. - switch (emote) { - case FIVE_BACKWARDS_EMOJI: - if (totalPages > 5) turn(-5); - break; - case BACKWARDS_EMOJI: - turn(-1); - break; - case FORWARDS_EMOJI: - turn(1); - break; - case FIVE_FORWARDS_EMOJI: - if (totalPages > 5) turn(5); - break; - } - } - }; - - // Listen for reactions and call the handler. - let backwardsReactionFive = totalPages > 5 ? await message.react(FIVE_BACKWARDS_EMOJI) : null; - let backwardsReaction = await message.react(BACKWARDS_EMOJI); - let forwardsReaction = await message.react(FORWARDS_EMOJI); - let forwardsReactionFive = totalPages > 5 ? await message.react(FIVE_FORWARDS_EMOJI) : null; - unreactEventListeners.set(message.id, handle); - - const collector = message.createReactionCollector( - (reaction, user) => { - // This check is actually redundant because of handle(). - if (user.id === listenTo || listenTo === null) { - // The reason this is inside the call is because it's possible to switch a user's permissions halfway and suddenly throw an error. - // This will dynamically adjust for that, switching modes depending on whether it currently has the "Manage Messages" permission. - const canDeleteEmotes = botHasPermission(message.guild, Permissions.FLAGS.MANAGE_MESSAGES); - handle(reaction.emoji.name, user.id); - if (canDeleteEmotes) reaction.users.remove(user); - } - - return false; - }, - // Apparently, regardless of whether you put "time" or "idle", it won't matter to the collector. - // In order to actually reset the timer, you have to do it manually via collector.resetTimer(). - {time: duration} - ); - - // When time's up, remove the bot's own reactions. - collector.on("end", () => { - unreactEventListeners.delete(message.id); - backwardsReactionFive?.users.remove(message.author); - backwardsReaction.users.remove(message.author); - forwardsReaction.users.remove(message.author); - forwardsReactionFive?.users.remove(message.author); - }); - } -} - -//export function generateMulti -// paginate after generateonetimeprompt - -// Returns null if timed out, otherwise, returns the value. -export function generateOneTimePrompt( - message: Message, - stack: {[emote: string]: T}, - listenTo: string | null = null, - duration = 60000 -): Promise { return new Promise(async (resolve) => { - // First, start reacting to the message in order. - reactInOrder(message, Object.keys(stack)); + const hasMultiplePages = totalPages > 1; + const message = await send(onTurnPage(0, hasMultiplePages)); - // Then setup the reaction listener in parallel. - await message.awaitReactions( - (reaction: MessageReaction, user: User) => { - if (user.id === listenTo || listenTo === null) { - const emote = reaction.emoji.name; + if (hasMultiplePages) { + const multiPageSize = options?.multiPageSize ?? 5; + const idleTimeout = options?.idleTimeout ?? 60000; + let page = 0; - if (emote in stack) { - resolve(stack[emote]); - message.delete(); - } + const turn = (amount: number) => { + page += amount; + + if (page >= totalPages) { + page %= totalPages; + } else if (page < 0) { + // Assuming 3 total pages, it's a bit tricker, but if we just take the modulo of the absolute value (|page| % total), we get (1 2 0 ...), and we just need the pattern (2 1 0 ...). It needs to reverse order except for when it's 0. I want to find a better solution, but for the time being... total - (|page| % total) unless (|page| % total) = 0, then return 0. + const flattened = Math.abs(page) % totalPages; + if (flattened !== 0) page = totalPages - flattened; } - // CollectorFilter requires a boolean to be returned. - // My guess is that the return value of awaitReactions can be altered by making a boolean filter. - // However, because that's not my concern with this command, I don't have to worry about it. - // May as well just set it to false because I'm not concerned with collecting any reactions. - return false; - }, - {time: duration} - ); + message.edit(onTurnPage(page, true)); + }; - if (!message.deleted) { - message.delete(); - resolve(null); + let stack: {[emote: string]: number} = { + "⬅️": -1, + "➡️": 1 + }; + + if (totalPages > multiPageSize) { + stack = { + "⏪": -multiPageSize, + ...stack, + "⏩": multiPageSize + }; + } + + const handle = (reaction: MessageReaction, user: User | PartialUser) => { + if (user.id === listenTo || (listenTo === null && user.id !== client.user!.id)) { + // Turn the page + const emote = reaction.emoji.name; + if (emote in stack) turn(stack[emote]); + + // Reset the timer + client.clearTimeout(timeout); + timeout = client.setTimeout(destroy, idleTimeout); + } + }; + + // When time's up, remove the bot's own reactions. + const destroy = () => { + reactEventListeners.delete(message.id); + for (const emote of message.reactions.cache.values()) emote.users.remove(message.author); + resolve(page); + }; + + // Start the reactions and call the handler. + reactInOrder(message, Object.keys(stack)); + reactEventListeners.set(message.id, handle); + emptyReactEventListeners.set(message.id, destroy); + let timeout = client.setTimeout(destroy, idleTimeout); } }); } -// Start a parallel chain of ordered reactions, allowing a collector to end early. -// Check if the collector ended early by seeing if the message is already deleted. -// Though apparently, message.deleted doesn't seem to update fast enough, so just put a try catch block on message.react(). -async function reactInOrder(message: Message, emotes: EmojiIdentifierResolvable[]): Promise { +export async function poll(message: Message, emotes: string[], duration = 60000): Promise<{[emote: string]: number}> { + if (emotes.length === 0) throw new Error("poll() was called without any emotes."); + + reactInOrder(message, emotes); + const reactions = await message.awaitReactions( + (reaction: MessageReaction) => emotes.includes(reaction.emoji.name), + {time: duration} + ); + const reactionsByCount: {[emote: string]: number} = {}; + for (const emote of emotes) { - try { - await message.react(emote); - } catch { - return; + const reaction = reactions.get(emote); + + if (reaction) { + const hasBot = reaction.users.cache.has(client.user!.id); // Apparently, reaction.me doesn't work properly. + + if (reaction.count !== null) { + const difference = hasBot ? 1 : 0; + reactionsByCount[emote] = reaction.count - difference; + } else { + reactionsByCount[emote] = 0; + } + } else { + reactionsByCount[emote] = 0; } } + + return reactionsByCount; } export function confirm(message: Message, senderID: string, timeout = 30000): Promise { @@ -235,6 +204,58 @@ export function askForReply(message: Message, listenTo: string, timeout?: number }); } +// Returns null if timed out, otherwise, returns the value. +export function generateOneTimePrompt( + message: Message, + stack: {[emote: string]: T}, + listenTo: string | null = null, + duration = 60000 +): Promise { + return new Promise(async (resolve) => { + // First, start reacting to the message in order. + reactInOrder(message, Object.keys(stack)); + + // Then setup the reaction listener in parallel. + await message.awaitReactions( + (reaction: MessageReaction, user: User) => { + if (user.id === listenTo || listenTo === null) { + const emote = reaction.emoji.name; + + if (emote in stack) { + resolve(stack[emote]); + message.delete(); + } + } + + // CollectorFilter requires a boolean to be returned. + // My guess is that the return value of awaitReactions can be altered by making a boolean filter. + // However, because that's not my concern with this command, I don't have to worry about it. + // May as well just set it to false because I'm not concerned with collecting any reactions. + return false; + }, + {time: duration} + ); + + if (!message.deleted) { + message.delete(); + resolve(null); + } + }); +} + +// Start a parallel chain of ordered reactions, allowing a collector to end early. +// Check if the collector ended early by seeing if the message is already deleted. +// Though apparently, message.deleted doesn't seem to update fast enough, so just put a try catch block on message.react(). +async function reactInOrder(message: Message, emotes: EmojiIdentifierResolvable[]): Promise { + for (const emote of emotes) { + try { + await message.react(emote); + } catch { + return; + } + } +} + /** * Tests if a bot has a certain permission in a specified guild. */