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