diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4bb750c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit" + } +} \ No newline at end of file diff --git a/Justfile b/Justfile index 6295405..3756aff 100644 --- a/Justfile +++ b/Justfile @@ -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/ \ No newline at end of file + bunx --bun @biomejs/biome lint --fix *.ts plugins/ \ No newline at end of file diff --git a/commands/calendar/nag/checkin.ts b/commands/calendar/nag/checkin.ts deleted file mode 100644 index 07ae6af..0000000 --- a/commands/calendar/nag/checkin.ts +++ /dev/null @@ -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, - }; -} diff --git a/commands/calendar/nag/unnag.ts b/commands/calendar/nag/unnag.ts deleted file mode 100644 index 4431a0f..0000000 --- a/commands/calendar/nag/unnag.ts +++ /dev/null @@ -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), - }; -} diff --git a/commands/calendar/ping.ts b/commands/calendar/ping.ts deleted file mode 100644 index 9e62a1d..0000000 --- a/commands/calendar/ping.ts +++ /dev/null @@ -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}.`, - ); - }, - }; -} diff --git a/commands/calendar/remind.ts b/commands/calendar/remind.ts deleted file mode 100644 index 601a70c..0000000 --- a/commands/calendar/remind.ts +++ /dev/null @@ -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, - }; -} diff --git a/commands/quotes/quote.ts b/commands/quotes/quote.ts deleted file mode 100644 index 7eb12cc..0000000 --- a/commands/quotes/quote.ts +++ /dev/null @@ -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, - }; -} diff --git a/database.ts b/database.ts index cb36075..bb2ce55 100644 --- a/database.ts +++ b/database.ts @@ -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 { diff --git a/environ.ts b/environ.ts new file mode 100644 index 0000000..1e6c889 --- /dev/null +++ b/environ.ts @@ -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 }; +} diff --git a/index.ts b/index.ts index d9d0bb3..9825564 100644 --- a/index.ts +++ b/index.ts @@ -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; - initialize: (any) => Promise; -} - -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 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); diff --git a/plugin.ts b/plugin.ts new file mode 100644 index 0000000..5492771 --- /dev/null +++ b/plugin.ts @@ -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; +} + +export interface Plugin { + start: () => Promise; + stop: () => Promise; + commands: Command[] +} + +export class BasePlugin implements Plugin { + settings: Settings; + commands: Command[] + + constructor(settings: Settings) { + this.settings = settings; + } + + async start() {} + async stop() {} +} \ No newline at end of file diff --git a/plugins/nag/checkin.ts b/plugins/nag/checkin.ts new file mode 100644 index 0000000..cc455c4 --- /dev/null +++ b/plugins/nag/checkin.ts @@ -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; + } + }, + }; +} diff --git a/commands/calendar/nag/service.test.ts b/plugins/nag/index.test.ts similarity index 83% rename from commands/calendar/nag/service.test.ts rename to plugins/nag/index.test.ts index 390f722..174cb65 100644 --- a/commands/calendar/nag/service.test.ts +++ b/plugins/nag/index.test.ts @@ -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([]); diff --git a/commands/calendar/nag/service.ts b/plugins/nag/index.ts similarity index 67% rename from commands/calendar/nag/service.ts rename to plugins/nag/index.ts index 60b3e16..e45bfd5 100644 --- a/commands/calendar/nag/service.ts +++ b/plugins/nag/index.ts @@ -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); } } diff --git a/plugins/nag/models.ts b/plugins/nag/models.ts new file mode 100644 index 0000000..e4eab50 --- /dev/null +++ b/plugins/nag/models.ts @@ -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(); +} \ No newline at end of file diff --git a/commands/calendar/nag/nag.ts b/plugins/nag/nag.ts similarity index 72% rename from commands/calendar/nag/nag.ts rename to plugins/nag/nag.ts index 3ed459a..fc0e8ce 100644 --- a/commands/calendar/nag/nag.ts +++ b/plugins/nag/nag.ts @@ -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), }; } diff --git a/plugins/nag/unnag.ts b/plugins/nag/unnag.ts new file mode 100644 index 0000000..0c76aa9 --- /dev/null +++ b/plugins/nag/unnag.ts @@ -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; + }, + }; +} diff --git a/plugins/ping.ts b/plugins/ping.ts new file mode 100644 index 0000000..ede3b70 --- /dev/null +++ b/plugins/ping.ts @@ -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}.`, + ); + }, + }, + ]; + } +} diff --git a/plugins/quote/index.ts b/plugins/quote/index.ts new file mode 100644 index 0000000..ab39a52 --- /dev/null +++ b/plugins/quote/index.ts @@ -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}`); + } + }, + }, + ]; + } +} \ No newline at end of file diff --git a/commands/quotes/quotes.json b/plugins/quote/quotes.json similarity index 100% rename from commands/quotes/quotes.json rename to plugins/quote/quotes.json diff --git a/plugins/remind/index.ts b/plugins/remind/index.ts new file mode 100644 index 0000000..9c0add3 --- /dev/null +++ b/plugins/remind/index.ts @@ -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); + } +} diff --git a/plugins/remind/models.ts b/plugins/remind/models.ts new file mode 100644 index 0000000..1077be0 --- /dev/null +++ b/plugins/remind/models.ts @@ -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(); +} \ No newline at end of file