Updated library functions

This commit is contained in:
WatDuhHekBro 2021-04-11 03:02:56 -05:00
parent 3798c27df9
commit c980a182f8
14 changed files with 479 additions and 542 deletions

View File

@ -75,27 +75,24 @@ Because versions are assigned to batches of changes rather than single changes (
```ts ```ts
const pages = ["one", "two", "three"]; const pages = ["one", "two", "three"];
paginate(channel, author.id, pages.length, (page) => { paginate(send, page => {
return { return {content: pages[page]};
content: pages[page] }, pages.length, author.id);
};
});
``` ```
`prompt()` `confirm()`
```ts ```ts
const msg = await channel.send('Are you sure you want to delete this?'); const result = await confirm(await send("Are you sure you want to delete this?"), author.id); // boolean | null
prompt(msg, author.id, () => {
//...
});
``` ```
`callMemberByUsername()` `askMultipleChoice()`
```ts ```ts
callMemberByUsername(message, args.join(" "), (member) => { const result = await askMultipleChoice(await send("Which of the following numbers is your favorite?"), author.id, 4, 10000); // number (0 to 3) | null
channel.send(`Your nickname is ${member.nickname}.`); ```
});
`askForReply()`
```ts
const reply = await askForReply(await send("What is your favorite thing to do?"), author.id, 10000); // Message | null
``` ```
## [src/lib](../src/lib.ts) - General utility functions ## [src/lib](../src/lib.ts) - General utility functions

View File

@ -4,7 +4,6 @@ import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./modu
import {BuyCommand, ShopCommand} from "./modules/eco-shop"; import {BuyCommand, ShopCommand} from "./modules/eco-shop";
import {MondayCommand, AwardCommand} from "./modules/eco-extras"; import {MondayCommand, AwardCommand} from "./modules/eco-extras";
import {BetCommand} from "./modules/eco-bet"; import {BetCommand} from "./modules/eco-bet";
import {GuildMember} from "discord.js";
export default new NamedCommand({ export default new NamedCommand({
description: "Economy command for Monika.", description: "Economy command for Monika.",
@ -38,7 +37,7 @@ export default new NamedCommand({
async run({send, guild, channel, args, message, combined}) { async run({send, guild, channel, args, message, combined}) {
if (isAuthorized(guild, channel)) { if (isAuthorized(guild, channel)) {
const member = await getMemberByName(guild!, combined); const member = await getMemberByName(guild!, combined);
if (member instanceof GuildMember) send(getMoneyEmbed(member.user)); if (typeof member !== "string") send(getMoneyEmbed(member.user));
else send(member); else send(member);
} }
} }

View File

@ -1,7 +1,7 @@
import {Command, NamedCommand, askYesOrNo} from "../../../core"; import {Command, NamedCommand, confirm} from "../../../core";
import {pluralise} from "../../../lib"; import {pluralise} from "../../../lib";
import {Storage} from "../../../structures"; import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils"; import {isAuthorized, getMoneyEmbed} from "./eco-utils";
import {User} from "discord.js"; import {User} from "discord.js";
export const BetCommand = new NamedCommand({ export const BetCommand = new NamedCommand({
@ -79,88 +79,89 @@ export const BetCommand = new NamedCommand({
return send(`Bet duration is too long, maximum duration is ${durationBounds.max}`); return send(`Bet duration is too long, maximum duration is ${durationBounds.max}`);
// Ask target whether or not they want to take the bet. // Ask target whether or not they want to take the bet.
const takeBet = await askYesOrNo( const takeBet = await confirm(
await send( await send(
`<@${target.id}>, do you want to take this bet of ${pluralise(amount, "Mon", "s")}` `<@${target.id}>, do you want to take this bet of ${pluralise(amount, "Mon", "s")}`
), ),
target.id target.id
); );
if (takeBet) { if (!takeBet) return send(`<@${target.id}> has rejected your bet, <@${author.id}>`);
// [MEDIUM PRIORITY: bet persistence to prevent losses in case of shutdown.]
// Remove amount money from both parts at the start to avoid duplication of money.
sender.money -= amount;
receiver.money -= amount;
// Very hacky solution for persistence but better than no solution, backup returns runs during the bot's setup code.
sender.ecoBetInsurance += amount;
receiver.ecoBetInsurance += amount;
Storage.save();
// Notify both users. // [MEDIUM PRIORITY: bet persistence to prevent losses in case of shutdown.]
await send( // Remove amount money from both parts at the start to avoid duplication of money.
`<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${pluralise( sender.money -= amount;
amount, receiver.money -= amount;
"Mon", // Very hacky solution for persistence but better than no solution, backup returns runs during the bot's setup code.
"s" sender.ecoBetInsurance += amount;
)} has been deducted from each of them.` receiver.ecoBetInsurance += amount;
Storage.save();
// Notify both users.
send(
`<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${pluralise(
amount,
"Mon",
"s"
)} has been deducted from each of them.`
);
// Wait for the duration of the bet.
return client.setTimeout(async () => {
// In debug mode, saving the storage will break the references, so you have to redeclare sender and receiver for it to actually save.
const sender = Storage.getUser(author.id);
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("❌");
// Wait for the duration of the bet. // Filter reactions to only collect the pertinent ones.
return client.setTimeout(async () => { voteMsg
// In debug mode, saving the storage will break the references, so you have to redeclare sender and receiver for it to actually save. .awaitReactions(
const sender = Storage.getUser(author.id); (reaction, user) => {
const receiver = Storage.getUser(target.id); return ["✅", "❌"].includes(reaction.emoji.name);
// [TODO: when D.JSv13 comes out, inline reply to clean up.] },
// When bet is over, give a vote to ask people their thoughts. // [Pertinence to make configurable on the fly.]
const voteMsg = await send( {time: parseDuration("2m")}
`VOTE: do you think that <@${ )
target.id .then((reactions) => {
}> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${ // Count votes
message.id const okReaction = reactions.get("✅");
}` const noReaction = reactions.get("❌");
); const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0;
await voteMsg.react("✅"); const no = noReaction ? (noReaction.count ?? 1) - 1 : 0;
await voteMsg.react("❌");
// Filter reactions to only collect the pertinent ones. if (ok > no) {
voteMsg receiver.money += amount * 2;
.awaitReactions( send(
(reaction, user) => { `By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.`
return ["✅", "❌"].includes(reaction.emoji.name); );
}, } else if (ok < no) {
// [Pertinence to make configurable on the fly.] sender.money += amount * 2;
{time: parseDuration("2m")} send(
) `By the people's votes, <@${target.id}> has lost the bet that <@${author.id}> had sent them.`
.then((reactions) => { );
// Count votes } else {
const okReaction = reactions.get("✅"); sender.money += amount;
const noReaction = reactions.get("❌"); receiver.money += amount;
const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0; send(
const no = noReaction ? (noReaction.count ?? 1) - 1 : 0; `By the people's votes, <@${target.id}> couldn't be determined to have won or lost the bet that <@${author.id}> had sent them.`
);
}
if (ok > no) { sender.ecoBetInsurance -= amount;
receiver.money += amount * 2; receiver.ecoBetInsurance -= amount;
send( Storage.save();
`By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.` });
); }, duration);
} 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.`
);
}
sender.ecoBetInsurance -= amount;
receiver.ecoBetInsurance -= amount;
Storage.save();
});
}, duration);
} else return await send(`<@${target.id}> has rejected your bet, <@${author.id}>`);
} else return; } else return;
} }
}) })

View File

@ -1,5 +1,4 @@
import {GuildMember} from "discord.js"; import {Command, getMemberByName, NamedCommand, confirm, RestCommand} from "../../../core";
import {Command, getMemberByName, NamedCommand, prompt, RestCommand} from "../../../core";
import {pluralise} from "../../../lib"; import {pluralise} from "../../../lib";
import {Storage} from "../../../structures"; import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils"; import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils";
@ -90,7 +89,7 @@ export const LeaderboardCommand = new NamedCommand({
const user = await client.users.fetch(id); const user = await client.users.fetch(id);
fields.push({ fields.push({
name: `#${i + 1}. ${user.username}#${user.discriminator}`, name: `#${i + 1}. ${user.tag}`,
value: pluralise(users[id].money, "Mon", "s") value: pluralise(users[id].money, "Mon", "s")
}); });
} }
@ -158,42 +157,38 @@ export const PayCommand = new NamedCommand({
return send("You have to use this in a server if you want to send Mons with a username!"); return send("You have to use this in a server if you want to send Mons with a username!");
const member = await getMemberByName(guild, combined); const member = await getMemberByName(guild, combined);
if (!(member instanceof GuildMember)) return send(member); if (typeof member === "string") return send(member);
else if (member.user.id === author.id) return send("You can't send Mons to yourself!"); else if (member.user.id === author.id) return send("You can't send Mons to yourself!");
else if (member.user.bot && process.argv[2] !== "dev") return send("You can't send Mons to a bot!"); else if (member.user.bot && process.argv[2] !== "dev") return send("You can't send Mons to a bot!");
const target = member.user; const target = member.user;
return prompt( const result = await confirm(
await send( await send(`Are you sure you want to send ${pluralise(amount, "Mon", "s")} to this person?`, {
`Are you sure you want to send ${pluralise( embed: {
amount, color: ECO_EMBED_COLOR,
"Mon", author: {
"s" name: target.tag,
)} to this person?\n*(This message will automatically be deleted after 10 seconds.)*`, icon_url: target.displayAvatarURL({
{ format: "png",
embed: { dynamic: true
color: ECO_EMBED_COLOR, })
author: {
name: `${target.username}#${target.discriminator}`,
icon_url: target.displayAvatarURL({
format: "png",
dynamic: true
})
}
} }
} }
), }),
author.id, author.id
() => {
const receiver = Storage.getUser(target.id);
sender.money -= amount;
receiver.money += amount;
Storage.save();
send(getSendEmbed(author, target, amount));
}
); );
if (result) {
const receiver = Storage.getUser(target.id);
sender.money -= amount;
receiver.money += amount;
Storage.save();
send(getSendEmbed(author, target, amount));
}
} }
return;
} }
}) })
}); });

View File

@ -34,12 +34,17 @@ export const ShopCommand = new NamedCommand({
const shopPages = split(ShopItems, 5); const shopPages = split(ShopItems, 5);
const pageAmount = shopPages.length; const pageAmount = shopPages.length;
paginate(send, author.id, pageAmount, (page, hasMultiplePages) => { paginate(
return getShopEmbed( send,
shopPages[page], (page, hasMultiplePages) => {
hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop" return getShopEmbed(
); shopPages[page],
}); hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop"
);
},
pageAmount,
author.id
);
} }
} }
}); });

View File

@ -42,11 +42,11 @@ export function getSendEmbed(sender: User, receiver: User, amount: number): obje
description: `${sender.toString()} has sent ${pluralise(amount, "Mon", "s")} to ${receiver.toString()}!`, description: `${sender.toString()} has sent ${pluralise(amount, "Mon", "s")} to ${receiver.toString()}!`,
fields: [ fields: [
{ {
name: `Sender: ${sender.username}#${sender.discriminator}`, name: `Sender: ${sender.tag}`,
value: pluralise(Storage.getUser(sender.id).money, "Mon", "s") value: pluralise(Storage.getUser(sender.id).money, "Mon", "s")
}, },
{ {
name: `Receiver: ${receiver.username}#${receiver.discriminator}`, name: `Receiver: ${receiver.tag}`,
value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s") value: pluralise(Storage.getUser(receiver.id).money, "Mon", "s")
} }
], ],

View File

@ -1,4 +1,4 @@
import {User, GuildMember} from "discord.js"; import {User} from "discord.js";
import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, RestCommand} from "../../core"; import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, RestCommand} from "../../core";
// Quotes must be used here or the numbers will change // Quotes must be used here or the numbers will change
@ -70,7 +70,7 @@ export default new NamedCommand({
async run({send, message, channel, guild, author, client, args, combined}) { async run({send, message, channel, guild, author, client, args, combined}) {
const member = await getMemberByName(guild!, combined); const member = await getMemberByName(guild!, combined);
if (member instanceof GuildMember) { if (typeof member !== "string") {
if (member.id in registry) { if (member.id in registry) {
send(`\`${member.nickname ?? member.user.username}\` - ${registry[member.id]}`); send(`\`${member.nickname ?? member.user.username}\` - ${registry[member.id]}`);
} else { } else {

View File

@ -20,16 +20,21 @@ export default new NamedCommand({
const commands = await getCommandList(); const commands = await getCommandList();
const categoryArray = commands.keyArray(); const categoryArray = commands.keyArray();
paginate(send, author.id, categoryArray.length, (page, hasMultiplePages) => { paginate(
const category = categoryArray[page]; send,
const commandList = commands.get(category)!; (page, hasMultiplePages) => {
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\`\n`; const category = categoryArray[page];
for (const command of commandList) output += `\n \`${command.name}\`: ${command.description}`; const commandList = commands.get(category)!;
return new MessageEmbed() let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\`\n`;
.setTitle(hasMultiplePages ? `${category} (Page ${page + 1} of ${categoryArray.length})` : category) for (const command of commandList) output += `\n \`${command.name}\`: ${command.description}`;
.setDescription(output) return new MessageEmbed()
.setColor(EMBED_COLOR); .setTitle(hasMultiplePages ? `${category} (Page ${page + 1} of ${categoryArray.length})` : category)
}); .setDescription(output)
.setColor(EMBED_COLOR);
},
categoryArray.length,
author.id
);
}, },
any: new Command({ any: new Command({
async run({send, message, channel, guild, author, member, client, args}) { async run({send, message, channel, guild, author, member, client, args}) {

View File

@ -36,7 +36,7 @@ export default new NamedCommand({
async run({send, message, channel, guild, author, client, args, combined}) { async run({send, message, channel, guild, author, client, args, combined}) {
const member = await getMemberByName(guild!, combined); const member = await getMemberByName(guild!, combined);
if (member instanceof GuildMember) { if (typeof member !== "string") {
send( send(
member.user.displayAvatarURL({ member.user.displayAvatarURL({
dynamic: true, dynamic: true,
@ -110,7 +110,7 @@ export default new NamedCommand({
async run({send, message, channel, guild, author, member, client, args, combined}) { async run({send, message, channel, guild, author, member, client, args, combined}) {
const targetGuild = getGuildByName(combined); const targetGuild = getGuildByName(combined);
if (targetGuild instanceof Guild) { if (typeof targetGuild !== "string") {
send(await getGuildInfo(targetGuild, guild)); send(await getGuildInfo(targetGuild, guild));
} else { } else {
send(targetGuild); send(targetGuild);

View File

@ -90,17 +90,22 @@ 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) // Gather the first page (if it even exists, which it might not if there no valid emotes appear)
if (pages > 0) { if (pages > 0) {
paginate(send, author.id, pages, (page, hasMultiplePages) => { paginate(
embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**"); send,
(page, hasMultiplePages) => {
embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**");
let desc = ""; let desc = "";
for (const emote of sections[page]) { for (const emote of sections[page]) {
desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`; desc += `${emote} ${emote.name} (**${emote.guild.name}**)\n`;
} }
embed.setDescription(desc); embed.setDescription(desc);
return embed; return embed;
}); },
pages,
author.id
);
} else { } else {
send("No valid emotes found by that query."); send("No valid emotes found by that query.");
} }

View File

@ -1,15 +1,6 @@
import { import {Command, NamedCommand, askForReply, confirm, askMultipleChoice, getMemberByName, RestCommand} from "../../core";
Command,
NamedCommand,
ask,
askYesOrNo,
askMultipleChoice,
prompt,
getMemberByName,
RestCommand
} from "../../core";
import {Storage} from "../../structures"; import {Storage} from "../../structures";
import {User, GuildMember} from "discord.js"; import {User} from "discord.js";
import moment from "moment"; import moment from "moment";
const DATE_FORMAT = "D MMMM YYYY"; const DATE_FORMAT = "D MMMM YYYY";
@ -178,183 +169,184 @@ function getTimeEmbed(user: User) {
export default new NamedCommand({ export default new NamedCommand({
description: "Show others what time it is for you.", description: "Show others what time it is for you.",
aliases: ["tz"], aliases: ["tz"],
async run({send, channel, author}) { async run({send, author}) {
send(getTimeEmbed(author)); send(getTimeEmbed(author));
}, },
subcommands: { subcommands: {
// Welcome to callback hell. We hope you enjoy your stay here! // Welcome to callback hell. We hope you enjoy your stay here!
setup: new NamedCommand({ setup: new NamedCommand({
description: "Registers your timezone information for the bot.", description: "Registers your timezone information for the bot.",
async run({send, author, channel}) { async run({send, author}) {
const profile = Storage.getUser(author.id); const profile = Storage.getUser(author.id);
profile.timezone = null; profile.timezone = null;
profile.daylightSavingsRegion = null; profile.daylightSavingsRegion = null;
let hour: number;
ask( // Parse and validate reply
const reply = await askForReply(
await send( await send(
"What hour (0 to 23) is it for you right now?\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*" "What hour (0 to 23) is it for you right now?\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*"
), ),
author.id, author.id,
(reply) => { 30000
hour = parseInt(reply);
if (isNaN(hour)) {
return false;
}
return hour >= 0 && hour <= 23;
},
async () => {
// You need to also take into account whether or not it's the same day in UTC or not.
// The problem this setup avoids is messing up timezones by 24 hours.
// For example, the old logic was just (hour - hourUTC). When I setup my timezone (UTC-6) at 18:00, it was UTC 00:00.
// That means that that formula was doing (18 - 0) getting me UTC+18 instead of UTC-6 because that naive formula didn't take into account a difference in days.
// (day * 24 + hour) - (day * 24 + hour)
// Since the timezones will be restricted to -12 to +14, you'll be given three options.
// The end of the month should be calculated automatically, you should have enough information at that point.
// But after mapping out the edge cases, I figured out that you can safely gather accurate information just based on whether the UTC day matches the user's day.
// 21:xx (-12, -d) -- 09:xx (+0, 0d) -- 23:xx (+14, 0d)
// 22:xx (-12, -d) -- 10:xx (+0, 0d) -- 00:xx (+14, +d)
// 23:xx (-12, -d) -- 11:xx (+0, 0d) -- 01:xx (+14, +d)
// 00:xx (-12, 0d) -- 12:xx (+0, 0d) -- 02:xx (+14, +d)
// For example, 23:xx (-12) - 01:xx (+14) shows that even the edge cases of UTC-12 and UTC+14 cannot overlap, so the dataset can be reduced to a yes or no option.
// - 23:xx same day = +0, 23:xx diff day = -1
// - 00:xx same day = +0, 00:xx diff day = +1
// - 01:xx same day = +0, 01:xx diff day = +1
// First, create a tuple list matching each possible hour-dayOffset-timezoneOffset combination. In the above example, it'd go something like this:
// [[23, -1, -12], [0, 0, -11], ..., [23, 0, 12], [0, 1, 13], [1, 1, 14]]
// Then just find the matching one by filtering through dayOffset (equals zero or not), then the hour from user input.
// Remember that you don't know where the difference in day might be at this point, so you can't do (hour - hourUTC) safely.
// In terms of the equation, you're missing a variable in (--> day <-- * 24 + hour) - (day * 24 + hour). That's what the loop is for.
// Now you might be seeing a problem with setting this up at the end/beginning of a month, but that actually isn't a problem.
// Let's say that it's 00:xx of the first UTC day of a month. hourSumUTC = 24
// UTC-12 --> hourSumLowerBound (hourSumUTC - 12) = 12
// UTC+14 --> hourSumUpperBound (hourSumUTC + 14) = 38
// Remember, the nice thing about making these bounds relative to the UTC hour sum is that there can't be any conflicts even at the edges of months.
// And remember that we aren't using this question: (day * 24 + hour) - (day * 24 + hour). We're drawing from a list which does not store its data in absolute terms.
// That means there's no 31st, 1st, 2nd, it's -1, 0, +1. I just need to make sure to calculate an offset to subtract from the hour sums.
const date = new Date(); // e.g. 2021-05-01 @ 05:00
const day = date.getUTCDate(); // e.g. 1
const hourSumUTC = day * 24 + date.getUTCHours(); // e.g. 29
const timezoneTupleList: [number, number, number][] = [];
const uniques: number[] = []; // only for temporary use
const duplicates = [];
// Setup the tuple list in a separate block.
for (let timezoneOffset = -12; timezoneOffset <= 14; timezoneOffset++) {
const hourSum = hourSumUTC + timezoneOffset; // e.g. 23, UTC-6 (17 to 43)
const hour = hourSum % 24; // e.g. 23
// This works because you get the # of days w/o hours minus UTC days without hours.
// Since it's all relative to UTC, it'll end up being -1, 0, or 1.
const dayOffset = Math.floor(hourSum / 24) - day; // e.g. -1
timezoneTupleList.push([hour, dayOffset, timezoneOffset]);
if (uniques.includes(hour)) {
duplicates.push(hour);
} else {
uniques.push(hour);
}
}
// I calculate the list beforehand and check for duplicates to reduce unnecessary asking.
if (duplicates.includes(hour)) {
const isSameDay = await askYesOrNo(
await send(
`Is the current day of the month the ${moment().utc().format("Do")} for you?`
),
author.id
);
// Filter through isSameDay (aka !hasDayOffset) then hour from user-generated input.
// isSameDay is checked first to reduce the amount of conditionals per loop.
if (isSameDay) {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset === 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
} else {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset !== 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
} else {
// If it's a unique hour, just search through the tuple list and find the matching entry.
for (const [hourPoint, _dayOffset, timezoneOffset] of timezoneTupleList) {
if (hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
// I should note that error handling should be added sometime because await throws an exception on Promise.reject.
const hasDST = await askYesOrNo(
await send("Does your timezone change based on daylight savings?"),
author.id
);
const finalize = () => {
Storage.save();
send(
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
getTimeEmbed(author)
);
};
if (hasDST) {
const finalizeDST = (region: DST) => {
profile.daylightSavingsRegion = region;
// If daylight savings is active, subtract the timezone offset by one to store the standard time.
if (hasDaylightSavings(region)) {
profile.timezone!--;
}
finalize();
};
askMultipleChoice(await send(DST_NOTE_SETUP), author.id, [
() => finalizeDST("na"),
() => finalizeDST("eu"),
() => finalizeDST("sh")
]);
} else {
finalize();
}
},
() => "you need to enter in a valid integer between 0 to 23"
); );
if (reply === null) return send("Message timed out.");
const hour = parseInt(reply.content);
const isValidHour = !isNaN(hour) && hour >= 0 && hour <= 23;
if (!isValidHour) return reply.reply("you need to enter in a valid integer between 0 to 23");
// You need to also take into account whether or not it's the same day in UTC or not.
// The problem this setup avoids is messing up timezones by 24 hours.
// For example, the old logic was just (hour - hourUTC). When I setup my timezone (UTC-6) at 18:00, it was UTC 00:00.
// That means that that formula was doing (18 - 0) getting me UTC+18 instead of UTC-6 because that naive formula didn't take into account a difference in days.
// (day * 24 + hour) - (day * 24 + hour)
// Since the timezones will be restricted to -12 to +14, you'll be given three options.
// The end of the month should be calculated automatically, you should have enough information at that point.
// But after mapping out the edge cases, I figured out that you can safely gather accurate information just based on whether the UTC day matches the user's day.
// 21:xx (-12, -d) -- 09:xx (+0, 0d) -- 23:xx (+14, 0d)
// 22:xx (-12, -d) -- 10:xx (+0, 0d) -- 00:xx (+14, +d)
// 23:xx (-12, -d) -- 11:xx (+0, 0d) -- 01:xx (+14, +d)
// 00:xx (-12, 0d) -- 12:xx (+0, 0d) -- 02:xx (+14, +d)
// For example, 23:xx (-12) - 01:xx (+14) shows that even the edge cases of UTC-12 and UTC+14 cannot overlap, so the dataset can be reduced to a yes or no option.
// - 23:xx same day = +0, 23:xx diff day = -1
// - 00:xx same day = +0, 00:xx diff day = +1
// - 01:xx same day = +0, 01:xx diff day = +1
// First, create a tuple list matching each possible hour-dayOffset-timezoneOffset combination. In the above example, it'd go something like this:
// [[23, -1, -12], [0, 0, -11], ..., [23, 0, 12], [0, 1, 13], [1, 1, 14]]
// Then just find the matching one by filtering through dayOffset (equals zero or not), then the hour from user input.
// Remember that you don't know where the difference in day might be at this point, so you can't do (hour - hourUTC) safely.
// In terms of the equation, you're missing a variable in (--> day <-- * 24 + hour) - (day * 24 + hour). That's what the loop is for.
// Now you might be seeing a problem with setting this up at the end/beginning of a month, but that actually isn't a problem.
// Let's say that it's 00:xx of the first UTC day of a month. hourSumUTC = 24
// UTC-12 --> hourSumLowerBound (hourSumUTC - 12) = 12
// UTC+14 --> hourSumUpperBound (hourSumUTC + 14) = 38
// Remember, the nice thing about making these bounds relative to the UTC hour sum is that there can't be any conflicts even at the edges of months.
// And remember that we aren't using this question: (day * 24 + hour) - (day * 24 + hour). We're drawing from a list which does not store its data in absolute terms.
// That means there's no 31st, 1st, 2nd, it's -1, 0, +1. I just need to make sure to calculate an offset to subtract from the hour sums.
const date = new Date(); // e.g. 2021-05-01 @ 05:00
const day = date.getUTCDate(); // e.g. 1
const hourSumUTC = day * 24 + date.getUTCHours(); // e.g. 29
const timezoneTupleList: [number, number, number][] = [];
const uniques: number[] = []; // only for temporary use
const duplicates = [];
// Setup the tuple list in a separate block.
for (let timezoneOffset = -12; timezoneOffset <= 14; timezoneOffset++) {
const hourSum = hourSumUTC + timezoneOffset; // e.g. 23, UTC-6 (17 to 43)
const hour = hourSum % 24; // e.g. 23
// This works because you get the # of days w/o hours minus UTC days without hours.
// Since it's all relative to UTC, it'll end up being -1, 0, or 1.
const dayOffset = Math.floor(hourSum / 24) - day; // e.g. -1
timezoneTupleList.push([hour, dayOffset, timezoneOffset]);
if (uniques.includes(hour)) {
duplicates.push(hour);
} else {
uniques.push(hour);
}
}
// I calculate the list beforehand and check for duplicates to reduce unnecessary asking.
if (duplicates.includes(hour)) {
const isSameDay = await confirm(
await send(`Is the current day of the month the ${moment().utc().format("Do")} for you?`),
author.id
);
// Filter through isSameDay (aka !hasDayOffset) then hour from user-generated input.
// isSameDay is checked first to reduce the amount of conditionals per loop.
if (isSameDay) {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset === 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
} else {
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
if (dayOffset !== 0 && hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
} else {
// If it's a unique hour, just search through the tuple list and find the matching entry.
for (const [hourPoint, _dayOffset, timezoneOffset] of timezoneTupleList) {
if (hour === hourPoint) {
profile.timezone = timezoneOffset;
}
}
}
// I should note that error handling should be added sometime because await throws an exception on Promise.reject.
const hasDST = await confirm(
await send("Does your timezone change based on daylight savings?"),
author.id
);
const finalize = () => {
Storage.save();
send(
"You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.",
getTimeEmbed(author)
);
};
if (hasDST) {
const finalizeDST = (region: DST) => {
profile.daylightSavingsRegion = region;
// If daylight savings is active, subtract the timezone offset by one to store the standard time.
if (hasDaylightSavings(region)) {
profile.timezone!--;
}
finalize();
};
const index = await askMultipleChoice(await send(DST_NOTE_SETUP), author.id, 3);
switch (index) {
case 0:
finalizeDST("na");
break;
case 1:
finalizeDST("eu");
break;
case 2:
finalizeDST("sh");
break;
}
} else {
finalize();
}
return;
} }
}), }),
delete: new NamedCommand({ delete: new NamedCommand({
description: "Delete your timezone information.", description: "Delete your timezone information.",
async run({send, channel, author}) { async run({send, author}) {
prompt( const result = await confirm(
await send( await send("Are you sure you want to delete your timezone information?"),
"Are you sure you want to delete your timezone information?\n*(This message will automatically be deleted after 10 seconds.)*" author.id
),
author.id,
() => {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
Storage.save();
}
); );
if (result) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
Storage.save();
}
} }
}), }),
utc: new NamedCommand({ utc: new NamedCommand({
description: "Displays UTC time.", description: "Displays UTC time.",
async run({send, channel}) { async run({send}) {
const time = moment().utc(); const time = moment().utc();
send({ send({
@ -386,15 +378,15 @@ export default new NamedCommand({
id: "user", id: "user",
user: new Command({ user: new Command({
description: "See what time it is for someone else.", description: "See what time it is for someone else.",
async run({send, channel, args}) { async run({send, args}) {
send(getTimeEmbed(args[0])); send(getTimeEmbed(args[0]));
} }
}), }),
any: new RestCommand({ any: new RestCommand({
description: "See what time it is for someone else (by their username).", description: "See what time it is for someone else (by their username).",
async run({send, channel, args, guild, combined}) { async run({send, guild, combined}) {
const member = await getMemberByName(guild!, combined); const member = await getMemberByName(guild!, combined);
if (member instanceof GuildMember) send(getTimeEmbed(member.user)); if (typeof member !== "string") send(getTimeEmbed(member.user));
else send(member); else send(member);
} }
}) })

View File

@ -11,7 +11,7 @@ import {
GuildChannel, GuildChannel,
Channel Channel
} from "discord.js"; } from "discord.js";
import {getChannelByID, getGuildByID, getMessageByID, getUserByID, SingleMessageOptions, SendFunction} from "./libd"; import {getChannelByID, getGuildByID, getMessageByID, getUserByID, SendFunction} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
import {getPrefix} from "./interface"; import {getPrefix} from "./interface";
import {parseVars, requireAllCasesHandledFor} from "../lib"; import {parseVars, requireAllCasesHandledFor} from "../lib";
@ -244,18 +244,14 @@ export class Command extends BaseCommand {
} }
// Go through the arguments provided and find the right subcommand, then execute with the given arguments. // Go through the arguments provided and find the right subcommand, then execute with the given arguments.
// Will return null if it successfully executes, SingleMessageOptions if there's an error (to let the user know what it is). // Will return null if it successfully executes, string if there's an error (to let the user know what it is).
// //
// Calls the resulting subcommand's execute method in order to make more modular code, basically pushing the chain of execution to the subcommand. // Calls the resulting subcommand's execute method in order to make more modular code, basically pushing the chain of execution to the subcommand.
// For example, a numeric subcommand would accept args of [4] then execute on it. // For example, a numeric subcommand would accept args of [4] then execute on it.
// //
// Because each Command instance is isolated from others, it becomes practically impossible to predict the total amount of subcommands when isolating the code to handle each individual layer of recursion. // Because each Command instance is isolated from others, it becomes practically impossible to predict the total amount of subcommands when isolating the code to handle each individual layer of recursion.
// Therefore, if a Command is declared as a rest type, any typed args that come at the end must be handled manually. // Therefore, if a Command is declared as a rest type, any typed args that come at the end must be handled manually.
public async execute( public async execute(args: string[], menu: CommandMenu, metadata: ExecuteCommandMetadata): Promise<string | null> {
args: string[],
menu: CommandMenu,
metadata: ExecuteCommandMetadata
): Promise<SingleMessageOptions | null> {
// Update inherited properties if the current command specifies a property. // Update inherited properties if the current command specifies a property.
// In case there are no initial arguments, these should go first so that it can register. // In case there are no initial arguments, these should go first so that it can register.
if (this.permission !== -1) metadata.permission = this.permission; if (this.permission !== -1) metadata.permission = this.permission;
@ -292,9 +288,7 @@ export class Command extends BaseCommand {
const errorMessage = error.stack ?? error; const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`); console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
return { return `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``;
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
};
} }
} }
@ -313,15 +307,13 @@ export class Command extends BaseCommand {
const id = patterns.channel.exec(param)![1]; const id = patterns.channel.exec(param)![1];
const channel = await getChannelByID(id); const channel = await getChannelByID(id);
if (channel instanceof Channel) { if (typeof channel !== "string") {
if (channel instanceof TextChannel || channel instanceof DMChannel) { if (channel instanceof TextChannel || channel instanceof DMChannel) {
metadata.symbolicArgs.push("<channel>"); metadata.symbolicArgs.push("<channel>");
menu.args.push(channel); menu.args.push(channel);
return this.channel.execute(args, menu, metadata); return this.channel.execute(args, menu, metadata);
} else { } else {
return { return `\`${id}\` is not a valid text channel!`;
content: `\`${id}\` is not a valid text channel!`
};
} }
} else { } else {
return channel; return channel;
@ -330,9 +322,7 @@ export class Command extends BaseCommand {
const id = patterns.role.exec(param)![1]; const id = patterns.role.exec(param)![1];
if (!menu.guild) { if (!menu.guild) {
return { return "You can't use role parameters in DM channels!";
content: "You can't use role parameters in DM channels!"
};
} }
const role = menu.guild.roles.cache.get(id); const role = menu.guild.roles.cache.get(id);
@ -342,9 +332,7 @@ export class Command extends BaseCommand {
menu.args.push(role); menu.args.push(role);
return this.role.execute(args, menu, metadata); return this.role.execute(args, menu, metadata);
} else { } else {
return { return `\`${id}\` is not a valid role in this server!`;
content: `\`${id}\` is not a valid role in this server!`
};
} }
} else if (this.emote && patterns.emote.test(param)) { } else if (this.emote && patterns.emote.test(param)) {
const id = patterns.emote.exec(param)![1]; const id = patterns.emote.exec(param)![1];
@ -355,9 +343,7 @@ export class Command extends BaseCommand {
menu.args.push(emote); menu.args.push(emote);
return this.emote.execute(args, menu, metadata); return this.emote.execute(args, menu, metadata);
} else { } else {
return { return `\`${id}\` isn't a valid emote!`;
content: `\`${id}\` isn't a valid emote!`
};
} }
} else if (this.message && (isMessageLink || isMessagePair)) { } else if (this.message && (isMessageLink || isMessagePair)) {
let channelID = ""; let channelID = "";
@ -375,7 +361,7 @@ export class Command extends BaseCommand {
const message = await getMessageByID(channelID, messageID); const message = await getMessageByID(channelID, messageID);
if (message instanceof Message) { if (typeof message !== "string") {
metadata.symbolicArgs.push("<message>"); metadata.symbolicArgs.push("<message>");
menu.args.push(message); menu.args.push(message);
return this.message.execute(args, menu, metadata); return this.message.execute(args, menu, metadata);
@ -386,7 +372,7 @@ export class Command extends BaseCommand {
const id = patterns.user.exec(param)![1]; const id = patterns.user.exec(param)![1];
const user = await getUserByID(id); const user = await getUserByID(id);
if (user instanceof User) { if (typeof user !== "string") {
metadata.symbolicArgs.push("<user>"); metadata.symbolicArgs.push("<user>");
menu.args.push(user); menu.args.push(user);
return this.user.execute(args, menu, metadata); return this.user.execute(args, menu, metadata);
@ -403,24 +389,20 @@ export class Command extends BaseCommand {
case "channel": case "channel":
const channel = await getChannelByID(id); const channel = await getChannelByID(id);
if (channel instanceof Channel) { if (typeof channel !== "string") {
if (channel instanceof TextChannel || channel instanceof DMChannel) { if (channel instanceof TextChannel || channel instanceof DMChannel) {
metadata.symbolicArgs.push("<channel>"); metadata.symbolicArgs.push("<channel>");
menu.args.push(channel); menu.args.push(channel);
return this.id.execute(args, menu, metadata); return this.id.execute(args, menu, metadata);
} else { } else {
return { return `\`${id}\` is not a valid text channel!`;
content: `\`${id}\` is not a valid text channel!`
};
} }
} else { } else {
return channel; return channel;
} }
case "role": case "role":
if (!menu.guild) { if (!menu.guild) {
return { return "You can't use role parameters in DM channels!";
content: "You can't use role parameters in DM channels!"
};
} }
const role = menu.guild.roles.cache.get(id); const role = menu.guild.roles.cache.get(id);
@ -429,9 +411,7 @@ export class Command extends BaseCommand {
menu.args.push(role); menu.args.push(role);
return this.id.execute(args, menu, metadata); return this.id.execute(args, menu, metadata);
} else { } else {
return { return `\`${id}\` isn't a valid role in this server!`;
content: `\`${id}\` isn't a valid role in this server!`
};
} }
case "emote": case "emote":
const emote = menu.client.emojis.cache.get(id); const emote = menu.client.emojis.cache.get(id);
@ -440,14 +420,12 @@ export class Command extends BaseCommand {
menu.args.push(emote); menu.args.push(emote);
return this.id.execute(args, menu, metadata); return this.id.execute(args, menu, metadata);
} else { } else {
return { return `\`${id}\` isn't a valid emote!`;
content: `\`${id}\` isn't a valid emote!`
};
} }
case "message": case "message":
const message = await getMessageByID(menu.channel, id); const message = await getMessageByID(menu.channel, id);
if (message instanceof Message) { if (typeof message !== "string") {
menu.args.push(message); menu.args.push(message);
return this.id.execute(args, menu, metadata); return this.id.execute(args, menu, metadata);
} else { } else {
@ -456,7 +434,7 @@ export class Command extends BaseCommand {
case "user": case "user":
const user = await getUserByID(id); const user = await getUserByID(id);
if (user instanceof User) { if (typeof user !== "string") {
menu.args.push(user); menu.args.push(user);
return this.id.execute(args, menu, metadata); return this.id.execute(args, menu, metadata);
} else { } else {
@ -465,7 +443,7 @@ export class Command extends BaseCommand {
case "guild": case "guild":
const guild = getGuildByID(id); const guild = getGuildByID(id);
if (guild instanceof Guild) { if (typeof guild !== "string") {
menu.args.push(guild); menu.args.push(guild);
return this.id.execute(args, menu, metadata); return this.id.execute(args, menu, metadata);
} else { } else {
@ -489,11 +467,9 @@ export class Command extends BaseCommand {
return this.any.execute(args.join(" "), menu, metadata); return this.any.execute(args.join(" "), menu, metadata);
} else { } else {
metadata.symbolicArgs.push(`"${param}"`); metadata.symbolicArgs.push(`"${param}"`);
return { return `No valid command sequence matching \`${metadata.header} ${metadata.symbolicArgs.join(
content: `No valid command sequence matching \`${metadata.header} ${metadata.symbolicArgs.join( " "
" " )}\` found.`;
)}\` found.`
};
} }
// Note: Do NOT add a return statement here. In case one of the other sections is missing // Note: Do NOT add a return statement here. In case one of the other sections is missing
@ -682,7 +658,7 @@ export class RestCommand extends BaseCommand {
combined: string, combined: string,
menu: CommandMenu, menu: CommandMenu,
metadata: ExecuteCommandMetadata metadata: ExecuteCommandMetadata
): Promise<SingleMessageOptions | null> { ): Promise<string | null> {
// Update inherited properties if the current command specifies a property. // Update inherited properties if the current command specifies a property.
// In case there are no initial arguments, these should go first so that it can register. // In case there are no initial arguments, these should go first so that it can register.
if (this.permission !== -1) metadata.permission = this.permission; if (this.permission !== -1) metadata.permission = this.permission;
@ -716,9 +692,7 @@ export class RestCommand extends BaseCommand {
const errorMessage = error.stack ?? error; const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`); console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
return { return `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``;
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
};
} }
} }
@ -743,36 +717,34 @@ export class RestCommand extends BaseCommand {
// See if there is anything that'll prevent the user from executing the command. // See if there is anything that'll prevent the user from executing the command.
// Returns null if successful, otherwise returns a message with the error. // Returns null if successful, otherwise returns a message with the error.
function canExecute(menu: CommandMenu, metadata: ExecuteCommandMetadata): SingleMessageOptions | null { function canExecute(menu: CommandMenu, metadata: ExecuteCommandMetadata): string | null {
// 1. Does this command specify a required channel type? If so, does the channel type match? // 1. Does this command specify a required channel type? If so, does the channel type match?
if ( if (
metadata.channelType === CHANNEL_TYPE.GUILD && metadata.channelType === CHANNEL_TYPE.GUILD &&
(!(menu.channel instanceof GuildChannel) || menu.guild === null || menu.member === null) (!(menu.channel instanceof GuildChannel) || menu.guild === null || menu.member === null)
) { ) {
return {content: "This command must be executed in a server."}; return "This command must be executed in a server.";
} else if ( } else if (
metadata.channelType === CHANNEL_TYPE.DM && metadata.channelType === CHANNEL_TYPE.DM &&
(menu.channel.type !== "dm" || menu.guild !== null || menu.member !== null) (menu.channel.type !== "dm" || menu.guild !== null || menu.member !== null)
) { ) {
return {content: "This command must be executed as a direct message."}; return "This command must be executed as a direct message.";
} }
// 2. Is this an NSFW command where the channel prevents such use? (DM channels bypass this requirement.) // 2. Is this an NSFW command where the channel prevents such use? (DM channels bypass this requirement.)
if (metadata.nsfw && menu.channel.type !== "dm" && !menu.channel.nsfw) { if (metadata.nsfw && menu.channel.type !== "dm" && !menu.channel.nsfw) {
return {content: "This command must be executed in either an NSFW channel or as a direct message."}; return "This command must be executed in either an NSFW channel or as a direct message.";
} }
// 3. Does the user have permission to execute the command? // 3. Does the user have permission to execute the command?
if (!hasPermission(menu.author, menu.member, metadata.permission)) { if (!hasPermission(menu.author, menu.member, metadata.permission)) {
const userPermLevel = getPermissionLevel(menu.author, menu.member); const userPermLevel = getPermissionLevel(menu.author, menu.member);
return { return `You don't have access to this command! Your permission level is \`${getPermissionName(
content: `You don't have access to this command! Your permission level is \`${getPermissionName( userPermLevel
userPermLevel )}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName( metadata.permission
metadata.permission )}\` (${metadata.permission}).`;
)}\` (${metadata.permission}).`
};
} }
return null; return null;

View File

@ -15,7 +15,9 @@ import {
MessageAdditions, MessageAdditions,
SplitOptions, SplitOptions,
APIMessage, APIMessage,
StringResolvable StringResolvable,
EmojiIdentifierResolvable,
MessageReaction
} from "discord.js"; } from "discord.js";
import {unreactEventListeners, replyEventListeners} from "./eventListeners"; import {unreactEventListeners, replyEventListeners} from "./eventListeners";
import {client} from "./interface"; import {client} from "./interface";
@ -31,19 +33,6 @@ export type SendFunction = ((
((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) & ((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise<Message[]>) &
((content: StringResolvable, options: MessageOptions) => Promise<Message | Message[]>); ((content: StringResolvable, options: MessageOptions) => Promise<Message | Message[]>);
/**
* Tests if a bot has a certain permission in a specified guild.
*/
export function botHasPermission(guild: Guild | null, permission: number): boolean {
return !!guild?.me?.hasPermission(permission);
}
// The SoonTM Section //
// Maybe promisify this section to reduce the potential for creating callback hell? Especially if multiple questions in a row are being asked.
// It's probably a good idea to modularize the base reaction handler so there's less copy pasted code.
// Maybe also make a reaction handler that listens for when reactions are added and removed.
// The reaction handler would also run an async function to react in order (parallel to the reaction handler).
const FIVE_BACKWARDS_EMOJI = "⏪"; const FIVE_BACKWARDS_EMOJI = "⏪";
const BACKWARDS_EMOJI = "⬅️"; const BACKWARDS_EMOJI = "⬅️";
const FORWARDS_EMOJI = "➡️"; const FORWARDS_EMOJI = "➡️";
@ -56,34 +45,35 @@ const FIVE_FORWARDS_EMOJI = "⏩";
*/ */
export async function paginate( export async function paginate(
send: SendFunction, send: SendFunction,
senderID: string, onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions,
total: number, totalPages: number,
callback: (page: number, hasMultiplePages: boolean) => SingleMessageOptions, listenTo: string | null = null,
duration = 60000 duration = 60000
) { ): Promise<void> {
const hasMultiplePages = total > 1; const hasMultiplePages = totalPages > 1;
const message = await send(callback(0, hasMultiplePages)); const message = await send(onTurnPage(0, hasMultiplePages));
if (hasMultiplePages) { if (hasMultiplePages) {
let page = 0; let page = 0;
const turn = (amount: number) => { const turn = (amount: number) => {
page += amount; page += amount;
if (page >= total) { if (page >= totalPages) {
page %= total; page %= totalPages;
} else if (page < 0) { } 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. // 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) % total; const flattened = Math.abs(page) % totalPages;
if (flattened !== 0) page = total - flattened; if (flattened !== 0) page = totalPages - flattened;
} }
message.edit(callback(page, true)); message.edit(onTurnPage(page, true));
}; };
const handle = (emote: string, reacterID: string) => { const handle = (emote: string, reacterID: string) => {
if (senderID === reacterID) { if (reacterID === listenTo || listenTo === null) {
collector.resetTimer(); // The timer refresh MUST be present in both react and unreact.
switch (emote) { switch (emote) {
case FIVE_BACKWARDS_EMOJI: case FIVE_BACKWARDS_EMOJI:
if (total > 5) turn(-5); if (totalPages > 5) turn(-5);
break; break;
case BACKWARDS_EMOJI: case BACKWARDS_EMOJI:
turn(-1); turn(-1);
@ -92,28 +82,28 @@ export async function paginate(
turn(1); turn(1);
break; break;
case FIVE_FORWARDS_EMOJI: case FIVE_FORWARDS_EMOJI:
if (total > 5) turn(5); if (totalPages > 5) turn(5);
break; break;
} }
} }
}; };
// Listen for reactions and call the handler. // Listen for reactions and call the handler.
let backwardsReactionFive = total > 5 ? await message.react(FIVE_BACKWARDS_EMOJI) : null; let backwardsReactionFive = totalPages > 5 ? await message.react(FIVE_BACKWARDS_EMOJI) : null;
let backwardsReaction = await message.react(BACKWARDS_EMOJI); let backwardsReaction = await message.react(BACKWARDS_EMOJI);
let forwardsReaction = await message.react(FORWARDS_EMOJI); let forwardsReaction = await message.react(FORWARDS_EMOJI);
let forwardsReactionFive = total > 5 ? await message.react(FIVE_FORWARDS_EMOJI) : null; let forwardsReactionFive = totalPages > 5 ? await message.react(FIVE_FORWARDS_EMOJI) : null;
unreactEventListeners.set(message.id, handle); unreactEventListeners.set(message.id, handle);
const collector = message.createReactionCollector( const collector = message.createReactionCollector(
(reaction, user) => { (reaction, user) => {
if (user.id === senderID) { // 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. // 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. // 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); const canDeleteEmotes = botHasPermission(message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
handle(reaction.emoji.name, user.id); handle(reaction.emoji.name, user.id);
if (canDeleteEmotes) reaction.users.remove(user); if (canDeleteEmotes) reaction.users.remove(user);
collector.resetTimer();
} }
return false; return false;
@ -134,100 +124,73 @@ export async function paginate(
} }
} }
// Waits for the sender to either confirm an action or let it pass (and delete the message). //export function generateMulti
// This should probably be renamed to "confirm" now that I think of it, "prompt" is better used elsewhere. // paginate after generateonetimeprompt
// Append "\n*(This message will automatically be deleted after 10 seconds.)*" in the future?
/**
* Prompts the user about a decision before following through.
*/
export async function prompt(message: Message, senderID: string, onConfirm: () => void, duration = 10000) {
let isDeleted = false;
message.react("✅"); // Returns null if timed out, otherwise, returns the value.
await message.awaitReactions( export function generateOneTimePrompt<T>(
(reaction, user) => {
if (user.id === senderID) {
if (reaction.emoji.name === "✅") {
onConfirm();
isDeleted = true;
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 (!isDeleted) message.delete();
}
// Asks the user for some input using the inline reply feature. The message here is a message you send beforehand.
// If the reply is rejected, reply with an error message (when stable support comes from discord.js).
// Append "\n*(Note: Make sure to use Discord's inline reply feature or this won't work!)*" in the future? And also the "you can now reply to this message" edit.
export function ask(
message: Message, message: Message,
senderID: string, stack: {[emote: string]: T},
condition: (reply: string) => boolean, listenTo: string | null = null,
onSuccess: () => void, duration = 60000
onReject: () => string, ): Promise<T | null> {
timeout = 60000 return new Promise(async (resolve) => {
) { // First, start reacting to the message in order.
const referenceID = `${message.channel.id}-${message.id}`; reactInOrder(message, Object.keys(stack));
replyEventListeners.set(referenceID, (reply) => { // Then setup the reaction listener in parallel.
if (reply.author.id === senderID) {
if (condition(reply.content)) {
onSuccess();
replyEventListeners.delete(referenceID);
} else {
reply.reply(onReject());
}
}
});
setTimeout(() => {
replyEventListeners.set(referenceID, (reply) => {
reply.reply("that action timed out, try using the command again");
replyEventListeners.delete(referenceID);
});
}, timeout);
}
export function askYesOrNo(message: Message, senderID: string, timeout = 30000): Promise<boolean> {
return new Promise(async (resolve, reject) => {
let isDeleted = false;
await message.react("✅");
message.react("❌");
await message.awaitReactions( await message.awaitReactions(
(reaction, user) => { (reaction: MessageReaction, user: User) => {
if (user.id === senderID) { if (user.id === listenTo || listenTo === null) {
const isCheckReacted = reaction.emoji.name === "✅"; const emote = reaction.emoji.name;
if (isCheckReacted || reaction.emoji.name === "❌") { if (emote in stack) {
resolve(isCheckReacted); resolve(stack[emote]);
isDeleted = true;
message.delete(); 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; return false;
}, },
{time: timeout} {time: duration}
); );
if (!isDeleted) { if (!message.deleted) {
message.delete(); message.delete();
reject("Prompt timed out."); 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;
}
}
}
export function confirm(message: Message, senderID: string, timeout = 30000): Promise<boolean | null> {
return generateOneTimePrompt(
message,
{
"✅": true,
"❌": false
},
senderID,
timeout
);
}
// This MUST be split into an array. These emojis are made up of several characters each, adding up to 29 in length. // This MUST be split into an array. These emojis are made up of several characters each, adding up to 29 in length.
const multiNumbers = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟"]; const multiNumbers = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟"];
@ -236,40 +199,47 @@ const multiNumbers = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6
export async function askMultipleChoice( export async function askMultipleChoice(
message: Message, message: Message,
senderID: string, senderID: string,
callbackStack: (() => void)[], choices: number,
timeout = 90000 timeout = 90000
) { ): Promise<number | null> {
if (callbackStack.length > multiNumbers.length) { if (choices > multiNumbers.length)
message.channel.send( throw new Error(
`\`ERROR: The amount of callbacks in "askMultipleChoice" must not exceed the total amount of allowed options (${multiNumbers.length})!\`` `askMultipleChoice only accepts up to ${multiNumbers.length} options, ${choices} was provided.`
); );
return; const numbers: {[emote: string]: number} = {};
} for (let i = 0; i < choices; i++) numbers[multiNumbers[i]] = i;
return generateOneTimePrompt(message, numbers, senderID, timeout);
}
let isDeleted = false; // Asks the user for some input using the inline reply feature. The message here is a message you send beforehand.
// If the reply is rejected, reply with an error message (when stable support comes from discord.js).
export function askForReply(message: Message, listenTo: string, timeout?: number): Promise<Message | null> {
return new Promise((resolve) => {
const referenceID = `${message.channel.id}-${message.id}`;
for (let i = 0; i < callbackStack.length; i++) { replyEventListeners.set(referenceID, (reply) => {
await message.react(multiNumbers[i]); if (reply.author.id === listenTo) {
} message.delete();
replyEventListeners.delete(referenceID);
await message.awaitReactions( resolve(reply);
(reaction, user) => {
if (user.id === senderID) {
const index = multiNumbers.indexOf(reaction.emoji.name);
if (index !== -1) {
callbackStack[index]();
isDeleted = true;
message.delete();
}
} }
});
return false; if (timeout) {
}, client.setTimeout(() => {
{time: timeout} if (!message.deleted) message.delete();
); replyEventListeners.delete(referenceID);
resolve(null);
}, timeout);
}
});
}
if (!isDeleted) message.delete(); /**
* Tests if a bot has a certain permission in a specified guild.
*/
export function botHasPermission(guild: Guild | null, permission: number): boolean {
return !!guild?.me?.hasPermission(permission);
} }
// For "get x by y" methods: // For "get x by y" methods:
@ -277,79 +247,75 @@ export async function askMultipleChoice(
// It's more reliable to get users/members by fetching their IDs. fetch() will searching through the cache anyway. // It's more reliable to get users/members by fetching their IDs. fetch() will searching through the cache anyway.
// For guilds, do an extra check to make sure there isn't an outage (guild.available). // For guilds, do an extra check to make sure there isn't an outage (guild.available).
export function getGuildByID(id: string): Guild | SingleMessageOptions { export function getGuildByID(id: string): Guild | string {
const guild = client.guilds.cache.get(id); const guild = client.guilds.cache.get(id);
if (guild) { if (guild) {
if (guild.available) return guild; if (guild.available) return guild;
else return {content: `The guild \`${guild.name}\` (ID: \`${id}\`) is unavailable due to an outage.`}; else return `The guild \`${guild.name}\` (ID: \`${id}\`) is unavailable due to an outage.`;
} else { } else {
return { return `No guild found by the ID of \`${id}\`!`;
content: `No guild found by the ID of \`${id}\`!`
};
} }
} }
export function getGuildByName(name: string): Guild | SingleMessageOptions { export function getGuildByName(name: string): Guild | string {
const query = name.toLowerCase(); const query = name.toLowerCase();
const guild = client.guilds.cache.find((guild) => guild.name.toLowerCase().includes(query)); const guild = client.guilds.cache.find((guild) => guild.name.toLowerCase().includes(query));
if (guild) { if (guild) {
if (guild.available) return guild; if (guild.available) return guild;
else return {content: `The guild \`${guild.name}\` (ID: \`${guild.id}\`) is unavailable due to an outage.`}; else return `The guild \`${guild.name}\` (ID: \`${guild.id}\`) is unavailable due to an outage.`;
} else { } else {
return { return `No guild found by the name of \`${name}\`!`;
content: `No guild found by the name of \`${name}\`!`
};
} }
} }
export async function getChannelByID(id: string): Promise<Channel | SingleMessageOptions> { export async function getChannelByID(id: string): Promise<Channel | string> {
try { try {
return await client.channels.fetch(id); return await client.channels.fetch(id);
} catch { } catch {
return {content: `No channel found by the ID of \`${id}\`!`}; return `No channel found by the ID of \`${id}\`!`;
} }
} }
// Only go through the cached channels (non-DM channels). Plus, searching DM channels by name wouldn't really make sense, nor do they have names to search anyway. // Only go through the cached channels (non-DM channels). Plus, searching DM channels by name wouldn't really make sense, nor do they have names to search anyway.
export function getChannelByName(name: string): GuildChannel | SingleMessageOptions { export function getChannelByName(name: string): GuildChannel | string {
const query = name.toLowerCase(); const query = name.toLowerCase();
const channel = client.channels.cache.find( const channel = client.channels.cache.find(
(channel) => channel instanceof GuildChannel && channel.name.toLowerCase().includes(query) (channel) => channel instanceof GuildChannel && channel.name.toLowerCase().includes(query)
) as GuildChannel | undefined; ) as GuildChannel | undefined;
if (channel) return channel; if (channel) return channel;
else return {content: `No channel found by the name of \`${name}\`!`}; else return `No channel found by the name of \`${name}\`!`;
} }
export async function getMessageByID( export async function getMessageByID(
channel: TextChannel | DMChannel | NewsChannel | string, channel: TextChannel | DMChannel | NewsChannel | string,
id: string id: string
): Promise<Message | SingleMessageOptions> { ): Promise<Message | string> {
if (typeof channel === "string") { if (typeof channel === "string") {
const targetChannel = await getChannelByID(channel); const targetChannel = await getChannelByID(channel);
if (targetChannel instanceof TextChannel || targetChannel instanceof DMChannel) channel = targetChannel; if (targetChannel instanceof TextChannel || targetChannel instanceof DMChannel) channel = targetChannel;
else if (targetChannel instanceof Channel) return {content: `\`${id}\` isn't a valid text-based channel!`}; else if (targetChannel instanceof Channel) return `\`${id}\` isn't a valid text-based channel!`;
else return targetChannel; else return targetChannel;
} }
try { try {
return await channel.messages.fetch(id); return await channel.messages.fetch(id);
} catch { } catch {
return {content: `\`${id}\` isn't a valid message of the channel ${channel}!`}; return `\`${id}\` isn't a valid message of the channel ${channel}!`;
} }
} }
export async function getUserByID(id: string): Promise<User | SingleMessageOptions> { export async function getUserByID(id: string): Promise<User | string> {
try { try {
return await client.users.fetch(id); return await client.users.fetch(id);
} catch { } catch {
return {content: `No user found by the ID of \`${id}\`!`}; return `No user found by the ID of \`${id}\`!`;
} }
} }
// Also check tags (if provided) to narrow down users. // Also check tags (if provided) to narrow down users.
export function getUserByName(name: string): User | SingleMessageOptions { export function getUserByName(name: string): User | string {
let query = name.toLowerCase(); let query = name.toLowerCase();
const tagMatch = /^(.+?)#(\d{4})$/.exec(name); const tagMatch = /^(.+?)#(\d{4})$/.exec(name);
let tag: string | null = null; let tag: string | null = null;
@ -366,19 +332,19 @@ export function getUserByName(name: string): User | SingleMessageOptions {
}); });
if (user) return user; if (user) return user;
else return {content: `No user found by the name of \`${name}\`!`}; else return `No user found by the name of \`${name}\`!`;
} }
export async function getMemberByID(guild: Guild, id: string): Promise<GuildMember | SingleMessageOptions> { export async function getMemberByID(guild: Guild, id: string): Promise<GuildMember | string> {
try { try {
return await guild.members.fetch(id); return await guild.members.fetch(id);
} catch { } catch {
return {content: `No member found by the ID of \`${id}\`!`}; return `No member found by the ID of \`${id}\`!`;
} }
} }
// First checks if a member can be found by that nickname, then check if a member can be found by that username. // First checks if a member can be found by that nickname, then check if a member can be found by that username.
export async function getMemberByName(guild: Guild, name: string): Promise<GuildMember | SingleMessageOptions> { export async function getMemberByName(guild: Guild, name: string): Promise<GuildMember | string> {
const member = ( const member = (
await guild.members.fetch({ await guild.members.fetch({
query: name, query: name,
@ -395,9 +361,9 @@ export async function getMemberByName(guild: Guild, name: string): Promise<Guild
if (user instanceof User) { if (user instanceof User) {
const member = guild.members.resolve(user); const member = guild.members.resolve(user);
if (member) return member; if (member) return member;
else return {content: `The user \`${user.tag}\` isn't in this guild!`}; else return `The user \`${user.tag}\` isn't in this guild!`;
} else { } else {
return {content: `No member found by the name of \`${name}\`!`}; return `No member found by the name of \`${name}\`!`;
} }
} }
} }

View File

@ -1,5 +1,5 @@
import {client} from "../index"; import {client} from "../index";
import {Message, MessageEmbed} from "discord.js"; import {MessageEmbed} from "discord.js";
import {getPrefix} from "../structures"; import {getPrefix} from "../structures";
import {getMessageByID} from "../core"; import {getMessageByID} from "../core";
@ -13,7 +13,7 @@ client.on("message", async (message) => {
const linkMessage = await getMessageByID(channelID, messageID); const linkMessage = await getMessageByID(channelID, messageID);
// If it's an invalid link (or the bot doesn't have access to it). // If it's an invalid link (or the bot doesn't have access to it).
if (!(linkMessage instanceof Message)) { if (typeof linkMessage === "string") {
return message.channel.send("I don't have access to that channel!"); return message.channel.send("I don't have access to that channel!");
} }