diff --git a/docs/Documentation.md b/docs/Documentation.md index 1907bc9..28c2c32 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -75,27 +75,24 @@ Because versions are assigned to batches of changes rather than single changes ( ```ts const pages = ["one", "two", "three"]; -paginate(channel, author.id, pages.length, (page) => { - return { - content: pages[page] - }; -}); +paginate(send, page => { + return {content: pages[page]}; +}, pages.length, author.id); ``` -`prompt()` +`confirm()` ```ts -const msg = await channel.send('Are you sure you want to delete this?'); - -prompt(msg, author.id, () => { - //... -}); +const result = await confirm(await send("Are you sure you want to delete this?"), author.id); // boolean | null ``` -`callMemberByUsername()` +`askMultipleChoice()` ```ts -callMemberByUsername(message, args.join(" "), (member) => { - channel.send(`Your nickname is ${member.nickname}.`); -}); +const result = await askMultipleChoice(await send("Which of the following numbers is your favorite?"), author.id, 4, 10000); // number (0 to 3) | null +``` + +`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 diff --git a/src/commands/fun/eco.ts b/src/commands/fun/eco.ts index 96c1b43..241411d 100644 --- a/src/commands/fun/eco.ts +++ b/src/commands/fun/eco.ts @@ -4,7 +4,6 @@ import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./modu import {BuyCommand, ShopCommand} from "./modules/eco-shop"; import {MondayCommand, AwardCommand} from "./modules/eco-extras"; import {BetCommand} from "./modules/eco-bet"; -import {GuildMember} from "discord.js"; export default new NamedCommand({ description: "Economy command for Monika.", @@ -38,7 +37,7 @@ export default new NamedCommand({ async run({send, guild, channel, args, message, combined}) { if (isAuthorized(guild, channel)) { 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); } } diff --git a/src/commands/fun/modules/eco-bet.ts b/src/commands/fun/modules/eco-bet.ts index ecda43c..7b38485 100644 --- a/src/commands/fun/modules/eco-bet.ts +++ b/src/commands/fun/modules/eco-bet.ts @@ -1,7 +1,7 @@ -import {Command, NamedCommand, askYesOrNo} from "../../../core"; +import {Command, NamedCommand, confirm} from "../../../core"; import {pluralise} from "../../../lib"; 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"; 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}`); // Ask target whether or not they want to take the bet. - const takeBet = await askYesOrNo( + const takeBet = await confirm( await send( `<@${target.id}>, do you want to take this bet of ${pluralise(amount, "Mon", "s")}` ), target.id ); - if (takeBet) { - // [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(); + if (!takeBet) return send(`<@${target.id}> has rejected your bet, <@${author.id}>`); - // Notify both users. - await send( - `<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${pluralise( - amount, - "Mon", - "s" - )} has been deducted from each of them.` + // [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. + 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. - 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("❌"); + // 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; - // 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; + 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.` + ); + } - 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.` - ); - } - sender.ecoBetInsurance -= amount; - receiver.ecoBetInsurance -= amount; - Storage.save(); - }); - }, duration); - } else return await send(`<@${target.id}> has rejected your bet, <@${author.id}>`); + sender.ecoBetInsurance -= amount; + receiver.ecoBetInsurance -= amount; + Storage.save(); + }); + }, duration); } else return; } }) diff --git a/src/commands/fun/modules/eco-core.ts b/src/commands/fun/modules/eco-core.ts index ec3d8b6..4e0a520 100644 --- a/src/commands/fun/modules/eco-core.ts +++ b/src/commands/fun/modules/eco-core.ts @@ -1,5 +1,4 @@ -import {GuildMember} from "discord.js"; -import {Command, getMemberByName, NamedCommand, prompt, RestCommand} from "../../../core"; +import {Command, getMemberByName, NamedCommand, confirm, RestCommand} from "../../../core"; import {pluralise} from "../../../lib"; import {Storage} from "../../../structures"; 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); fields.push({ - name: `#${i + 1}. ${user.username}#${user.discriminator}`, + name: `#${i + 1}. ${user.tag}`, 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!"); 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.bot && process.argv[2] !== "dev") return send("You can't send Mons to a bot!"); const target = member.user; - return prompt( - await send( - `Are you sure you want to send ${pluralise( - amount, - "Mon", - "s" - )} to this person?\n*(This message will automatically be deleted after 10 seconds.)*`, - { - embed: { - color: ECO_EMBED_COLOR, - author: { - name: `${target.username}#${target.discriminator}`, - icon_url: target.displayAvatarURL({ - format: "png", - dynamic: true - }) - } + const result = await confirm( + await send(`Are you sure you want to send ${pluralise(amount, "Mon", "s")} to this person?`, { + embed: { + color: ECO_EMBED_COLOR, + author: { + name: target.tag, + icon_url: target.displayAvatarURL({ + format: "png", + dynamic: true + }) } } - ), - author.id, - () => { - const receiver = Storage.getUser(target.id); - sender.money -= amount; - receiver.money += amount; - Storage.save(); - send(getSendEmbed(author, target, amount)); - } + }), + author.id ); + + if (result) { + const receiver = Storage.getUser(target.id); + sender.money -= amount; + receiver.money += amount; + Storage.save(); + send(getSendEmbed(author, target, amount)); + } } + + return; } }) }); diff --git a/src/commands/fun/modules/eco-shop.ts b/src/commands/fun/modules/eco-shop.ts index acd0e4a..91ac71d 100644 --- a/src/commands/fun/modules/eco-shop.ts +++ b/src/commands/fun/modules/eco-shop.ts @@ -34,12 +34,17 @@ export const ShopCommand = new NamedCommand({ const shopPages = split(ShopItems, 5); const pageAmount = shopPages.length; - paginate(send, author.id, pageAmount, (page, hasMultiplePages) => { - return getShopEmbed( - shopPages[page], - hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop" - ); - }); + paginate( + send, + (page, hasMultiplePages) => { + return getShopEmbed( + shopPages[page], + hasMultiplePages ? `Shop (Page ${page + 1} of ${pageAmount})` : "Shop" + ); + }, + pageAmount, + author.id + ); } } }); diff --git a/src/commands/fun/modules/eco-utils.ts b/src/commands/fun/modules/eco-utils.ts index 327abdb..d014374 100644 --- a/src/commands/fun/modules/eco-utils.ts +++ b/src/commands/fun/modules/eco-utils.ts @@ -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()}!`, fields: [ { - name: `Sender: ${sender.username}#${sender.discriminator}`, + name: `Sender: ${sender.tag}`, 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") } ], diff --git a/src/commands/fun/whois.ts b/src/commands/fun/whois.ts index 4ad9c97..507c14f 100644 --- a/src/commands/fun/whois.ts +++ b/src/commands/fun/whois.ts @@ -1,4 +1,4 @@ -import {User, GuildMember} from "discord.js"; +import {User} from "discord.js"; import {Command, NamedCommand, getMemberByName, CHANNEL_TYPE, RestCommand} from "../../core"; // 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}) { const member = await getMemberByName(guild!, combined); - if (member instanceof GuildMember) { + if (typeof member !== "string") { if (member.id in registry) { send(`\`${member.nickname ?? member.user.username}\` - ${registry[member.id]}`); } else { diff --git a/src/commands/system/help.ts b/src/commands/system/help.ts index 7691a5a..7e5139c 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -20,16 +20,21 @@ export default new NamedCommand({ const commands = await getCommandList(); const categoryArray = commands.keyArray(); - 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); - }); + 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 + ); }, any: new Command({ async run({send, message, channel, guild, author, member, client, args}) { diff --git a/src/commands/utility/info.ts b/src/commands/utility/info.ts index 5e51363..c9368e6 100644 --- a/src/commands/utility/info.ts +++ b/src/commands/utility/info.ts @@ -36,7 +36,7 @@ export default new NamedCommand({ async run({send, message, channel, guild, author, client, args, combined}) { const member = await getMemberByName(guild!, combined); - if (member instanceof GuildMember) { + if (typeof member !== "string") { send( member.user.displayAvatarURL({ dynamic: true, @@ -110,7 +110,7 @@ export default new NamedCommand({ async run({send, message, channel, guild, author, member, client, args, combined}) { const targetGuild = getGuildByName(combined); - if (targetGuild instanceof Guild) { + if (typeof targetGuild !== "string") { send(await getGuildInfo(targetGuild, guild)); } else { send(targetGuild); diff --git a/src/commands/utility/lsemotes.ts b/src/commands/utility/lsemotes.ts index 37840b9..7e1e2d9 100644 --- a/src/commands/utility/lsemotes.ts +++ b/src/commands/utility/lsemotes.ts @@ -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) if (pages > 0) { - paginate(send, author.id, pages, (page, hasMultiplePages) => { - embed.setTitle(hasMultiplePages ? `**Emotes** (Page ${page + 1} of ${pages})` : "**Emotes**"); + paginate( + send, + (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; - }); + return embed; + }, + pages, + author.id + ); } else { send("No valid emotes found by that query."); } diff --git a/src/commands/utility/time.ts b/src/commands/utility/time.ts index 7598f5a..9852b86 100644 --- a/src/commands/utility/time.ts +++ b/src/commands/utility/time.ts @@ -1,15 +1,6 @@ -import { - Command, - NamedCommand, - ask, - askYesOrNo, - askMultipleChoice, - prompt, - getMemberByName, - RestCommand -} from "../../core"; +import {Command, NamedCommand, askForReply, confirm, askMultipleChoice, getMemberByName, RestCommand} from "../../core"; import {Storage} from "../../structures"; -import {User, GuildMember} from "discord.js"; +import {User} from "discord.js"; import moment from "moment"; const DATE_FORMAT = "D MMMM YYYY"; @@ -178,183 +169,184 @@ function getTimeEmbed(user: User) { export default new NamedCommand({ description: "Show others what time it is for you.", aliases: ["tz"], - async run({send, channel, author}) { + async run({send, author}) { send(getTimeEmbed(author)); }, subcommands: { // Welcome to callback hell. We hope you enjoy your stay here! setup: new NamedCommand({ description: "Registers your timezone information for the bot.", - async run({send, author, channel}) { + async run({send, author}) { const profile = Storage.getUser(author.id); profile.timezone = null; profile.daylightSavingsRegion = null; - let hour: number; - ask( + // Parse and validate reply + const reply = await askForReply( 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!)*" ), author.id, - (reply) => { - 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" + 30000 ); + 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({ description: "Delete your timezone information.", - async run({send, channel, author}) { - prompt( - await send( - "Are you sure you want to delete your timezone information?\n*(This message will automatically be deleted after 10 seconds.)*" - ), - author.id, - () => { - const profile = Storage.getUser(author.id); - profile.timezone = null; - profile.daylightSavingsRegion = null; - Storage.save(); - } + async run({send, author}) { + const result = await confirm( + await send("Are you sure you want to delete your timezone information?"), + author.id ); + + if (result) { + const profile = Storage.getUser(author.id); + profile.timezone = null; + profile.daylightSavingsRegion = null; + Storage.save(); + } } }), utc: new NamedCommand({ description: "Displays UTC time.", - async run({send, channel}) { + async run({send}) { const time = moment().utc(); send({ @@ -386,15 +378,15 @@ export default new NamedCommand({ id: "user", user: new Command({ description: "See what time it is for someone else.", - async run({send, channel, args}) { + async run({send, args}) { send(getTimeEmbed(args[0])); } }), any: new RestCommand({ 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); - if (member instanceof GuildMember) send(getTimeEmbed(member.user)); + if (typeof member !== "string") send(getTimeEmbed(member.user)); else send(member); } }) diff --git a/src/core/command.ts b/src/core/command.ts index cded03c..7e024da 100644 --- a/src/core/command.ts +++ b/src/core/command.ts @@ -11,7 +11,7 @@ import { GuildChannel, Channel } 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 {getPrefix} from "./interface"; 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. - // 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. // 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. // 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( - args: string[], - menu: CommandMenu, - metadata: ExecuteCommandMetadata - ): Promise { + public async execute(args: string[], menu: CommandMenu, metadata: ExecuteCommandMetadata): Promise { // 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. if (this.permission !== -1) metadata.permission = this.permission; @@ -292,9 +288,7 @@ export class Command extends BaseCommand { const errorMessage = error.stack ?? error; console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`); - return { - content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\`` - }; + return `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 channel = await getChannelByID(id); - if (channel instanceof Channel) { + if (typeof channel !== "string") { if (channel instanceof TextChannel || channel instanceof DMChannel) { metadata.symbolicArgs.push(""); menu.args.push(channel); return this.channel.execute(args, menu, metadata); } else { - return { - content: `\`${id}\` is not a valid text channel!` - }; + return `\`${id}\` is not a valid text channel!`; } } else { return channel; @@ -330,9 +322,7 @@ export class Command extends BaseCommand { const id = patterns.role.exec(param)![1]; if (!menu.guild) { - return { - content: "You can't use role parameters in DM channels!" - }; + return "You can't use role parameters in DM channels!"; } const role = menu.guild.roles.cache.get(id); @@ -342,9 +332,7 @@ export class Command extends BaseCommand { menu.args.push(role); return this.role.execute(args, menu, metadata); } else { - return { - content: `\`${id}\` is not a valid role in this server!` - }; + return `\`${id}\` is not a valid role in this server!`; } } else if (this.emote && patterns.emote.test(param)) { const id = patterns.emote.exec(param)![1]; @@ -355,9 +343,7 @@ export class Command extends BaseCommand { menu.args.push(emote); return this.emote.execute(args, menu, metadata); } else { - return { - content: `\`${id}\` isn't a valid emote!` - }; + return `\`${id}\` isn't a valid emote!`; } } else if (this.message && (isMessageLink || isMessagePair)) { let channelID = ""; @@ -375,7 +361,7 @@ export class Command extends BaseCommand { const message = await getMessageByID(channelID, messageID); - if (message instanceof Message) { + if (typeof message !== "string") { metadata.symbolicArgs.push(""); menu.args.push(message); return this.message.execute(args, menu, metadata); @@ -386,7 +372,7 @@ export class Command extends BaseCommand { const id = patterns.user.exec(param)![1]; const user = await getUserByID(id); - if (user instanceof User) { + if (typeof user !== "string") { metadata.symbolicArgs.push(""); menu.args.push(user); return this.user.execute(args, menu, metadata); @@ -403,24 +389,20 @@ export class Command extends BaseCommand { case "channel": const channel = await getChannelByID(id); - if (channel instanceof Channel) { + if (typeof channel !== "string") { if (channel instanceof TextChannel || channel instanceof DMChannel) { metadata.symbolicArgs.push(""); menu.args.push(channel); return this.id.execute(args, menu, metadata); } else { - return { - content: `\`${id}\` is not a valid text channel!` - }; + return `\`${id}\` is not a valid text channel!`; } } else { return channel; } case "role": if (!menu.guild) { - return { - content: "You can't use role parameters in DM channels!" - }; + return "You can't use role parameters in DM channels!"; } const role = menu.guild.roles.cache.get(id); @@ -429,9 +411,7 @@ export class Command extends BaseCommand { menu.args.push(role); return this.id.execute(args, menu, metadata); } else { - return { - content: `\`${id}\` isn't a valid role in this server!` - }; + return `\`${id}\` isn't a valid role in this server!`; } case "emote": const emote = menu.client.emojis.cache.get(id); @@ -440,14 +420,12 @@ export class Command extends BaseCommand { menu.args.push(emote); return this.id.execute(args, menu, metadata); } else { - return { - content: `\`${id}\` isn't a valid emote!` - }; + return `\`${id}\` isn't a valid emote!`; } case "message": const message = await getMessageByID(menu.channel, id); - if (message instanceof Message) { + if (typeof message !== "string") { menu.args.push(message); return this.id.execute(args, menu, metadata); } else { @@ -456,7 +434,7 @@ export class Command extends BaseCommand { case "user": const user = await getUserByID(id); - if (user instanceof User) { + if (typeof user !== "string") { menu.args.push(user); return this.id.execute(args, menu, metadata); } else { @@ -465,7 +443,7 @@ export class Command extends BaseCommand { case "guild": const guild = getGuildByID(id); - if (guild instanceof Guild) { + if (typeof guild !== "string") { menu.args.push(guild); return this.id.execute(args, menu, metadata); } else { @@ -489,11 +467,9 @@ export class Command extends BaseCommand { return this.any.execute(args.join(" "), menu, metadata); } else { metadata.symbolicArgs.push(`"${param}"`); - return { - content: `No valid command sequence matching \`${metadata.header} ${metadata.symbolicArgs.join( - " " - )}\` found.` - }; + return `No valid command sequence matching \`${metadata.header} ${metadata.symbolicArgs.join( + " " + )}\` found.`; } // 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, menu: CommandMenu, metadata: ExecuteCommandMetadata - ): Promise { + ): Promise { // 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. if (this.permission !== -1) metadata.permission = this.permission; @@ -716,9 +692,7 @@ export class RestCommand extends BaseCommand { const errorMessage = error.stack ?? error; console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`); - return { - content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\`` - }; + return `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. // 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? if ( metadata.channelType === CHANNEL_TYPE.GUILD && (!(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 ( metadata.channelType === CHANNEL_TYPE.DM && (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.) 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? if (!hasPermission(menu.author, menu.member, metadata.permission)) { const userPermLevel = getPermissionLevel(menu.author, menu.member); - return { - content: `You don't have access to this command! Your permission level is \`${getPermissionName( - userPermLevel - )}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName( - metadata.permission - )}\` (${metadata.permission}).` - }; + return `You don't have access to this command! Your permission level is \`${getPermissionName( + userPermLevel + )}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName( + metadata.permission + )}\` (${metadata.permission}).`; } return null; diff --git a/src/core/libd.ts b/src/core/libd.ts index c9d5bcf..58338a5 100644 --- a/src/core/libd.ts +++ b/src/core/libd.ts @@ -15,7 +15,9 @@ import { MessageAdditions, SplitOptions, APIMessage, - StringResolvable + StringResolvable, + EmojiIdentifierResolvable, + MessageReaction } from "discord.js"; import {unreactEventListeners, replyEventListeners} from "./eventListeners"; import {client} from "./interface"; @@ -31,19 +33,6 @@ export type SendFunction = (( ((content: StringResolvable, options: MessageOptions & {split: true | SplitOptions}) => Promise) & ((content: StringResolvable, options: MessageOptions) => Promise); -/** - * 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 BACKWARDS_EMOJI = "⬅️"; const FORWARDS_EMOJI = "➡️"; @@ -56,34 +45,35 @@ const FIVE_FORWARDS_EMOJI = "⏩"; */ export async function paginate( send: SendFunction, - senderID: string, - total: number, - callback: (page: number, hasMultiplePages: boolean) => SingleMessageOptions, + onTurnPage: (page: number, hasMultiplePages: boolean) => SingleMessageOptions, + totalPages: number, + listenTo: string | null = null, duration = 60000 -) { - const hasMultiplePages = total > 1; - const message = await send(callback(0, hasMultiplePages)); +): Promise { + const hasMultiplePages = totalPages > 1; + const message = await send(onTurnPage(0, hasMultiplePages)); if (hasMultiplePages) { let page = 0; const turn = (amount: number) => { page += amount; - if (page >= total) { - page %= total; + 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) % total; - if (flattened !== 0) page = total - flattened; + const flattened = Math.abs(page) % totalPages; + if (flattened !== 0) page = totalPages - flattened; } - message.edit(callback(page, true)); + message.edit(onTurnPage(page, true)); }; 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) { case FIVE_BACKWARDS_EMOJI: - if (total > 5) turn(-5); + if (totalPages > 5) turn(-5); break; case BACKWARDS_EMOJI: turn(-1); @@ -92,28 +82,28 @@ export async function paginate( turn(1); break; case FIVE_FORWARDS_EMOJI: - if (total > 5) turn(5); + if (totalPages > 5) turn(5); break; } } }; // 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 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); const collector = message.createReactionCollector( (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. // 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); - collector.resetTimer(); } 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). -// This should probably be renamed to "confirm" now that I think of it, "prompt" is better used elsewhere. -// 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; +//export function generateMulti +// paginate after generateonetimeprompt - message.react("✅"); - await message.awaitReactions( - (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( +// Returns null if timed out, otherwise, returns the value. +export function generateOneTimePrompt( message: Message, - senderID: string, - condition: (reply: string) => boolean, - onSuccess: () => void, - onReject: () => string, - timeout = 60000 -) { - const referenceID = `${message.channel.id}-${message.id}`; + 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)); - replyEventListeners.set(referenceID, (reply) => { - 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 { - return new Promise(async (resolve, reject) => { - let isDeleted = false; - - await message.react("✅"); - message.react("❌"); + // Then setup the reaction listener in parallel. await message.awaitReactions( - (reaction, user) => { - if (user.id === senderID) { - const isCheckReacted = reaction.emoji.name === "✅"; + (reaction: MessageReaction, user: User) => { + if (user.id === listenTo || listenTo === null) { + const emote = reaction.emoji.name; - if (isCheckReacted || reaction.emoji.name === "❌") { - resolve(isCheckReacted); - isDeleted = true; + 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: timeout} + {time: duration} ); - if (!isDeleted) { + if (!message.deleted) { 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 { + for (const emote of emotes) { + try { + await message.react(emote); + } catch { + return; + } + } +} + +export function confirm(message: Message, senderID: string, timeout = 30000): Promise { + 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. 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( message: Message, senderID: string, - callbackStack: (() => void)[], + choices: number, timeout = 90000 -) { - if (callbackStack.length > multiNumbers.length) { - message.channel.send( - `\`ERROR: The amount of callbacks in "askMultipleChoice" must not exceed the total amount of allowed options (${multiNumbers.length})!\`` +): Promise { + if (choices > multiNumbers.length) + throw new Error( + `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 { + return new Promise((resolve) => { + const referenceID = `${message.channel.id}-${message.id}`; - for (let i = 0; i < callbackStack.length; i++) { - await message.react(multiNumbers[i]); - } - - await message.awaitReactions( - (reaction, user) => { - if (user.id === senderID) { - const index = multiNumbers.indexOf(reaction.emoji.name); - - if (index !== -1) { - callbackStack[index](); - isDeleted = true; - message.delete(); - } + replyEventListeners.set(referenceID, (reply) => { + if (reply.author.id === listenTo) { + message.delete(); + replyEventListeners.delete(referenceID); + resolve(reply); } + }); - return false; - }, - {time: timeout} - ); + if (timeout) { + client.setTimeout(() => { + 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: @@ -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. // 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); if (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 { - return { - content: `No guild found by the ID of \`${id}\`!` - }; + return `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 guild = client.guilds.cache.find((guild) => guild.name.toLowerCase().includes(query)); if (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 { - return { - content: `No guild found by the name of \`${name}\`!` - }; + return `No guild found by the name of \`${name}\`!`; } } -export async function getChannelByID(id: string): Promise { +export async function getChannelByID(id: string): Promise { try { return await client.channels.fetch(id); } 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. -export function getChannelByName(name: string): GuildChannel | SingleMessageOptions { +export function getChannelByName(name: string): GuildChannel | string { const query = name.toLowerCase(); const channel = client.channels.cache.find( (channel) => channel instanceof GuildChannel && channel.name.toLowerCase().includes(query) ) as GuildChannel | undefined; 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( channel: TextChannel | DMChannel | NewsChannel | string, id: string -): Promise { +): Promise { if (typeof channel === "string") { const targetChannel = await getChannelByID(channel); 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; } try { return await channel.messages.fetch(id); } 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 { +export async function getUserByID(id: string): Promise { try { return await client.users.fetch(id); } 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. -export function getUserByName(name: string): User | SingleMessageOptions { +export function getUserByName(name: string): User | string { let query = name.toLowerCase(); const tagMatch = /^(.+?)#(\d{4})$/.exec(name); let tag: string | null = null; @@ -366,19 +332,19 @@ export function getUserByName(name: string): User | SingleMessageOptions { }); 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 { +export async function getMemberByID(guild: Guild, id: string): Promise { try { return await guild.members.fetch(id); } 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. -export async function getMemberByName(guild: Guild, name: string): Promise { +export async function getMemberByName(guild: Guild, name: string): Promise { const member = ( await guild.members.fetch({ query: name, @@ -395,9 +361,9 @@ export async function getMemberByName(guild: Guild, name: string): Promise { const linkMessage = await getMessageByID(channelID, messageID); // 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!"); }