Implement Nag service

This commit is contained in:
2025-07-05 17:05:28 -04:00
parent 02c404069c
commit d4387a8264
7 changed files with 516 additions and 306 deletions

View File

@@ -1,21 +1,45 @@
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 =>
.setName("checkin")
.setDescription("Check-in for your daily nag")
.addStringOption((option) =>
option
.setName('text')
.setDescription('Optional description of what you have achieved'),
.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 {

View File

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

View File

@@ -1,63 +1,55 @@
import {
ChatInputCommandInteraction,
Client,
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import {Sequelize, literal} from 'sequelize';
} 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 =>
.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'),
.setName("text")
.setDescription("What you have to do every day"),
)
.addStringOption(option =>
.addStringOption((option) =>
option
.setName('failText')
.setDescription('Custom message to be broadcast on failure'),
.setName("failText")
.setDescription("Custom message to be broadcast on failure"),
)
.addBooleanOption(option =>
.addBooleanOption((option) =>
option
.setName('mentionHere')
.setDescription('Whether to DM you or @ a channel')
.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')",
),
});
}
async function initialize(settings: Settings) {}
async function execute(interaction: ChatInputCommandInteraction) {
const text = interaction.options.getString('text');
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,
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');
const checkIn = chrono.parseDate("today at 9AM");
if (!checkIn) {
await interaction.reply(
'Internal error while saving your nag. Tell Drew the bot is broken!!!',
"Internal error while saving your nag. Tell Drew the bot is broken!!!",
);
return;
}

View File

@@ -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([]);
});
});

View File

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

View File

@@ -1,19 +1,24 @@
import {
ChatInputCommandInteraction,
Client,
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import {Sequelize} from 'sequelize';
} 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) {
// 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;
}

View File

@@ -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';
} from "discord.js";
import {sql, GuildSetting, initDb} from './database';
import { sql, GuildSetting, initDb } from "./database";
const BLITZCRANK_BANNER = `
****++++++++++*+++
@@ -71,9 +71,9 @@ const client = new Client({
],
});
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);
@@ -84,26 +84,29 @@ interface Command {
initialize: (any) => Promise<void>;
}
import {Collection} from 'discord.js';
import { Collection } from "discord.js";
const commands = new Collection<string, Command>();
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',
"remind",
RemindCommand({
client: client,
db: sql,
publicChannel: remindersChannelId,
responseMode: 'public',
responseMode: "public",
}),
);
commands.set('quote', QuoteCommand({}));
commands.set("quote", QuoteCommand({}));
async function syncCommands() {
try {
@@ -111,7 +114,7 @@ async function syncCommands() {
const _data = await rest.put(
Routes.applicationGuildCommands(appId, guildId),
{
body: commands.mapValues(cmd => cmd.data.toJSON()),
body: commands.mapValues((cmd) => cmd.data.toJSON()),
},
);
console.log(`Successfully reloaded slash commands`);
@@ -135,7 +138,7 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: 'There was an error while executing this command',
content: "There was an error while executing this command",
flags: MessageFlags.Ephemeral,
});
}
@@ -143,7 +146,7 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
// TODO
});
client.once(Events.ClientReady, async readyClient => {
client.once(Events.ClientReady, async (readyClient) => {
await syncCommands();
initDb(); // TODO
GuildSetting.sync(); // TODO
@@ -155,7 +158,7 @@ client.once(Events.ClientReady, async readyClient => {
});
}
// Print banner
for (const ln of BLITZCRANK_BANNER.split('\n')) {
for (const ln of BLITZCRANK_BANNER.split("\n")) {
console.log(ln);
}
console.log(`Logged in as ${readyClient.user.tag}`);