Implemented time command...

By @WatDuhHekBro
This commit is contained in:
Keanu Timmermans 2021-01-25 15:17:15 +01:00 committed by GitHub
commit 8b29163c26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 576 additions and 12 deletions

View File

@ -0,0 +1,391 @@
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 DOW_FORMAT = "dddd";
const TIME_FORMAT = "HH:mm:ss";
type DST = "na" | "eu" | "sh";
const TIME_EMBED_COLOR = 0x191970;
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 of March
- Ends: 1st Sunday of November
Europe
- Starts: Last Sunday of March
- Ends: Last Sunday of 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 of March
- Ends: 1st Sunday of November
Europe (2)
- Starts: Last Sunday of March
- Ends: Last Sunday of 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 dayOfWeek = "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);
dayOfWeek = now.format(DOW_FORMAT);
localTime = now.format(TIME_FORMAT);
timezoneOffset = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString();
}
const embed = {
embed: {
color: TIME_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
format: "png",
dynamic: true
})
},
fields: [
{
name: "Local Date",
value: localDate
},
{
name: "Day of the Week",
value: dayOfWeek
},
{
name: "Local Time",
value: localTime
},
{
name: daylightSavingsRegion !== 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.",
aliases: ["tz"],
async run({channel, author}) {
channel.send(getTimeEmbed(author));
},
subcommands: {
// Welcome to callback hell. We hope you enjoy your stay here!
setup: new Command({
description: "Registers your timezone information for the bot.",
async run({author, channel, ask, askYesOrNo, askMultipleChoice}) {
const profile = Storage.getUser(author.id);
profile.timezone = null;
profile.daylightSavingsRegion = null;
let hour: number;
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) => {
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 channel.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 channel.send("Does your timezone change based on daylight savings?"),
author.id
);
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!--;
}
finalize();
};
askMultipleChoice(await channel.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"
);
}
}),
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: TIME_EMBED_COLOR,
fields: [
{
name: "Local Date",
value: time.format(DATE_FORMAT)
},
{
name: "Day of the Week",
value: time.format(DOW_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));
});
}
})
});

View File

@ -38,6 +38,20 @@ export interface CommonLibrary {
username: string,
onSuccess: (member: GuildMember) => void
) => Promise<void>;
ask: (
message: Message,
senderID: string,
condition: (reply: string) => boolean,
onSuccess: () => void,
onReject: () => string,
timeout?: number
) => void;
askYesOrNo: (message: Message, senderID: string, timeout?: number) => Promise<boolean>;
askMultipleChoice: (
message: Message,
senderID: string,
callbackStack: (() => void)[] | ((choice: number) => void)
) => void;
// Dynamic Properties //
args: any[];
@ -182,6 +196,8 @@ export function updateGlobalEmoteRegistry(): void {
FileManager.write("emote-registry", data, true);
}
// Maybe promisify this section to reduce the potential for creating callback hell? Especially if multiple questions in a row are being asked.
// Pagination function that allows for customization via a callback.
// Define your own pages outside the function because this only manages the actual turning of pages.
$.paginate = async (
@ -237,6 +253,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 +262,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 +281,111 @@ $.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<string, (message: Message) => 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 = (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(
(reaction, user) => {
if (user.id === senderID) {
const isCheckReacted = reaction.emoji.name === "✅";
if (isCheckReacted || reaction.emoji.name === "❌") {
resolve(isCheckReacted);
isDeleted = true;
message.delete();
}
}
return false;
},
{time: timeout}
);
if (!isDeleted) {
message.delete();
reject("Prompt timed out.");
}
});
};
// 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.
// This definitely needs a single callback alternative, because using the numerical version isn't actually that uncommon of a pattern.
$.askMultipleChoice = async (message: Message, senderID: string, callbackStack: (() => void)[], 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})!\``
);
return;
}
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({

View File

@ -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;
}
}
@ -104,7 +110,18 @@ if (process.argv[2] === "dev") {
}
export function getPrefix(guild: DiscordGuild | null): string {
return Storage.getGuild(guild?.id || "N/A").prefix ?? Config.prefix;
let prefix = Config.prefix;
if (guild) {
const possibleGuildPrefix = Storage.getGuild(guild.id).prefix;
// Here, lossy comparison works in our favor because you wouldn't want an empty string to trigger the prefix.
if (possibleGuildPrefix) {
prefix = possibleGuildPrefix;
}
}
return prefix;
}
export interface EmoteRegistryDumpEntry {

View File

@ -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<string, Command> | null = null;
@ -16,16 +16,47 @@ export default new Event<"message">({
// Message Setup //
if (message.author.bot) return;
const prefix = getPrefix(message.guild);
if (!message.content.startsWith(prefix)) {
if (message.client.user && message.mentions.has(message.client.user))
message.channel.send(`${message.author.toString()}, my prefix on this guild is \`${prefix}\`.`);
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);
}
let prefix = getPrefix(message.guild);
const originalPrefix = prefix;
let exitEarly = !message.content.startsWith(prefix);
const clientUser = message.client.user;
let usesBotSpecificPrefix = false;
// If the client user exists, check if it starts with the bot-specific prefix.
if (clientUser) {
// If the prefix starts with the bot-specific prefix, go off that instead (these two options must mutually exclude each other).
// The pattern here has an optional space at the end to capture that and make it not mess with the header and args.
const matches = message.content.match(new RegExp(`^<@!?${clientUser.id}> ?`));
if (matches) {
prefix = matches[0];
exitEarly = false;
usesBotSpecificPrefix = true;
}
}
// If it doesn't start with the current normal prefix or the bot-specific unique prefix, exit the thread of execution early.
// Inline replies should still be captured here because if it doesn't exit early, two characters for a two-length prefix would still trigger commands.
if (exitEarly) return;
const [header, ...args] = message.content.substring(prefix.length).split(/ +/);
// If the message is just the prefix itself, move onto this block.
if (header === "" && args.length === 0) {
// I moved the bot-specific prefix to a separate conditional block to separate the logic.
// And because it listens for the mention as a prefix instead of a free-form mention, inline replies (probably) shouldn't ever trigger this unintentionally.
if (usesBotSpecificPrefix) {
message.channel.send(`${message.author.toString()}, my prefix on this guild is \`${originalPrefix}\`.`);
return;
}
}
if (!commands.has(header)) return;
if (
@ -60,7 +91,7 @@ export default new Event<"message">({
for (let param of args) {
if (command.endpoint) {
if (command.subcommands.size > 0 || command.user || command.number || command.any)
$.warn(`An endpoint cannot have subcommands! Check ${prefix}${header} again.`);
$.warn(`An endpoint cannot have subcommands! Check ${originalPrefix}${header} again.`);
isEndpoint = true;
break;
}