diff --git a/src/commands/utilities/time.ts b/src/commands/utilities/time.ts new file mode 100644 index 0000000..de4fe12 --- /dev/null +++ b/src/commands/utilities/time.ts @@ -0,0 +1,293 @@ +import Command from "../../core/command"; +import {Storage} from "../../core/structures"; +import {User} from "discord.js"; +import moment from "moment"; + +const DATE_FORMAT = "D MMMM YYYY"; +const TIME_FORMAT = "HH:mm:ss"; +type DST = "na" | "eu" | "sh"; + +const DAYLIGHT_SAVINGS_REGIONS: {[region in DST]: string} = { + na: "North America", + eu: "Europe", + sh: "Southern Hemisphere" +}; + +const DST_NOTE_INFO = `*Note: To make things simple, the way the bot will handle specific points in time when switching Daylight Savings is just to switch at UTC 00:00, ignoring local timezones. After all, there's no need to get this down to the exact hour.* + +North America +- Starts: 2nd Sunday March +- Ends: 1st Sunday November + +Europe +- Starts: Last Sunday March +- Ends: Last Sunday October + +Southern Hemisphere +- Starts: 1st Sunday of October +- Ends: 1st Sunday of April`; + +const DST_NOTE_SETUP = `Which daylight savings region most closely matches your own? + +North America (1️⃣) +- Starts: 2nd Sunday March +- Ends: 1st Sunday November + +Europe (2️⃣) +- Starts: Last Sunday March +- Ends: Last Sunday October + +Southern Hemisphere (3️⃣) +- Starts: 1st Sunday of October +- Ends: 1st Sunday of April`; + +const DAYS_OF_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +// Returns an integer of the specific day the Sunday falls on, -1 if not found +// Also modifies the date object to the specified day as a side effect +function getSunday(date: Date, order: number) { + const daysInCurrentMonth = DAYS_OF_MONTH[date.getUTCMonth()]; + let occurrencesLeft = order - 1; + + // Search for the last Sunday of the month + if (order === 0) { + for (let day = daysInCurrentMonth; day >= 1; day--) { + date.setUTCDate(day); + + if (date.getUTCDay() === 0) { + return day; + } + } + } else if (order > 0) { + for (let day = 1; day <= daysInCurrentMonth; day++) { + date.setUTCDate(day); + + if (date.getUTCDay() === 0) { + if (occurrencesLeft > 0) { + occurrencesLeft--; + } else { + return day; + } + } + } + } + + return -1; +} + +// region: [firstMonth (0-11), firstOrder, secondMonth (0-11), secondOrder] +const DST_REGION_TABLE = { + na: [2, 2, 10, 1], + eu: [2, 0, 9, 0], + sh: [3, 1, 9, 1] // this one is reversed for the sake of code simplicity +}; + +// capturing: northern hemisphere is concave, southern hemisphere is convex +function hasDaylightSavings(region: DST) { + const [firstMonth, firstOrder, secondMonth, secondOrder] = DST_REGION_TABLE[region]; + const date = new Date(); + const now = date.getTime(); + const currentYear = date.getUTCFullYear(); + const firstDate = new Date(Date.UTC(currentYear, firstMonth)); + const secondDate = new Date(Date.UTC(currentYear, secondMonth)); + getSunday(firstDate, firstOrder); + getSunday(secondDate, secondOrder); + const insideBounds = now >= firstDate.getTime() && now < secondDate.getTime(); + return region !== "sh" ? insideBounds : !insideBounds; +} + +function getTimeEmbed(user: User) { + const {timezone, daylightSavingsRegion} = Storage.getUser(user.id); + let localDate = "N/A"; + let localTime = "N/A"; + let timezoneOffset = "N/A"; + + if (timezone !== null) { + const daylightSavingsOffset = daylightSavingsRegion && hasDaylightSavings(daylightSavingsRegion) ? 1 : 0; + const daylightTimezone = timezone + daylightSavingsOffset; + const now = moment().utcOffset(daylightTimezone * 60); + localDate = now.format(DATE_FORMAT); + localTime = now.format(TIME_FORMAT); + timezoneOffset = daylightTimezone > 0 ? `+${daylightTimezone}` : daylightTimezone.toString(); + } + + const embed = { + embed: { + color: 0x000080, + author: { + name: user.username, + icon_url: user.displayAvatarURL({ + format: "png", + dynamic: true + }) + }, + fields: [ + { + name: "Local Date", + value: localDate + }, + { + name: "Local Time", + value: localTime + }, + { + name: timezone !== null ? "Current Timezone Offset" : "Timezone Offset", + value: timezoneOffset + }, + { + name: "Observes Daylight Savings?", + value: daylightSavingsRegion ? "Yes" : "No" + } + ] + } + }; + + if (daylightSavingsRegion) { + embed.embed.fields.push( + { + name: "Daylight Savings Active?", + value: hasDaylightSavings(daylightSavingsRegion) ? "Yes" : "No" + }, + { + name: "Daylight Savings Region", + value: DAYLIGHT_SAVINGS_REGIONS[daylightSavingsRegion] + } + ); + } + + return embed; +} + +export default new Command({ + description: "Show others what time it is for you.", + async run({channel, author}) { + channel.send(getTimeEmbed(author)); + }, + subcommands: { + setup: new Command({ + description: "Registers your timezone information for the bot.", + async run({author, channel, ask, askYesOrNo, askMultipleChoice, prompt}) { + const profile = Storage.getUser(author.id); + + ask( + await channel.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) => { + const hour = parseInt(reply); + + if (isNaN(hour)) { + return false; + } + + const isValidHour = hour >= 0 && hour <= 23; + + if (isValidHour) { + const date = new Date(); + profile.timezone = hour - date.getUTCHours(); + } + + return isValidHour; + }, + async () => { + askYesOrNo( + await channel.send("Does your timezone change based on daylight savings?"), + author.id, + async (hasDST) => { + const finalize = () => { + Storage.save(); + channel.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 as number)--; + } + + finalize(); + }; + + askMultipleChoice(await channel.send(DST_NOTE_SETUP), author.id, [ + () => finalizeDST("na"), + () => finalizeDST("eu"), + () => finalizeDST("sh") + ]); + } else { + finalize(); + } + } + ); + }, + () => { + return "you need to enter in a valid integer between 0 to 23"; + } + ); + } + }), + delete: new Command({ + description: "Delete your timezone information.", + async run({channel, author, prompt}) { + prompt( + await channel.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(); + } + ); + } + }), + utc: new Command({ + description: "Displays UTC time.", + async run({channel}) { + const time = moment().utc(); + + channel.send({ + embed: { + color: 0x000080, + fields: [ + { + name: "Local Date", + value: time.format(DATE_FORMAT) + }, + { + name: "Local Time", + value: time.format(TIME_FORMAT) + } + ] + } + }); + } + }), + daylight: new Command({ + description: "Provides information on the daylight savings region", + run: DST_NOTE_INFO + }) + }, + user: new Command({ + description: "See what time it is for someone else.", + async run({channel, args}) { + channel.send(getTimeEmbed(args[0])); + } + }), + any: new Command({ + description: "See what time it is for someone else (by their username).", + async run({channel, args, message, callMemberByUsername}) { + callMemberByUsername(message, args.join(" "), (member) => { + channel.send(getTimeEmbed(member.user)); + }); + } + }) +}); diff --git a/src/core/lib.ts b/src/core/lib.ts index 0c44fbc..f8efea6 100644 --- a/src/core/lib.ts +++ b/src/core/lib.ts @@ -38,6 +38,20 @@ export interface CommonLibrary { username: string, onSuccess: (member: GuildMember) => void ) => Promise; + ask: ( + message: Message, + senderID: string, + condition: (reply: string) => boolean, + onSuccess: () => void, + onReject: () => string, + timeout?: number + ) => void; + askYesOrNo: (message: Message, senderID: string, onSuccess: (condition: boolean) => void, timeout?: number) => void; + askMultipleChoice: ( + message: Message, + senderID: string, + callbackStack: (() => void)[] | ((choice: number) => void) + ) => void; // Dynamic Properties // args: any[]; @@ -237,6 +251,8 @@ $.paginate = async ( }; // 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? $.prompt = async (message: Message, senderID: string, onConfirm: () => void, duration = 10000) => { let isDeleted = false; @@ -244,9 +260,11 @@ $.prompt = async (message: Message, senderID: string, onConfirm: () => void, dur await message.awaitReactions( (reaction, user) => { if (user.id === senderID) { - if (reaction.emoji.name === "✅") onConfirm(); - isDeleted = true; - message.delete(); + if (reaction.emoji.name === "✅") { + onConfirm(); + isDeleted = true; + message.delete(); + } } // CollectorFilter requires a boolean to be returned. @@ -261,6 +279,98 @@ $.prompt = async (message: Message, senderID: string, onConfirm: () => void, dur if (!isDeleted) message.delete(); }; +// A list of "channel-message" and callback pairs. Also, I imagine that the callback will be much more maintainable when discord.js v13 comes out with a dedicated message.referencedMessage property. +// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there. +export const replyEventListeners = new Map void>(); + +// 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. +$.ask = async ( + message: Message, + senderID: string, + condition: (reply: string) => boolean, + onSuccess: () => void, + onReject: () => string, + timeout = 60000 +) => { + const referenceID = `${message.channel.id}-${message.id}`; + + 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); +}; + +$.askYesOrNo = async (message: Message, senderID: string, onSuccess: (condition: boolean) => void, timeout = 30000) => { + let isDeleted = false; + + await message.react("✅"); + message.react("❌"); + await message.awaitReactions( + (reaction, user) => { + if (user.id === senderID) { + const isCheckReacted = reaction.emoji.name === "✅"; + + if (isCheckReacted || reaction.emoji.name === "❌") { + onSuccess(isCheckReacted); + isDeleted = true; + message.delete(); + } + } + + return false; + }, + {time: timeout} + ); + + if (!isDeleted) message.delete(); +}; + +// 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️⃣", "🔟"]; + +// This will bring up an option to let the user choose between one option out of many. +$.askMultipleChoice = async (message: Message, senderID: string, callbackStack: (() => void)[], timeout = 90000) => { + let isDeleted = false; + + 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(); + } + } + + return false; + }, + {time: timeout} + ); + + if (!isDeleted) message.delete(); +}; + $.getMemberByUsername = async (guild: Guild, username: string) => { return ( await guild.members.fetch({ diff --git a/src/core/structures.ts b/src/core/structures.ts index 0d78e13..b37e169 100644 --- a/src/core/structures.ts +++ b/src/core/structures.ts @@ -24,11 +24,17 @@ class User { public money: number; public lastReceived: number; public lastMonday: number; + public timezone: number | null; // This is for the standard timezone only, not the daylight savings timezone + public daylightSavingsRegion: "na" | "eu" | "sh" | null; constructor(data?: GenericJSON) { this.money = select(data?.money, 0, Number); this.lastReceived = select(data?.lastReceived, -1, Number); this.lastMonday = select(data?.lastMonday, -1, Number); + this.timezone = data?.timezone ?? null; + this.daylightSavingsRegion = /^((na)|(eu)|(sh))$/.test(data?.daylightSavingsRegion) + ? data?.daylightSavingsRegion + : null; } } diff --git a/src/events/message.ts b/src/events/message.ts index 558208e..00e6458 100644 --- a/src/events/message.ts +++ b/src/events/message.ts @@ -3,7 +3,7 @@ import Command, {loadCommands} from "../core/command"; import {hasPermission, getPermissionLevel, PermissionNames} from "../core/permissions"; import {Permissions, Collection} from "discord.js"; import {getPrefix} from "../core/structures"; -import $ from "../core/lib"; +import $, {replyEventListeners} from "../core/lib"; // It's a rather hacky solution, but since there's no top-level await, I just have to make the loading conditional. let commands: Collection | null = null; @@ -16,9 +16,15 @@ export default new Event<"message">({ // Message Setup // if (message.author.bot) return; + // If there's an inline reply, fire off that event listener (if it exists). + if (message.reference) { + const reference = message.reference; + replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message); + } + const prefix = getPrefix(message.guild); - if (!message.content.startsWith(prefix)) { + if (!message.content.startsWith(prefix) && !message.reference) { if (message.client.user && message.mentions.has(message.client.user)) message.channel.send(`${message.author.toString()}, my prefix on this guild is \`${prefix}\`.`); return;