223 lines
5.7 KiB
TypeScript
223 lines
5.7 KiB
TypeScript
import { type Client, TextChannel } from "discord.js";
|
|
import {
|
|
type Sequelize,
|
|
Model,
|
|
INTEGER,
|
|
STRING,
|
|
BOOLEAN,
|
|
DATE,
|
|
literal,
|
|
Op,
|
|
} from "sequelize";
|
|
|
|
export interface Settings {
|
|
client: Client; // Main Discord client object
|
|
db: Sequelize; // Database access object
|
|
publicChannel?: string; // Channel to use if a reminder is public
|
|
}
|
|
|
|
export class Nag extends Model {
|
|
// Primary key
|
|
declare id: number;
|
|
// User who created this nag
|
|
declare userId: string;
|
|
// Guild (server) of original nag request
|
|
declare guildId: string;
|
|
// Channel of original nag request
|
|
declare channelId: string;
|
|
// Message of original nag request
|
|
declare messageId: string;
|
|
// Description of what you're supposed to do
|
|
declare text: string;
|
|
// Custom failure text
|
|
declare failText?: string;
|
|
// Should we @here?
|
|
declare mentionHere?: boolean;
|
|
}
|
|
|
|
export class CheckIn extends Model {
|
|
declare nagId: string;
|
|
// Date of the last time user ran /checkin
|
|
declare lastCheckIn: Date;
|
|
}
|
|
|
|
export async function initAndSyncTables(sequelize: Sequelize) {
|
|
Nag.init(
|
|
{
|
|
id: {
|
|
primaryKey: true,
|
|
type: INTEGER,
|
|
autoIncrement: true,
|
|
},
|
|
userId: {
|
|
type: STRING,
|
|
allowNull: false,
|
|
},
|
|
text: {
|
|
type: STRING,
|
|
allowNull: false,
|
|
},
|
|
failText: {
|
|
type: STRING,
|
|
},
|
|
mentionHere: {
|
|
type: BOOLEAN,
|
|
},
|
|
},
|
|
{ sequelize },
|
|
);
|
|
CheckIn.init(
|
|
{
|
|
nagId: {
|
|
type: INTEGER,
|
|
allowNull: false,
|
|
},
|
|
lastCheckIn: {
|
|
type: DATE,
|
|
allowNull: false,
|
|
},
|
|
},
|
|
{ sequelize },
|
|
);
|
|
CheckIn.hasOne(Nag, { foreignKey: "nagId" });
|
|
await Nag.sync();
|
|
await CheckIn.sync();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param hour Number between 0-24 representing the hour the check-in is performed.
|
|
* @param offset Number of days from today; this can be positive for future days or negative for past days.
|
|
* @returns Date object representing the check-in time
|
|
*/
|
|
export function getCheckIn(hour: number, offset: number = 0) {
|
|
const nDaysInMS = offset * 24 * 60 * 60 * 1000;
|
|
const day = new Date(Date.now() + nDaysInMS);
|
|
const checkInDate = new Date(
|
|
day.getFullYear(),
|
|
day.getMonth(),
|
|
day.getDate(),
|
|
hour,
|
|
);
|
|
return checkInDate;
|
|
}
|
|
|
|
export async function findGuiltyNags() {
|
|
const results = await Nag.findAll({ where: {} });
|
|
const guiltyNags: Nag[] = [];
|
|
const prevCheckIn = getCheckIn(9, -1);
|
|
const currentCheckIn = getCheckIn(9, 0);
|
|
|
|
for (const nag of results) {
|
|
console.log("Checking nag: ", nag.id);
|
|
const checkInResults = await CheckIn.findAll({
|
|
where: {
|
|
nagId: nag.id,
|
|
lastCheckIn: {
|
|
[Op.between]: [prevCheckIn, currentCheckIn],
|
|
},
|
|
},
|
|
});
|
|
if (checkInResults.length <= 0) {
|
|
guiltyNags.push(nag);
|
|
}
|
|
}
|
|
return guiltyNags;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns The Date representing the next check-in, which will be either
|
|
* today (if we are asking in the morning) or tomorrow (if we are asking
|
|
* after the check-in time for today.)
|
|
*/
|
|
export function nextCheckInDate() {
|
|
const todaysCheckInDate = getCheckIn(9, 0);
|
|
if (Date.now() - todaysCheckInDate.getTime() < 0) {
|
|
return getCheckIn(9, 0);
|
|
} else {
|
|
return getCheckIn(9, 1);
|
|
}
|
|
}
|
|
|
|
function nextCheckInMs() {
|
|
const delayMs = nextCheckInDate().getTime() - Date.now();
|
|
if (delayMs <= 0) {
|
|
// The value of nextCheckInDate is guaranteed to be in the future; if not, that's a bug in the program.
|
|
throw Error("Invalid value for nextCheckInDate");
|
|
}
|
|
return delayMs;
|
|
}
|
|
|
|
/**
|
|
* Handle state for the various nag-related commands, mainly related to
|
|
* tracking of timers and triggers.
|
|
*/
|
|
export class Manager {
|
|
// NOTE(DWM): I really hate the word 'Manager' when applied to code, but I
|
|
// thought for quite a bit about what to name these classes and I just can't
|
|
// come up with anything solid. (Pun intended.)
|
|
|
|
settings: Settings;
|
|
interval?: NodeJS.Timeout;
|
|
|
|
constructor(settings: Settings) {
|
|
this.settings = settings;
|
|
}
|
|
|
|
start() {
|
|
this.interval = setTimeout(this.loop, nextCheckInMs());
|
|
}
|
|
|
|
async triggerNag(nag: Nag) {
|
|
const client = this.settings.client;
|
|
// First, try and find the appropriate channel to send on
|
|
const guild = client.guilds.cache.get(nag.guildId);
|
|
if (!guild) {
|
|
console.error(
|
|
`No longer have access to Guild ${nag.guildId}; consider deleting this nag manually :(`,
|
|
);
|
|
return;
|
|
}
|
|
const channel = guild.channels.cache.get(nag.channelId);
|
|
if (!channel) {
|
|
console.error(
|
|
`No longer have access to Channel ${nag.channelId}; consider deleting this nag manually :(`,
|
|
);
|
|
return;
|
|
}
|
|
if (!channel?.isSendable() || !(channel instanceof TextChannel)) {
|
|
console.error("Somehow, we specified a channel which isn't sendable");
|
|
return; // TODO
|
|
}
|
|
try {
|
|
const failText =
|
|
nag.failText ??
|
|
`<@${nag.userId}> didn't complete "${nag.text}". Shame shame!`;
|
|
const mentionHere = nag.mentionHere ? "<@here> " : "";
|
|
const msg = `${mentionHere}${failText}`;
|
|
await channel.send(msg);
|
|
} catch (error) {
|
|
console.log("Error while creating Nag:", error); // TODO
|
|
}
|
|
}
|
|
|
|
async loop() {
|
|
// According to MDN we don't need to do this, but it makes me feel happier
|
|
// knowing that we won't accidentally call clearInterval(...) on a timer
|
|
// that isn't running anymore.
|
|
this.interval = undefined;
|
|
|
|
console.debug("nag.js main loop");
|
|
const guiltyNags = await findGuiltyNags();
|
|
for (const nag of guiltyNags) {
|
|
await this.triggerNag(nag);
|
|
}
|
|
this.interval = setTimeout(this.loop, nextCheckInMs());
|
|
}
|
|
|
|
stop() {
|
|
clearInterval(this.interval);
|
|
}
|
|
}
|