Added time command for user-submitted timezones

This commit is contained in:
WatDuhHekBro 2021-01-24 08:07:58 -06:00
parent eec6aa7b96
commit 7b4d8b934c
4 changed files with 420 additions and 5 deletions

View File

@ -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));
});
}
})
});

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, 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<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 = 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({

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;
}
}

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,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;