Implement Nag service
This commit is contained in:
@@ -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()
|
const data = new SlashCommandBuilder()
|
||||||
.setName('checkin')
|
.setName("checkin")
|
||||||
.setDescription('Check-in for your daily nag')
|
.setDescription("Check-in for your daily nag")
|
||||||
.addStringOption(option =>
|
.addStringOption((option) =>
|
||||||
option
|
option
|
||||||
.setName('text')
|
.setName("text")
|
||||||
.setDescription('Optional description of what you have achieved'),
|
.setDescription("Optional description of what you have achieved"),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function initialize(settings: Settings) {}
|
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 () {
|
export default function () {
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +1,71 @@
|
|||||||
import {
|
import {
|
||||||
ChatInputCommandInteraction,
|
type ChatInputCommandInteraction,
|
||||||
Client,
|
SlashCommandBuilder,
|
||||||
SlashCommandBuilder,
|
} from "discord.js";
|
||||||
} from 'discord.js';
|
|
||||||
import {Sequelize, literal} from 'sequelize';
|
|
||||||
|
|
||||||
import {Nag, CheckIn, Settings} from './common';
|
import { Nag, CheckIn, type Settings } from "./service";
|
||||||
import {Chrono} from 'chrono-node';
|
import { Chrono } from "chrono-node";
|
||||||
|
|
||||||
const data = new SlashCommandBuilder()
|
const data = new SlashCommandBuilder()
|
||||||
.setName('nag')
|
.setName("nag")
|
||||||
.setDescription('Let Blitzcrank nag you every day about something')
|
.setDescription("Let Blitzcrank nag you every day about something")
|
||||||
.addStringOption(option =>
|
.addStringOption((option) =>
|
||||||
option
|
option
|
||||||
.setRequired(true)
|
.setRequired(true)
|
||||||
.setName('text')
|
.setName("text")
|
||||||
.setDescription('What you have to do every day'),
|
.setDescription("What you have to do every day"),
|
||||||
)
|
)
|
||||||
.addStringOption(option =>
|
.addStringOption((option) =>
|
||||||
option
|
option
|
||||||
.setName('failText')
|
.setName("failText")
|
||||||
.setDescription('Custom message to be broadcast on failure'),
|
.setDescription("Custom message to be broadcast on failure"),
|
||||||
)
|
)
|
||||||
.addBooleanOption(option =>
|
.addBooleanOption((option) =>
|
||||||
option
|
option
|
||||||
.setName('mentionHere')
|
.setName("mentionHere")
|
||||||
.setDescription('Whether to DM you or @ a channel')
|
.setDescription("Whether to DM you or @ a channel")
|
||||||
.setRequired(false),
|
.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 initialize(settings: Settings) {}
|
||||||
|
|
||||||
async function execute(interaction: ChatInputCommandInteraction) {
|
async function execute(interaction: ChatInputCommandInteraction) {
|
||||||
const text = interaction.options.getString('text');
|
const text = interaction.options.getString("text");
|
||||||
if (text === null || text === undefined) {
|
if (text === null || text === undefined) {
|
||||||
await interaction.reply("Nag can't have a blank `text`, try again.");
|
await interaction.reply("Nag can't have a blank `text`, try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nag = await Nag.create({
|
const nag = await Nag.create({
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
text: text,
|
guildId: interaction.guild?.id,
|
||||||
failText: interaction.options.getString('failText'),
|
channelId: interaction.channel?.id,
|
||||||
mentionHere: interaction.options.getBoolean('mentionHere') ?? false,
|
messageId: interaction.id,
|
||||||
});
|
text: text,
|
||||||
await nag.save();
|
failText: interaction.options.getString("failText"),
|
||||||
const chrono = new Chrono();
|
mentionHere: interaction.options.getBoolean("mentionHere") ?? false,
|
||||||
const checkIn = chrono.parseDate('today at 9AM');
|
});
|
||||||
if (!checkIn) {
|
await nag.save();
|
||||||
await interaction.reply(
|
const chrono = new Chrono();
|
||||||
'Internal error while saving your nag. Tell Drew the bot is broken!!!',
|
const checkIn = chrono.parseDate("today at 9AM");
|
||||||
);
|
if (!checkIn) {
|
||||||
return;
|
await interaction.reply(
|
||||||
}
|
"Internal error while saving your nag. Tell Drew the bot is broken!!!",
|
||||||
await CheckIn.create({
|
);
|
||||||
nagId: nag.id,
|
return;
|
||||||
checkIn: checkIn,
|
}
|
||||||
});
|
await CheckIn.create({
|
||||||
await interaction.reply(
|
nagId: nag.id,
|
||||||
`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.`,
|
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) {
|
export default function (settings: Settings) {
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
execute,
|
execute,
|
||||||
initialize: async () => await initialize(settings),
|
initialize: async () => await initialize(settings),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
97
commands/calendar/nag/service.test.ts
Normal file
97
commands/calendar/nag/service.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
222
commands/calendar/nag/service.ts
Normal file
222
commands/calendar/nag/service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
ChatInputCommandInteraction,
|
type ChatInputCommandInteraction,
|
||||||
Client,
|
SlashCommandBuilder,
|
||||||
SlashCommandBuilder,
|
} from "discord.js";
|
||||||
} from 'discord.js';
|
|
||||||
import {Sequelize} from 'sequelize';
|
|
||||||
|
|
||||||
import {Settings} from './common';
|
import { type Settings, Nag } from "./service";
|
||||||
|
|
||||||
const data = new SlashCommandBuilder()
|
const data = new SlashCommandBuilder()
|
||||||
.setName('unnag')
|
.setName("unnag")
|
||||||
.setDescription('Remove a nag');
|
.setDescription("Remove a nag");
|
||||||
|
|
||||||
async function initialize(settings: Settings) {}
|
async function initialize(settings: Settings) {}
|
||||||
|
|
||||||
async function execute(interaction: ChatInputCommandInteraction) {
|
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) {
|
export default function (settings: Settings) {
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
execute,
|
execute,
|
||||||
initialize: async () => await initialize(settings),
|
initialize: async () => await initialize(settings),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
159
index.ts
159
index.ts
@@ -1,11 +1,11 @@
|
|||||||
import type {Interaction} from 'discord.js';
|
import type { Interaction } from "discord.js";
|
||||||
import {Client, Events, GatewayIntentBits, MessageFlags} from 'discord.js';
|
import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js";
|
||||||
import type {
|
import type {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
SlashCommandOptionsOnlyBuilder,
|
SlashCommandOptionsOnlyBuilder,
|
||||||
} from 'discord.js';
|
} from "discord.js";
|
||||||
|
|
||||||
import {sql, GuildSetting, initDb} from './database';
|
import { sql, GuildSetting, initDb } from "./database";
|
||||||
|
|
||||||
const BLITZCRANK_BANNER = `
|
const BLITZCRANK_BANNER = `
|
||||||
****++++++++++*+++
|
****++++++++++*+++
|
||||||
@@ -63,102 +63,105 @@ const BLITZCRANK_BANNER = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
import {Routes} from 'discord.js';
|
import { Routes } from "discord.js";
|
||||||
import {guildId, appId, token, remindersChannelId} from './config.json';
|
import { guildId, appId, token, remindersChannelId } from "./config.json";
|
||||||
import {REST} from 'discord.js';
|
import { REST } from "discord.js";
|
||||||
|
|
||||||
const rest = new REST();
|
const rest = new REST();
|
||||||
rest.setToken(token);
|
rest.setToken(token);
|
||||||
|
|
||||||
interface Command {
|
interface Command {
|
||||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder;
|
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder;
|
||||||
execute: (interaction: any) => Promise<void>;
|
execute: (interaction: any) => Promise<void>;
|
||||||
initialize: (any) => Promise<void>;
|
initialize: (any) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
import {Collection} from 'discord.js';
|
import { Collection } from "discord.js";
|
||||||
const commands = new Collection<string, Command>();
|
const commands = new Collection<string, Command>();
|
||||||
|
|
||||||
import PingCommand from './commands/calendar/ping';
|
import PingCommand from "./commands/calendar/ping";
|
||||||
import RemindCommand from './commands/calendar/remind';
|
import RemindCommand from "./commands/calendar/remind";
|
||||||
import QuoteCommand from './commands/quotes/quote';
|
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}`);
|
console.debug(`${remindersChannelId}`);
|
||||||
|
|
||||||
commands.set('ping', PingCommand({client: client, db: sql}));
|
commands.set("ping", PingCommand({ client: client, db: sql }));
|
||||||
commands.set(
|
commands.set(
|
||||||
'remind',
|
"remind",
|
||||||
RemindCommand({
|
RemindCommand({
|
||||||
client: client,
|
client: client,
|
||||||
db: sql,
|
db: sql,
|
||||||
publicChannel: remindersChannelId,
|
publicChannel: remindersChannelId,
|
||||||
responseMode: 'public',
|
responseMode: "public",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
commands.set('quote', QuoteCommand({}));
|
commands.set("quote", QuoteCommand({}));
|
||||||
|
|
||||||
async function syncCommands() {
|
async function syncCommands() {
|
||||||
try {
|
try {
|
||||||
console.log(`Started refreshing slash commands`);
|
console.log(`Started refreshing slash commands`);
|
||||||
const _data = await rest.put(
|
const _data = await rest.put(
|
||||||
Routes.applicationGuildCommands(appId, guildId),
|
Routes.applicationGuildCommands(appId, guildId),
|
||||||
{
|
{
|
||||||
body: commands.mapValues(cmd => cmd.data.toJSON()),
|
body: commands.mapValues((cmd) => cmd.data.toJSON()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
console.log(`Successfully reloaded slash commands`);
|
console.log(`Successfully reloaded slash commands`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
||||||
if (!interaction.isChatInputCommand()) {
|
if (!interaction.isChatInputCommand()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const command = commands.get(interaction.commandName);
|
const command = commands.get(interaction.commandName);
|
||||||
if (command == null) {
|
if (command == null) {
|
||||||
await interaction.followUp(`No such command ${interaction.commandName}`);
|
await interaction.followUp(`No such command ${interaction.commandName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
command.execute(interaction);
|
command.execute(interaction);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
await interaction.followUp({
|
await interaction.followUp({
|
||||||
content: 'There was an error while executing this command',
|
content: "There was an error while executing this command",
|
||||||
flags: MessageFlags.Ephemeral,
|
flags: MessageFlags.Ephemeral,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
client.once(Events.ClientReady, async readyClient => {
|
client.once(Events.ClientReady, async (readyClient) => {
|
||||||
await syncCommands();
|
await syncCommands();
|
||||||
initDb(); // TODO
|
initDb(); // TODO
|
||||||
GuildSetting.sync(); // TODO
|
GuildSetting.sync(); // TODO
|
||||||
|
|
||||||
for (const [_name, cmd] of commands) {
|
for (const [_name, cmd] of commands) {
|
||||||
await cmd?.initialize({
|
await cmd?.initialize({
|
||||||
client: client,
|
client: client,
|
||||||
db: sql,
|
db: sql,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Print banner
|
// Print banner
|
||||||
for (const ln of BLITZCRANK_BANNER.split('\n')) {
|
for (const ln of BLITZCRANK_BANNER.split("\n")) {
|
||||||
console.log(ln);
|
console.log(ln);
|
||||||
}
|
}
|
||||||
console.log(`Logged in as ${readyClient.user.tag}`);
|
console.log(`Logged in as ${readyClient.user.tag}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.login(token);
|
client.login(token);
|
||||||
|
|||||||
Reference in New Issue
Block a user