Files
blitzcrank/commands/calendar/nag/service.ts
2025-07-05 17:21:24 -04:00

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