import { TextChannel } from 'discord.js'; import { Op } from 'sequelize'; import { BasePlugin, type Command, type Settings } from '../../plugin' import CheckinCommand from './checkin' import checkin from './checkin'; import { CheckIn, initializeModels, Nag } from './models' import NagCommand from './nag' import UnnagCommand from './unnag' /** * * @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(); const guiltyNags: Nag[] = []; const prevCheckIn = getCheckIn(9, -1); const currentCheckIn = getCheckIn(9, 0); for (const nag of results) { const checkInResults = await CheckIn.findAll({ where: { id: 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 NagPlugin extends BasePlugin { // 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.) interval?: NodeJS.Timeout; constructor(settings: Settings) { super(settings); initializeModels(settings.database); this.commands = [ CheckinCommand(settings) as Command, // TODO Why, TS?... NagCommand(settings) as Command, // TODO Why, TS?... UnnagCommand(settings), ] } async 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()); } async stop() { clearInterval(this.interval); } }