Compare commits

...

2 Commits

Author SHA1 Message Date
WatDuhHekBro d7c18d1b06 Fixed time setup accounting for differences in day 2021-01-24 22:46:48 -06:00
WatDuhHekBro 8da5ad0ca6 Addressed issue with inline replies using prefix 2021-01-24 19:12:43 -06:00
4 changed files with 227 additions and 78 deletions

View File

@ -4,8 +4,10 @@ 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",
@ -16,12 +18,12 @@ const DAYLIGHT_SAVINGS_REGIONS: {[region in DST]: string} = {
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
- Starts: 2nd Sunday of March
- Ends: 1st Sunday of November
Europe
- Starts: Last Sunday March
- Ends: Last Sunday October
- Starts: Last Sunday of March
- Ends: Last Sunday of October
Southern Hemisphere
- Starts: 1st Sunday of October
@ -30,12 +32,12 @@ Southern Hemisphere
const DST_NOTE_SETUP = `Which daylight savings region most closely matches your own?
North America (1)
- Starts: 2nd Sunday March
- Ends: 1st Sunday November
- Starts: 2nd Sunday of March
- Ends: 1st Sunday of November
Europe (2)
- Starts: Last Sunday March
- Ends: Last Sunday October
- Starts: Last Sunday of March
- Ends: Last Sunday of October
Southern Hemisphere (3)
- Starts: 1st Sunday of October
@ -99,6 +101,7 @@ function hasDaylightSavings(region: DST) {
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";
@ -107,13 +110,14 @@ function getTimeEmbed(user: User) {
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();
timezoneOffset = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString();
}
const embed = {
embed: {
color: 0x000080,
color: TIME_EMBED_COLOR,
author: {
name: user.username,
icon_url: user.displayAvatarURL({
@ -126,12 +130,16 @@ function getTimeEmbed(user: User) {
name: "Local Date",
value: localDate
},
{
name: "Day of the Week",
value: dayOfWeek
},
{
name: "Local Time",
value: localTime
},
{
name: timezone !== null ? "Current Timezone Offset" : "Timezone Offset",
name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset",
value: timezoneOffset
},
{
@ -160,14 +168,19 @@ function getTimeEmbed(user: User) {
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, prompt}) {
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(
@ -175,60 +188,141 @@ export default new Command({
),
author.id,
(reply) => {
const hour = parseInt(reply);
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;
return hour >= 0 && hour <= 23;
},
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)
);
};
// 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.
if (hasDST) {
const finalizeDST = (region: DST) => {
profile.daylightSavingsRegion = region;
// (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.
// If daylight savings is active, subtract the timezone offset by one to store the standard time.
if (hasDaylightSavings(region)) {
(profile.timezone as number)--;
}
// 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)
finalize();
};
// 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
askMultipleChoice(await channel.send(DST_NOTE_SETUP), author.id, [
() => finalizeDST("na"),
() => finalizeDST("eu"),
() => finalizeDST("sh")
]);
} else {
finalize();
// 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();
}
},
() => {
return "you need to enter in a valid integer between 0 to 23";
}
() => "you need to enter in a valid integer between 0 to 23"
);
}
}),
@ -256,12 +350,16 @@ export default new Command({
channel.send({
embed: {
color: 0x000080,
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)

View File

@ -46,7 +46,7 @@ export interface CommonLibrary {
onReject: () => string,
timeout?: number
) => void;
askYesOrNo: (message: Message, senderID: string, onSuccess: (condition: boolean) => void, timeout?: number) => void;
askYesOrNo: (message: Message, senderID: string, timeout?: number) => Promise<boolean>;
askMultipleChoice: (
message: Message,
senderID: string,
@ -196,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 (
@ -315,36 +317,49 @@ $.ask = async (
}, timeout);
};
$.askYesOrNo = async (message: Message, senderID: string, onSuccess: (condition: boolean) => void, timeout = 30000) => {
let isDeleted = false;
$.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 === "✅";
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();
if (isCheckReacted || reaction.emoji.name === "❌") {
resolve(isCheckReacted);
isDeleted = true;
message.delete();
}
}
}
return false;
},
{time: timeout}
);
return false;
},
{time: timeout}
);
if (!isDeleted) message.delete();
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++) {

View File

@ -110,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

@ -22,16 +22,41 @@ export default new Event<"message">({
replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message);
}
const prefix = getPrefix(message.guild);
let prefix = getPrefix(message.guild);
const originalPrefix = prefix;
let exitEarly = !message.content.startsWith(prefix);
const clientUser = message.client.user;
let usesBotSpecificPrefix = false;
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;
// 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 (
@ -66,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;
}