mirror of
https://github.com/keanuplayz/TravBot-v3.git
synced 2024-08-15 02:33:12 +00:00
391 lines
17 KiB
TypeScript
391 lines
17 KiB
TypeScript
import {Command, NamedCommand, ask, askYesOrNo, askMultipleChoice, prompt, callMemberByUsername} from "../../core";
|
||
import {Storage} from "../../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 NamedCommand({
|
||
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 NamedCommand({
|
||
description: "Registers your timezone information for the bot.",
|
||
async run({author, channel}) {
|
||
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 NamedCommand({
|
||
description: "Delete your timezone information.",
|
||
async run({channel, author}) {
|
||
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 NamedCommand({
|
||
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 NamedCommand({
|
||
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(message, args.join(" "), (member) => {
|
||
channel.send(getTimeEmbed(member.user));
|
||
});
|
||
}
|
||
})
|
||
});
|