Refactor to more consistent structure (e.g. "plugins")

This commit is contained in:
2025-07-06 15:23:03 -04:00
parent a6255de889
commit 6fb38d36f3
22 changed files with 567 additions and 600 deletions

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
}
}

View File

@@ -3,8 +3,8 @@ fmt:
just format
format:
bunx --bun @biomejs/biome format --write *.ts commands/
bunx --bun @biomejs/biome format --write *.ts plugins/
lint:
bunx --bun @biomejs/biome lint --fix *.ts commands/
bunx --bun @biomejs/biome lint --fix *.ts plugins/

View File

@@ -1,50 +0,0 @@
import {
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import type {Settings} from './service';
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'),
);
async function initialize(settings: Settings) {}
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 (settings: Settings) {
return {
data,
initialize: async () => initialize(settings),
execute: execute,
};
}

View File

@@ -1,31 +0,0 @@
import {
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import {type Settings, Nag} from './service';
const data = new SlashCommandBuilder()
.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;
}
export default function (settings: Settings) {
return {
data,
execute,
initialize: async () => await initialize(settings),
};
}

View File

@@ -1,18 +0,0 @@
import {type Client, SlashCommandBuilder} from 'discord.js';
import type {Sequelize} from 'sequelize';
export default function (settings: {client: Client; db: Sequelize}) {
return {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Send a ping to the bot'),
initialize: async () => {},
execute: async interaction => {
await interaction.reply(
`Pong! This command was run by ${interaction.user.username}, who joined on ${interaction.member.joinedAt}.`,
);
},
};
}

View File

@@ -1,229 +0,0 @@
// import type { ChatInputCommandInteraction } from "discord.js";
import {
PermissionsBitField,
SlashCommandBuilder,
TextChannel,
type ChatInputCommandInteraction,
type Client,
} from 'discord.js';
import {type Sequelize, Model, INTEGER, TEXT, DATE, BOOLEAN} from 'sequelize';
import * as sequelize from 'sequelize';
import * as chrono from 'chrono-node';
// const REMINDERS_CHANNEL = "1062196593379520593"; // #bot-test-channel
// const REMINDERS_CHANNEL = ""; // #general
interface Settings {
client: Client; // Main Discord client object
db: Sequelize; // Database access object
responseMode: string;
publicChannel: string; // Channel to use if a reminder is public
loopIntervalSec?: number; // Loop interval in seconds
}
const data = new SlashCommandBuilder()
.setName('remind')
.setDescription('Remind me to do something')
.addStringOption(option =>
option
.setName('when')
.setDescription('Short description of when you want the reminder')
.setRequired(true),
)
.addStringOption(option =>
option
.setName('what')
.setDescription('Short description of what you want to be reminded of')
.setRequired(true),
);
class Reminder extends Model {
declare id: number;
declare userId: string;
declare text: string | null;
declare trigger: Date;
declare isValid: boolean;
declare requestChannel: string;
declare requestMessage: string;
}
class Plugin {
settings: Settings;
interval: NodeJS.Timeout;
constructor(settings: Settings) {
this.settings = settings;
}
getChannel(reminder: Reminder) {
let channel: TextChannel | null = null;
const client = this.settings.client;
const publicChannel = this.settings.publicChannel;
// if (this.settings.responseMode === "private") {
// channel = getSendableTextChannel(client, reminder.requestChannel);
// }
if (this.settings.responseMode === 'public' || !channel) {
channel = getSendableTextChannel(client, publicChannel);
}
if (!channel) {
throw Error('Cannot find valid channel to send reminder on');
}
return channel;
}
async loop() {
console.debug('remind.js main loop');
const results = await Reminder.findAll({
// NOTE: 'trigger' is the time when the reminder should go off. If trigger -
// now is positive, trigger is in the future. If trigger - now <=
// 1.0, then we have less than one minute before the timer should go
// off.
where: sequelize.literal(
"isValid AND (julianday(trigger) - julianday('now')) <= 1.0",
),
});
for (const reminder of results) {
try {
const now = new Date(Date.now());
const delay = reminder.trigger.getTime() - now.getTime();
console.log(
`Callback for Reminder ${reminder.get('id')} triggering in ${delay}ms`,
);
const channel = this.getChannel(reminder);
setTimeout(async () => await triggerReminder(channel, reminder), delay);
} catch (error) {
console.error(
`Error while processing Reminder ${reminder.id}: ${error}`,
);
console.trace();
}
}
}
start() {
this.interval = setInterval(
this.loop.bind(this),
1000 * (this.settings.loopIntervalSec ?? 60),
);
}
stop() {
clearInterval(this.interval);
}
}
async function initialize(settings: Settings) {
console.log('Initializing remind.js');
// Populate Reminder table inside SQLite DB
Reminder.init(
{
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
userId: {
type: TEXT,
allowNull: false,
},
text: TEXT,
trigger: {
type: DATE,
allowNull: false,
},
isValid: BOOLEAN,
requestChannel: TEXT,
requestMessage: TEXT,
},
{sequelize: settings.db},
);
await Reminder.sync();
// Try and determine how the bot will send reminders
console.debug(`Accessing channel ${settings.publicChannel}`);
if (!getSendableTextChannel(settings.client, settings.publicChannel)) {
throw Error(
'Invalid value for publicChannel; specify a channel the bot can access.',
);
}
// Initialize the 'plugin' object which will store all state required to control the mainloop.
if (settings.loopIntervalSec == null || settings.loopIntervalSec <= 0) {
console.log('Setting plugin loop interval to default of 60 seconds');
settings.loopIntervalSec = 60;
}
const plugin = new Plugin(settings);
plugin.start();
}
function getSendableTextChannel(client: Client, chanId: string) {
// console.log(`getSendableTextChannel(${client}, ${chanId})`);
const channel = client.channels.cache.get(chanId);
if (!client.user) {
throw Error("Can't check non-existent client");
}
if (!channel) {
throw Error('No such channel is visible to Blitz');
}
if (!channel?.isSendable()) {
throw Error('Channel is not a text channel, or otherwise not Sendable');
}
if (!(channel instanceof TextChannel)) {
throw Error('Channel is not a guild channel');
}
const permissions = channel?.permissionsFor(client.user);
const requiredPerms = new PermissionsBitField([
PermissionsBitField.Flags.SendMessages,
PermissionsBitField.Flags.ViewChannel,
]);
if (!permissions?.has(requiredPerms)) {
throw Error('Missing required permissions: SendMessages, ViewChannel');
}
return channel;
}
async function triggerReminder(channel: TextChannel, reminder: Reminder) {
try {
await channel.send({
content: `Reminder for <@${reminder.userId}>: ${reminder.text}`,
});
reminder.isValid = false;
} catch (error) {
console.log(`Error trigggering Reminder ${reminder.id}: ${error}`);
} finally {
await reminder.save();
}
}
async function execute(interaction: ChatInputCommandInteraction) {
const whenString = interaction.options.getString('when') ?? 'now';
const when = chrono.parseDate(whenString);
if (!when) {
await interaction.reply(`Sorry, I don't understand '${when}' as a date`);
return;
}
const reminder = await Reminder.create({
userId: interaction.user.id,
text: interaction.options.getString('what'),
trigger: when, // TODO
requestMessage: interaction.id,
requestChannel: interaction.channelId,
isValid: true,
});
reminder.save();
await interaction.reply(`Ok, I'll remind you at ${when}`);
console.info(
`Created Reminder ${reminder.id} to be triggered at ${reminder.trigger}`,
);
}
export default function (settings: Settings) {
return {
data,
execute,
initialize: async () => await initialize(settings),
Reminder,
settings,
};
}

View File

@@ -1,34 +0,0 @@
import {
SlashCommandBuilder,
TextChannel,
type ChatInputCommandInteraction,
type Client,
type InviteStageInstance,
} from 'discord.js';
import type {Sequelize} from 'sequelize';
import {quotes} from './quotes.json';
async function execute(interaction: ChatInputCommandInteraction) {
try {
const index = Math.floor(Math.random() * quotes.length);
await interaction.reply?.({
content: `> *${quotes[index].quote}*
> — ${quotes[index].author}`,
});
} catch (error) {
console.error(`Problem sending to channel: ${error}`);
}
}
async function initialize() {}
export default function (settings: {}) {
return {
data: new SlashCommandBuilder()
.setName('quote')
.setDescription('Print a quote from League of Legends.'),
initialize: initialize,
execute: execute,
};
}

View File

@@ -3,32 +3,41 @@ import {Model, Sequelize, STRING} from 'sequelize';
import 'node:fs/promises';
import {readFile} from 'node:fs';
export const sql = new Sequelize('sqlite://./blitzcrank.sqlite');
export const sequelize = new Sequelize('sqlite://./blitzcrank.sqlite');
export class GuildSetting extends Model {
// Discord ID for the Guild (server)
declare guildId: string;
// Name of the option
declare key: string;
// Value being set
declare value?: string;
}
GuildSetting.init(
{
guildId: {type: STRING},
key: {type: STRING},
value: {type: STRING},
},
{sequelize: sql},
);
export async function initDb() {
export async function initializeDatabase() {
try {
await sql.authenticate();
await sequelize.authenticate();
console.log('Connected to database');
} catch (error) {
console.error('Unable to connect to the database:', error);
}
}
export async function initializeModels() {
GuildSetting.init(
{
guildId: {type: STRING},
key: {type: STRING},
value: {type: STRING},
},
{sequelize},
);
}
export async function syncModels() {
await GuildSetting.sync();
}
export async function readSettingsFromFile(path: string) {
readFile(path, async (err, data) => {
try {

17
environ.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface EnvSettings {
BLITZCRANK_API_TOKEN: string;
BLITZCRANK_APP_ID: string;
}
export function readEnvSettings(): EnvSettings {
// TODO(DWM): I hate this.
const BLITZCRANK_API_TOKEN = process.env.BLITZCRANK_API_TOKEN;
if (BLITZCRANK_API_TOKEN === undefined || BLITZCRANK_API_TOKEN === null) {
throw TypeError();
}
const BLITZCRANK_APP_ID = process.env.BLITZCRANK_APP_ID;
if (BLITZCRANK_APP_ID === undefined || BLITZCRANK_APP_ID === null) {
throw TypeError();
}
return { BLITZCRANK_API_TOKEN, BLITZCRANK_APP_ID };
}

154
index.ts
View File

@@ -1,12 +1,3 @@
import type {Interaction} from 'discord.js';
import {Client, Events, GatewayIntentBits, MessageFlags} from 'discord.js';
import type {
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
} from 'discord.js';
import {sql, GuildSetting, initDb} from './database';
const BLITZCRANK_BANNER = `
****++++++++++*+++
**+*+******+********+******+*++*
@@ -62,6 +53,29 @@ const BLITZCRANK_BANNER = `
"FIRED UP AND READY TO SERVE!"
`;
import type {Interaction} from 'discord.js';
import {
Client,
Collection,
Events,
GatewayIntentBits,
MessageFlags,
REST,
Routes,
} from 'discord.js';
import {GuildSetting, initializeDatabase, initializeModels, sequelize, syncModels} from './database';
import {readEnvSettings} from './environ';
import {Command} from './plugin';
import { NagPlugin } from './plugins/nag'
import { PingPlugin } from './plugins/ping'
import { QuotePlugin } from './plugins/quote'
import { RemindPlugin } from './plugins/remind'
const envSettings = readEnvSettings();
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -70,84 +84,45 @@ const client = new Client({
GatewayIntentBits.MessageContent,
],
});
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);
rest.setToken(envSettings.BLITZCRANK_API_TOKEN);
interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder;
execute: (interaction: any) => Promise<void>;
initialize: (any) => Promise<void>;
}
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 NagCommand from './commands/calendar/nag/nag';
import UnnagCommand from './commands/calendar/nag/unnag';
import CheckinCommand from './commands/calendar/nag/checkin';
const settings = {
client: client,
database: sequelize,
}
import {Manager} from './commands/calendar/nag/service';
const nagManager = new Manager({
client: client,
db: sql,
});
nagManager.start();
const plugins = [
new NagPlugin(settings),
new QuotePlugin(settings),
new RemindPlugin(settings),
new PingPlugin(settings),
]
console.debug(`${remindersChannelId}`);
commands.set('ping', PingCommand({client: client, db: sql}));
commands.set(
'remind',
RemindCommand({
client: client,
db: sql,
publicChannel: remindersChannelId,
responseMode: 'public',
}),
);
commands.set('quote', QuoteCommand({}));
commands.set(
'nag',
NagCommand({
client: client,
db: sql,
}),
);
commands.set(
'unnag',
UnnagCommand({
client: client,
db: sql,
}),
);
commands.set(
'checkin',
CheckinCommand({
client: client,
db: sql,
}),
);
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);
async function addApplicationGuildCommands() {
for (const plugin of plugins) {
for (const command of plugin.commands) {
commands.set(command.data.name, command);
}
}
for (const [_guildId, guild] of client.guilds.cache) {
try {
console.log(`Started refreshing slash commands`);
const _data = await rest.put(
Routes.applicationGuildCommands(
envSettings.BLITZCRANK_APP_ID,
guild.id,
),
{
body: commands.mapValues(cmd => cmd.data.toJSON()),
},
);
console.log(`Successfully reloaded slash commands`);
} catch (error) {
console.error(error);
}
}
}
@@ -175,21 +150,16 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
});
client.once(Events.ClientReady, async readyClient => {
await syncCommands();
initDb(); // TODO
GuildSetting.sync(); // TODO
await initializeDatabase();
await initializeModels();
await syncModels();
await addApplicationGuildCommands();
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);
client.login(envSettings.BLITZCRANK_API_TOKEN);

32
plugin.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { ChatInputCommandInteraction, Client, SlashCommandBuilder } from "discord.js";
import type { Sequelize } from "sequelize";
export interface Settings {
// Main Discord client object
client: Client;
// Database access object
database: Sequelize;
}
export interface Command {
data: SlashCommandBuilder;
execute: (iteraction: ChatInputCommandInteraction) => Promise<void>;
}
export interface Plugin {
start: () => Promise<void>;
stop: () => Promise<void>;
commands: Command[]
}
export class BasePlugin implements Plugin {
settings: Settings;
commands: Command[]
constructor(settings: Settings) {
this.settings = settings;
}
async start() {}
async stop() {}
}

44
plugins/nag/checkin.ts Normal file
View File

@@ -0,0 +1,44 @@
import {
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import type {Settings} from '../../plugin';
import {CheckIn, Nag} from './models';
export default function (_settings: Settings) {
return {
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'),
),
execute: async (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;
}
},
};
}

View File

@@ -1,13 +1,11 @@
import {expect, test, vi, it, describe, beforeEach, afterEach} from 'vitest';
import {Sequelize } from 'sequelize';
import {afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
nextCheckInDate,
initAndSyncTables,
Nag,
CheckIn,
findGuiltyNags,
getCheckIn,
} from './service';
import {Sequelize, literal, Op} from 'sequelize';
nextCheckInDate,
} from '.';
import { CheckIn, initializeModels, Nag } from './models'
describe('nextCheckInDate', () => {
beforeEach(() => {
@@ -18,7 +16,7 @@ describe('nextCheckInDate', () => {
});
it('Returns 9AM if called before 9AM that day', () => {
const now = new Date(Date.now());
let at9AM = new Date(
const at9AM = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
@@ -34,7 +32,7 @@ describe('nextCheckInDate', () => {
const dayInMS = 24 * 60 * 60 * 1000;
const now = new Date(Date.now());
const tomorrow = new Date(Date.now() + dayInMS);
let tomorrow9AM = new Date(
const tomorrow9AM = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
@@ -59,15 +57,17 @@ describe('Finding nags without check-ins', async () => {
mentionHere: false,
};
await initAndSyncTables(sequelize);
await initializeModels(sequelize);
beforeEach(async () => {
vi.useFakeTimers();
await Nag.sync();
await CheckIn.sync();
});
afterEach(async () => {
await Nag.destroy({where: {}});
await CheckIn.destroy({where: {}});
await Nag.drop();
await CheckIn.drop();
vi.useRealTimers();
});
@@ -82,14 +82,16 @@ describe('Finding nags without check-ins', async () => {
});
it('Ignores nags with a recent check-in', async () => {
const currentCheckInTime = getCheckIn(9, 0);
vi.setSystemTime(currentCheckInTime);
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),
});
console.log(newCheckIn.lastCheckIn);
newCheckIn.save();
const results = await findGuiltyNags();
expect(results.map(nag => nag.userId)).toEqual([]);

View File

@@ -1,84 +1,11 @@
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();
}
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'
/**
*
@@ -105,10 +32,9 @@ export async function findGuiltyNags() {
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,
id: nag.id,
lastCheckIn: {
[Op.between]: [prevCheckIn, currentCheckIn],
},
@@ -149,20 +75,24 @@ function nextCheckInMs() {
* Handle state for the various nag-related commands, mainly related to
* tracking of timers and triggers.
*/
export class Manager {
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.)
settings: Settings;
interval?: NodeJS.Timeout;
constructor(settings: Settings) {
this.settings = settings;
initAndSyncTables(this.settings.db);
super(settings);
initializeModels(settings.database);
this.commands = [
CheckinCommand(settings) as Command, // TODO Why, TS?...
NagCommand(settings) as Command, // TODO Why, TS?...
UnnagCommand(settings),
]
}
start() {
async start() {
this.interval = setTimeout(this.loop, nextCheckInMs());
}
@@ -213,7 +143,7 @@ export class Manager {
this.interval = setTimeout(this.loop, nextCheckInMs());
}
stop() {
async stop() {
clearInterval(this.interval);
}
}

72
plugins/nag/models.ts Normal file
View File

@@ -0,0 +1,72 @@
import {
BOOLEAN,
DATE,
INTEGER,
Model,
type Sequelize,
STRING,
} from 'sequelize';
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 initializeModels(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();
}

View File

@@ -1,34 +1,10 @@
import {Chrono} from 'chrono-node';
import {
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
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')
.setRequired(false),
)
.addBooleanOption(option =>
option
.setName('mentionhere')
.setDescription('Whether to DM you or @ this channel')
.setRequired(false),
);
async function initialize(settings: Settings) {}
import type { Settings } from '../../plugin'
import {CheckIn, Nag} from './models';
async function execute(interaction: ChatInputCommandInteraction) {
const text = interaction.options.getString('text');
@@ -88,10 +64,29 @@ async function execute(interaction: ChatInputCommandInteraction) {
);
}
export default function (settings: Settings) {
export default function (_settings: Settings) {
return {
data,
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')
.setRequired(false),
)
.addBooleanOption(option =>
option
.setName('mentionhere')
.setDescription('Whether to DM you or @ this channel')
.setRequired(false),
),
execute,
initialize: async () => await initialize(settings),
};
}

25
plugins/nag/unnag.ts Normal file
View File

@@ -0,0 +1,25 @@
import {
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from 'discord.js';
import type {Settings} from '../../plugin';
import {Nag} from './models';
export default function (_settings: Settings) {
return {
data: new SlashCommandBuilder()
.setName('unnag')
.setDescription('Remove a nag'),
execute: async (interaction: ChatInputCommandInteraction) => {
// Find all nags for this user and delete them.
// TODO In the future, we should support having multiple nags
const results = await Nag.findAll({
where: {userId: interaction.user.id},
});
for (const result of results) {
await result.destroy();
}
return;
},
};
}

22
plugins/ping.ts Normal file
View File

@@ -0,0 +1,22 @@
import {SlashCommandBuilder} from 'discord.js';
import {BasePlugin, type Settings} from '../plugin';
export class PingPlugin extends BasePlugin {
constructor(settings: Settings) {
super(settings);
this.commands = [
{
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Send a ping to the bot'),
execute: async interaction => {
await interaction.reply(
`Pong! This command was run by ${interaction.user.username}, who joined on ${interaction.member?.joinedAt}.`,
);
},
},
];
}
}

29
plugins/quote/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import type {ChatInputCommandInteraction} from 'discord.js';
import {SlashCommandBuilder} from 'discord.js';
import {BasePlugin, type Settings } from '../../plugin';
import {quotes} from './quotes.json';
export class QuotePlugin extends BasePlugin {
constructor(settings: Settings) {
super(settings);
this.commands = [
{
data: new SlashCommandBuilder()
.setName('quote')
.setDescription('Print a quote from League of Legends.'),
execute: async (interaction: ChatInputCommandInteraction) => {
try {
const index = Math.floor(Math.random() * quotes.length);
await interaction.reply?.({
content: `> *${quotes[index].quote}*
> — ${quotes[index].author}`,
});
} catch (error) {
console.error(`Problem sending to channel: ${error}`);
}
},
},
];
}
}

136
plugins/remind/index.ts Normal file
View File

@@ -0,0 +1,136 @@
import * as chrono from 'chrono-node';
import {
type ChatInputCommandInteraction,
SlashCommandBuilder,
TextChannel,
} from 'discord.js';
import * as sequelize from 'sequelize';
import {BasePlugin, type Command, type Settings} from '../../plugin';
import {initializeModels, Reminder, syncModels} from './models';
async function triggerReminder(channel: TextChannel, reminder: Reminder) {
try {
await channel.send({
content: `Reminder for <@${reminder.userId}>: ${reminder.text}`,
});
reminder.isValid = false;
} catch (error) {
console.log(`Error trigggering Reminder ${reminder.id}: ${error}`);
} finally {
await reminder.save();
}
}
export class RemindPlugin extends BasePlugin {
interval: NodeJS.Timeout;
constructor(settings: Settings) {
super(settings);
initializeModels(settings.database);
this.commands = [
{
data: new SlashCommandBuilder()
.setName('remind')
.setDescription('Remind me to do something')
.addStringOption(option =>
option
.setName('when')
.setDescription('Short description of when you want the reminder')
.setRequired(true),
)
.addStringOption(option =>
option
.setName('what')
.setDescription(
'Short description of what you want to be reminded of',
)
.setRequired(true),
),
execute: async (interaction: ChatInputCommandInteraction) => {
const whenString = interaction.options.getString('when') ?? 'now';
const when = chrono.parseDate(whenString);
if (!when) {
await interaction.reply(
`Sorry, I don't understand '${when}' as a date`,
);
return;
}
const reminder = await Reminder.create({
userId: interaction.user.id,
text: interaction.options.getString('what'),
trigger: when, // TODO
requestMessage: interaction.id,
requestChannel: interaction.channelId,
isValid: true,
});
reminder.save();
await interaction.reply(`Ok, I'll remind you at ${when}`);
console.info(
`Created Reminder ${reminder.id} to be triggered at ${reminder.trigger}`,
);
},
} as Command, // TODO
];
}
getChannel(reminder: Reminder) {
// TODO I don't really know what else needs to go into this function
// I had some permissions stuff but it seemed very out-of-place
// I don't know why isSendable() doesn't cover permissions? Or maybe
// it does?
// Either way this function kind of smells
const client = this.settings.client;
const channel = client.channels.cache.get(reminder.requestChannel);
if (!(channel instanceof TextChannel)) {
throw TypeError("Can't send to a non-ext channel");
}
if (!channel) {
throw Error('Cannot find valid channel to send reminder on');
}
if (!channel.isSendable()) {
throw Error("Can't send to that channel");
}
return channel;
}
async loop() {
console.debug('remind.js main loop');
const results = await Reminder.findAll({
// NOTE: 'trigger' is the time when the reminder should go off. If trigger -
// now is positive, trigger is in the future. If trigger - now <=
// 1.0, then we have less than one minute before the timer should go
// off.
where: sequelize.literal(
"isValid AND (julianday(trigger) - julianday('now')) <= 1.0",
),
});
for (const reminder of results) {
try {
const now = new Date(Date.now());
const delay = reminder.trigger.getTime() - now.getTime();
console.log(
`Callback for Reminder ${reminder.get('id')} triggering in ${delay}ms`,
);
const channel = this.getChannel(reminder);
setTimeout(async () => await triggerReminder(channel, reminder), delay);
} catch (error) {
console.error(
`Error while processing Reminder ${reminder.id}: ${error}`,
);
console.trace();
}
}
}
async start() {
await syncModels();
this.interval = setInterval(this.loop.bind(this), 1000 * 60);
}
async stop() {
clearInterval(this.interval);
}
}

41
plugins/remind/models.ts Normal file
View File

@@ -0,0 +1,41 @@
import { BOOLEAN, DATE, INTEGER, Model, Sequelize, TEXT } from "sequelize";
export class Reminder extends Model {
declare id: number;
declare userId: string;
declare text: string | null;
declare trigger: Date;
declare isValid: boolean;
declare requestChannel: string;
declare requestMessage: string;
}
export function initializeModels(sequelize: Sequelize) {
Reminder.init(
{
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
},
userId: {
type: TEXT,
allowNull: false,
},
text: TEXT,
trigger: {
type: DATE,
allowNull: false,
},
isValid: BOOLEAN,
requestChannel: TEXT,
requestMessage: TEXT,
},
{sequelize},
);
// await Reminder.sync(); // TODO
}
export async function syncModels() {
await Reminder.sync();
}