diff --git a/commands/calendar/ping.ts b/commands/calendar/ping.ts index d19e5c6..772f6d8 100644 --- a/commands/calendar/ping.ts +++ b/commands/calendar/ping.ts @@ -3,13 +3,16 @@ 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}.`, ); }, - data: new SlashCommandBuilder() - .setName("ping") - .setDescription("Send a ping to the bot"), }; } diff --git a/commands/calendar/remind.ts b/commands/calendar/remind.ts index afae4db..ee650c6 100644 --- a/commands/calendar/remind.ts +++ b/commands/calendar/remind.ts @@ -1,40 +1,96 @@ // import type { ChatInputCommandInteraction } from "discord.js"; import { - SlashCommandBuilder, - type Client, - type ChatInputCommandInteraction, - GuildChannel, PermissionsBitField, + SlashCommandBuilder, + TextChannel, + type ChatInputCommandInteraction, + type Client, } from "discord.js"; -import { - type Sequelize, - Model, - INTEGER, - TEXT, - DATE, - BOOLEAN, - BIGINT, -} from "sequelize"; +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 = "395408839110950915"; // #general +// const REMINDERS_CHANNEL = ""; // #general -export default function (settings: { client: Client; db: Sequelize }) { - const client = settings.client; - const db = settings.db; +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 +} - 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; +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; + publicChannel: TextChannel; + + constructor(settings: Settings) { + this.settings = settings; + this.publicChannel = getSendableTextChannel( + settings.client, + settings.publicChannel, + ); } + async 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( + "(julianday(trigger) - julianday('now')) <= 1.0", + ), + }); + for (const reminder of results) { + try { + const now = new Date(Date.now()); + const wait = reminder.trigger.getTime() - now.getTime(); + console.log( + `Callback for Reminder ${reminder.get("id")} triggering in ${wait}ms`, + ); + setTimeout( + async () => await triggerReminder(this.publicChannel, reminder), + wait, + ); + } catch (error) { + console.error(`Unrecoverable error: ${error}`); + console.trace(); + } + } + } +} + +async function initialize(settings: Settings) { + console.log("Initializing remind.js"); + + // Populate Reminder table inside SQLite DB Reminder.init( { id: { @@ -55,136 +111,87 @@ export default function (settings: { client: Client; db: Sequelize }) { requestChannel: TEXT, requestMessage: TEXT, }, - { sequelize: db }, + { sequelize: settings.db }, ); + await Reminder.sync(); - async function canSendChannel(chanId: string): Promise { - 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 GuildChannel)) { - throw Error("Channel is not a guild channel"); - } - const permissions = channel?.permissionsFor(client.user); - const flags = new PermissionsBitField([ - PermissionsBitField.Flags.SendMessages, - PermissionsBitField.Flags.ViewChannel, - ]); - if (permissions?.has(flags)) { - return true; - } - return false; + if ( + !(await getSendableTextChannel(settings.client, settings.publicChannel)) + ) { + throw Error( + "Invalid value for publicChannel; specify a channel the bot can access.", + ); } - - async function triggerReminder(reminder: Reminder) { - try { - const channel = client.channels.cache.get(REMINDERS_CHANNEL); - if (!(await canSendChannel(REMINDERS_CHANNEL))) - return console.error("We're not permitted to send on that channel!"); - if (!channel?.isSendable()) { - return console.error( - `Can't find channel ${REMINDERS_CHANNEL} or it is not sendable`, - ); - } - await channel.send({ - content: `Reminder for <@${reminder.userId}>: ${reminder.text}`, - }); - console.log("Reminder sent!"); - reminder.set('isValid', false); - await reminder.save(); - } catch (error) { - console.log(error); - console.trace(); - } + const plugin = new Plugin(settings); + if (settings.loopIntervalSec == null || settings.loopIntervalSec <= 0) { + console.log("Setting plugin loop interval to default of 60 seconds"); + settings.loopIntervalSec = 60; } + setInterval(plugin.loop, 1000 * settings.loopIntervalSec); +} - async function loop() { - const results = await Reminder.findAll({ - // TODO: This means that there's less than or equal to one minute left before - // the reminder goes off. - where: sequelize.literal( - "isValid AND (julianday(trigger) - julianday('now')) <= 1.0 AND (julianday(trigger) - julianday('now')) > 0", - ), - }); - for (const rmd of results) { - try { - const now = new Date(Date.now()); - const wait = rmd.trigger.getTime() - now.getTime(); - console.log( - `Callback for Reminder ${rmd.get("id")} triggering in ${wait}ms`, - ); - setTimeout(async () => await triggerReminder(rmd), wait); - } catch (error) { - console.error(`Unrecoverable error: ${error}`) - console.trace(); - } - } +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"); } - - async function execute(interaction: ChatInputCommandInteraction) { - try { - const when = chrono.parseDate( - interaction.options.getString("when") ?? "now", - ); - 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(); - console.info( - `Created Reminder ${reminder.get("id")} to be triggered at ${reminder.get("trigger")}`, - ); - await interaction.reply(`Ok, I'll remind you at ${when}`); - } catch (_error) { - await interaction.reply( - "Something went wrong with adding your reminder.", - ); - } + 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); + if ( + !permissions?.has([ + PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.ViewChannel, + ]) + ) { + throw Error("Missing required permissions: SendMessages, ViewChannel"); + } + return channel; +} +async function triggerReminder(channel: TextChannel, reminder: Reminder) { + await channel.send({ + content: `Reminder for <@${reminder.userId}>: ${reminder.text}`, + }); + reminder.isValid = false; + await reminder.save(); +} + +async function execute(interaction: ChatInputCommandInteraction) { + const when = chrono.parseDate(interaction.options.getString("when") ?? "now"); + 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 { - Reminder: Reminder, - loop: loop, - 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: execute, - initialize: async () => { - console.log("Initializing remind.js"); - await Reminder.sync(); - await loop(); - setInterval(loop, 1000 * 60); - }, + data, + execute, + initialize, + Reminder, + settings, }; } diff --git a/index.ts b/index.ts index 37e3890..b7d72ea 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,9 @@ -import { Interaction } from "discord.js"; +import type { Interaction } from "discord.js"; import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js"; -import type { SlashCommandBuilder, SlashCommandOptionsOnlyBuilder } from "discord.js"; +import type { + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from "discord.js"; import { sql } from "./database"; @@ -68,27 +71,18 @@ const client = new Client({ ], }); -const pingCommand = PingCommand({ - client: client, - db: sql, -}); - -const remindCommand = RemindCommand({ - client: client, - db: sql, -}); - import { Routes } from "discord.js"; -import { guildId, appId, token } from "./config.json"; +import { guildId, appId, token, remindersChannelId } from "./config.json"; import { REST } from "discord.js"; const rest = new REST(); rest.setToken(token); interface Command { - data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder, - execute: (interaction: any) => Promise -}; + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; + execute: (interaction: any) => Promise; + initialize: (any) => Promise; +} import { Collection } from "discord.js"; const commands = new Collection(); @@ -97,7 +91,10 @@ import PingCommand from "./commands/calendar/ping"; import RemindCommand from "./commands/calendar/remind"; commands.set("ping", PingCommand({ client: client, db: sql })); -commands.set("remind", RemindCommand({ client: client, db: sql })); +commands.set( + "remind", + RemindCommand({ client: client, db: sql, publicChannel: remindersChannelId }), +); async function syncCommands() { try { @@ -138,8 +135,13 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => { }); client.once(Events.ClientReady, async (readyClient) => { - await syncCommands(); - await remindCommand.initialize(); + await syncCommands(); + 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);