// 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, }; }