Refactored paginate and added poll to library

This commit is contained in:
WatDuhHekBro 2021-04-11 05:45:50 -05:00
parent c980a182f8
commit a493536a23
8 changed files with 231 additions and 222 deletions

View File

@ -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()`

View File

@ -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;
}

View File

@ -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"
);
});
}
}
});

View File

@ -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: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([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: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([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}) {

View File

@ -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.");
}

View File

@ -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.`);
}

View File

@ -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<string, (emote: string, id: string) => void> = new Map();
// This will handle removing reactions automatically (if the bot has the right permission).
export const reactEventListeners = new Map<string, (reaction: MessageReaction, user: User | PartialUser) => void>();
export const emptyReactEventListeners = new Map<string, () => 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<string, (message: Message) => 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) => {

View File

@ -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<Message[]>) &
((content: StringResolvable, options: MessageOptions) => Promise<Message | Message[]>);
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<void> {
const hasMultiplePages = totalPages > 1;
const message = await send(onTurnPage(0, hasMultiplePages));
onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions,
options?: PaginateOptions
): Promise<number> {
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<T>(
message: Message,
stack: {[emote: string]: T},
listenTo: string | null = null,
duration = 60000
): Promise<T | null> {
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<void> {
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<boolean | null> {
@ -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<T>(
message: Message,
stack: {[emote: string]: T},
listenTo: string | null = null,
duration = 60000
): Promise<T | null> {
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<void> {
for (const emote of emotes) {
try {
await message.react(emote);
} catch {
return;
}
}
}
/**
* Tests if a bot has a certain permission in a specified guild.
*/