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 { // 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( { lastCheckIn: { type: DATE, allowNull: false, }, }, {sequelize}, ); Nag.hasMany(CheckIn); CheckIn.belongsTo(Nag); 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(); 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; initAndSyncTables(this.settings.db); } 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); } }