diff --git a/commands/calendar/nag/checkin.ts b/commands/calendar/nag/checkin.ts index 1a751be..a6525a4 100644 --- a/commands/calendar/nag/checkin.ts +++ b/commands/calendar/nag/checkin.ts @@ -1,24 +1,48 @@ -import {ChatInputCommandInteraction, SlashCommandBuilder} from 'discord.js'; +import { + type ChatInputCommandInteraction, + SlashCommandBuilder, +} from "discord.js"; -import {Settings} from './common'; +import type { Settings } from "./service"; -import {Nag, CheckIn} from './common'; +import { Nag, CheckIn } from "./service"; const data = new SlashCommandBuilder() - .setName('checkin') - .setDescription('Check-in for your daily nag') - .addStringOption(option => - option - .setName('text') - .setDescription('Optional description of what you have achieved'), - ); + .setName("checkin") + .setDescription("Check-in for your daily nag") + .addStringOption((option) => + option + .setName("text") + .setDescription("Optional description of what you have achieved"), + ); async function initialize(settings: Settings) {} -function execute(interaction: ChatInputCommandInteraction) {} +async function execute(interaction: ChatInputCommandInteraction) { + // TODO For now there is only one nag, but in the future this could be different. So let's construct it as a loop for now. + const result = await Nag.findAll({ + where: { + userId: interaction.user.id, + }, + }); + if (!result) { + await interaction.reply( + "Couldn't find any nags for you, what are you checking in for?", + ); + return; + } + for (const nag of result) { + await CheckIn.create({ + nagId: nag.id, + lastCheckIn: new Date(Date.now()), + }); + await interaction.reply("Thanks for checking in!"); + break; + } +} export default function () { - return { - data, - }; + return { + data, + }; } diff --git a/commands/calendar/nag/common.ts b/commands/calendar/nag/common.ts deleted file mode 100644 index dbb48e8..0000000 --- a/commands/calendar/nag/common.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - ChatInputCommandInteraction, - Client, - SlashCommandBuilder, - TextChannel, -} from 'discord.js'; -import { - Sequelize, - Model, - INTEGER, - STRING, - BOOLEAN, - DATE, - literal, -} 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 - loopIntervalSec?: number; // Loop interval in seconds -} - -export class Nag extends Model { - // Primary key - declare id: number; - // User who created this nag - declare userId: number; - // 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: INTEGER, - 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}, - ); - Nag.hasOne(CheckIn, {foreignKey: 'nagId'}); - await Nag.sync(); - await CheckIn.sync(); -} - -import {Guild, Channel} from 'discord.js'; - -export class Plugin { - settings: Settings; - publicChannel?: Channel; - interval: NodeJS.Timeout; - - constructor(settings: Settings) { - this.settings = settings; - } - - start() { - if (!this.settings.loopIntervalSec) { - this.settings.loopIntervalSec = 60; // 1 minute - } - this.interval = setInterval( - this.loop, - this.settings.loopIntervalSec * 1000, - ); - } - - async triggerNag(nag: Nag) { - const client = this.settings.client; - const chan = client.channels.cache.get('1234'); // TODO - if (!(chan instanceof TextChannel)) { - 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 chan.send(msg); - } catch (error) { - console.log('Error while creating Nag:', error); // TODO - } - } - - async loop() { - console.debug('nag.js main loop'); - // Find all nags where the last check-in was before (next check in) - (24 hours) - } - - stop() { - clearInterval(this.interval); - } -} diff --git a/commands/calendar/nag/nag.ts b/commands/calendar/nag/nag.ts index 62ae675..3e7afc8 100644 --- a/commands/calendar/nag/nag.ts +++ b/commands/calendar/nag/nag.ts @@ -1,79 +1,71 @@ import { - ChatInputCommandInteraction, - Client, - SlashCommandBuilder, -} from 'discord.js'; -import {Sequelize, literal} from 'sequelize'; + type ChatInputCommandInteraction, + SlashCommandBuilder, +} from "discord.js"; -import {Nag, CheckIn, Settings} from './common'; -import {Chrono} from 'chrono-node'; +import { Nag, CheckIn, type Settings } from "./service"; +import { Chrono } from "chrono-node"; const data = new SlashCommandBuilder() - .setName('nag') - .setDescription('Let Blitzcrank nag you every day about something') - .addStringOption(option => - option - .setRequired(true) - .setName('text') - .setDescription('What you have to do every day'), - ) - .addStringOption(option => - option - .setName('failText') - .setDescription('Custom message to be broadcast on failure'), - ) - .addBooleanOption(option => - option - .setName('mentionHere') - .setDescription('Whether to DM you or @ a channel') - .setRequired(false), - ); - -function lateCheckedInUsers() { - return Nag.findAll({ - include: [CheckIn], - where: literal( - "checkInTime <= datetime('now', '-1 day', 'start of day', '+9 hours')", - ), - }); -} + .setName("nag") + .setDescription("Let Blitzcrank nag you every day about something") + .addStringOption((option) => + option + .setRequired(true) + .setName("text") + .setDescription("What you have to do every day"), + ) + .addStringOption((option) => + option + .setName("failText") + .setDescription("Custom message to be broadcast on failure"), + ) + .addBooleanOption((option) => + option + .setName("mentionHere") + .setDescription("Whether to DM you or @ a channel") + .setRequired(false), + ); async function initialize(settings: Settings) {} async function execute(interaction: ChatInputCommandInteraction) { - const text = interaction.options.getString('text'); - if (text === null || text === undefined) { - await interaction.reply("Nag can't have a blank `text`, try again."); - return; - } - const nag = await Nag.create({ - userId: interaction.user.id, - text: text, - failText: interaction.options.getString('failText'), - mentionHere: interaction.options.getBoolean('mentionHere') ?? false, - }); - await nag.save(); - const chrono = new Chrono(); - const checkIn = chrono.parseDate('today at 9AM'); - if (!checkIn) { - await interaction.reply( - 'Internal error while saving your nag. Tell Drew the bot is broken!!!', - ); - return; - } - await CheckIn.create({ - nagId: nag.id, - checkIn: checkIn, - }); - await interaction.reply( - `I'll check every day at 9AM if you've completed '${text}'. If not, I'll nag you! Use /checkin to prevent a shameful callout, and /unnag to cancel.`, - ); + const text = interaction.options.getString("text"); + if (text === null || text === undefined) { + await interaction.reply("Nag can't have a blank `text`, try again."); + return; + } + const nag = await Nag.create({ + userId: interaction.user.id, + guildId: interaction.guild?.id, + channelId: interaction.channel?.id, + messageId: interaction.id, + text: text, + failText: interaction.options.getString("failText"), + mentionHere: interaction.options.getBoolean("mentionHere") ?? false, + }); + await nag.save(); + const chrono = new Chrono(); + const checkIn = chrono.parseDate("today at 9AM"); + if (!checkIn) { + await interaction.reply( + "Internal error while saving your nag. Tell Drew the bot is broken!!!", + ); + return; + } + await CheckIn.create({ + nagId: nag.id, + checkIn: checkIn, + }); + await interaction.reply( + `I'll check every day at 9AM if you've completed '${text}'. If not, I'll nag you! Use /checkin to prevent a shameful callout, and /unnag to cancel.`, + ); } export default function (settings: Settings) { - return { - data, - execute, - initialize: async () => await initialize(settings), - }; + return { + data, + execute, + initialize: async () => await initialize(settings), + }; } diff --git a/commands/calendar/nag/service.test.ts b/commands/calendar/nag/service.test.ts new file mode 100644 index 0000000..58b44c2 --- /dev/null +++ b/commands/calendar/nag/service.test.ts @@ -0,0 +1,97 @@ +import { expect, test, vi, it, describe, beforeEach, afterEach } from "vitest"; +import { + nextCheckInDate, + initAndSyncTables, + Nag, + CheckIn, + findGuiltyNags, + getCheckIn, +} from "./service"; +import { Sequelize, literal, Op } from "sequelize"; + +describe("nextCheckInDate", () => { + beforeEach(() => { + vi.useFakeTimers(); // Tell vitest to use fake timers + }); + afterEach(() => { + vi.useRealTimers(); // Reset date after test runs + }); + it("Returns 9AM if called before 9AM that day", () => { + const now = new Date(Date.now()); + let at9AM = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 9, + 0, + ); + vi.setSystemTime( + new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0), + ); + expect(nextCheckInDate()).toEqual(at9AM); + }); + it("Returns 9AM tomorrow if called after 9AM", () => { + const dayInMS = 24 * 60 * 60 * 1000; + const now = new Date(Date.now()); + const tomorrow = new Date(Date.now() + dayInMS); + let tomorrow9AM = new Date( + tomorrow.getFullYear(), + tomorrow.getMonth(), + tomorrow.getDate(), + 9, + 0, + ); + vi.setSystemTime( + new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 30), + ); + expect(nextCheckInDate()).toEqual(tomorrow9AM); + }); +}); + +describe("Finding nags without check-ins", async () => { + const sequelize = new Sequelize("sqlite://:memory:"); + const exampleNag = { + userId: "1234", + guildId: "1234", + channelId: "1234", + messageId: "1234", + text: "Example nag 1", + mentionHere: false, + }; + + await initAndSyncTables(sequelize); + + beforeEach(async () => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + await Nag.destroy({ where: {} }); + await CheckIn.destroy({ where: {} }); + vi.useRealTimers(); + }); + + it("Finds nags without any check-ins", async () => { + const now = new Date(); + vi.setSystemTime( + new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9), + ); + await Nag.create(exampleNag); + const results = await findGuiltyNags(); + expect(results.map((nag) => nag.userId)).toEqual(["1234"]); + }); + + it("Ignores nags with a recent check-in", async () => { + const newNag = await Nag.create(exampleNag); + newNag.save(); + const currentCheckInTime = getCheckIn(9, 0); + const newCheckIn = await CheckIn.create({ + nagId: newNag.id, + // 1 hour previously; i.e. we checked in before the required time + lastCheckIn: new Date(currentCheckInTime.getTime() - 60 * 60 * 1000), + }); + newCheckIn.save(); + const results = await findGuiltyNags(); + expect(results.map((nag) => nag.userId)).toEqual([]); + }); +}); diff --git a/commands/calendar/nag/service.ts b/commands/calendar/nag/service.ts new file mode 100644 index 0000000..9e48d1d --- /dev/null +++ b/commands/calendar/nag/service.ts @@ -0,0 +1,222 @@ +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); + } +} diff --git a/commands/calendar/nag/unnag.ts b/commands/calendar/nag/unnag.ts index b65548d..5f880df 100644 --- a/commands/calendar/nag/unnag.ts +++ b/commands/calendar/nag/unnag.ts @@ -1,26 +1,31 @@ import { - ChatInputCommandInteraction, - Client, - SlashCommandBuilder, -} from 'discord.js'; -import {Sequelize} from 'sequelize'; + type ChatInputCommandInteraction, + SlashCommandBuilder, +} from "discord.js"; -import {Settings} from './common'; +import { type Settings, Nag } from "./service"; const data = new SlashCommandBuilder() - .setName('unnag') - .setDescription('Remove a nag'); + .setName("unnag") + .setDescription("Remove a nag"); async function initialize(settings: Settings) {} async function execute(interaction: ChatInputCommandInteraction) { - return; + // Find all nags for this user + const results = await Nag.findAll({ + where: { userId: interaction.user.id }, + }); + for (const result of results) { + await result.destroy(); + } + return; } export default function (settings: Settings) { - return { - data, - execute, - initialize: async () => await initialize(settings), - }; + return { + data, + execute, + initialize: async () => await initialize(settings), + }; } diff --git a/index.ts b/index.ts index 279cb1a..1c22d06 100644 --- a/index.ts +++ b/index.ts @@ -1,11 +1,11 @@ -import type {Interaction} from 'discord.js'; -import {Client, Events, GatewayIntentBits, MessageFlags} from 'discord.js'; +import type { Interaction } from "discord.js"; +import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js"; import type { - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, -} from 'discord.js'; + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from "discord.js"; -import {sql, GuildSetting, initDb} from './database'; +import { sql, GuildSetting, initDb } from "./database"; const BLITZCRANK_BANNER = ` ****++++++++++*+++ @@ -63,102 +63,105 @@ const BLITZCRANK_BANNER = ` `; const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.MessageContent, - ], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.MessageContent, + ], }); -import {Routes} from 'discord.js'; -import {guildId, appId, token, remindersChannelId} from './config.json'; -import {REST} from 'discord.js'; +import { Routes } from "discord.js"; +import { guildId, appId, token, remindersChannelId } from "./config.json"; +import { REST } from "discord.js"; const rest = new REST(); rest.setToken(token); interface Command { - data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; - execute: (interaction: any) => Promise; - initialize: (any) => Promise; + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; + execute: (interaction: any) => Promise; + initialize: (any) => Promise; } -import {Collection} from 'discord.js'; +import { Collection } from "discord.js"; const commands = new Collection(); -import PingCommand from './commands/calendar/ping'; -import RemindCommand from './commands/calendar/remind'; -import QuoteCommand from './commands/quotes/quote'; +import PingCommand from "./commands/calendar/ping"; +import RemindCommand from "./commands/calendar/remind"; +import QuoteCommand from "./commands/quotes/quote"; +import NagCommand from "./commands/calendar/nag/nag"; +import UnnagCommand from "./commands/calendar/nag/unnag"; +import CheckinCommand from "./commands/calendar/nag/checkin"; console.debug(`${remindersChannelId}`); -commands.set('ping', PingCommand({client: client, db: sql})); +commands.set("ping", PingCommand({ client: client, db: sql })); commands.set( - 'remind', - RemindCommand({ - client: client, - db: sql, - publicChannel: remindersChannelId, - responseMode: 'public', - }), + "remind", + RemindCommand({ + client: client, + db: sql, + publicChannel: remindersChannelId, + responseMode: "public", + }), ); -commands.set('quote', QuoteCommand({})); +commands.set("quote", QuoteCommand({})); async function syncCommands() { - try { - console.log(`Started refreshing slash commands`); - const _data = await rest.put( - Routes.applicationGuildCommands(appId, guildId), - { - body: commands.mapValues(cmd => cmd.data.toJSON()), - }, - ); - console.log(`Successfully reloaded slash commands`); - } catch (error) { - console.error(error); - } + try { + console.log(`Started refreshing slash commands`); + const _data = await rest.put( + Routes.applicationGuildCommands(appId, guildId), + { + body: commands.mapValues((cmd) => cmd.data.toJSON()), + }, + ); + console.log(`Successfully reloaded slash commands`); + } catch (error) { + console.error(error); + } } client.on(Events.InteractionCreate, async (interaction: Interaction) => { - if (!interaction.isChatInputCommand()) { - return; - } - try { - const command = commands.get(interaction.commandName); - if (command == null) { - await interaction.followUp(`No such command ${interaction.commandName}`); - return; - } - command.execute(interaction); - } catch (error) { - console.error(error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command', - flags: MessageFlags.Ephemeral, - }); - } - } - // TODO + if (!interaction.isChatInputCommand()) { + return; + } + try { + const command = commands.get(interaction.commandName); + if (command == null) { + await interaction.followUp(`No such command ${interaction.commandName}`); + return; + } + command.execute(interaction); + } catch (error) { + console.error(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: "There was an error while executing this command", + flags: MessageFlags.Ephemeral, + }); + } + } + // TODO }); -client.once(Events.ClientReady, async readyClient => { - await syncCommands(); - initDb(); // TODO - GuildSetting.sync(); // TODO +client.once(Events.ClientReady, async (readyClient) => { + await syncCommands(); + initDb(); // TODO + GuildSetting.sync(); // TODO - for (const [_name, cmd] of commands) { - await cmd?.initialize({ - client: client, - db: sql, - }); - } - // Print banner - for (const ln of BLITZCRANK_BANNER.split('\n')) { - console.log(ln); - } - console.log(`Logged in as ${readyClient.user.tag}`); + for (const [_name, cmd] of commands) { + await cmd?.initialize({ + client: client, + db: sql, + }); + } + // Print banner + for (const ln of BLITZCRANK_BANNER.split("\n")) { + console.log(ln); + } + console.log(`Logged in as ${readyClient.user.tag}`); }); client.login(token);