diff --git a/commands/calendar/ping.ts b/commands/calendar/ping.ts index 64f204c..d19e5c6 100644 --- a/commands/calendar/ping.ts +++ b/commands/calendar/ping.ts @@ -1,12 +1,15 @@ -// import type { ChatInputCommandInteraction } from "discord.js"; -import { SlashCommandBuilder, } from "discord.js"; +import { type Client, SlashCommandBuilder } from "discord.js"; +import type { Sequelize } from "sequelize"; -export const data = new SlashCommandBuilder() - .setName("ping") - .setDescription("Send a ping to the bot"); - -export async function execute(interaction) { - await interaction.reply( - `Pong! This command was run by ${interaction.user.username}, who joined on ${interaction.member.joinedAt}.` - ); +export default function (settings: { client: Client; db: Sequelize }) { + return { + 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 42c381e..afae4db 100644 --- a/commands/calendar/remind.ts +++ b/commands/calendar/remind.ts @@ -1,50 +1,131 @@ // import type { ChatInputCommandInteraction } from "discord.js"; -import type { Client, ChatInputCommandInteraction } from "discord.js"; -import { InteractionResponse, SlashCommandBuilder } from "discord.js"; +import { + SlashCommandBuilder, + type Client, + type ChatInputCommandInteraction, + GuildChannel, + PermissionsBitField, +} from "discord.js"; +import { + type Sequelize, + Model, + INTEGER, + TEXT, + DATE, + BOOLEAN, + BIGINT, +} from "sequelize"; import * as sequelize from "sequelize"; import * as chrono from "chrono-node"; -import { sql } from "../../database"; -export default function (settings: { client: Client }) { +// const REMINDERS_CHANNEL = "1062196593379520593"; // #bot-test-channel +const REMINDERS_CHANNEL = "395408839110950915"; // #general + +export default function (settings: { client: Client; db: Sequelize }) { const client = settings.client; + const db = settings.db; - const Reminders = sql.define("reminders", { - id: { - type: sequelize.INTEGER, - primaryKey: true, - autoIncrement: 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; + } + + 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, }, - userId: sequelize.INTEGER, - text: sequelize.TEXT, - trigger: sequelize.DATE, - isValid: sequelize.BOOLEAN, - requestChannel: sequelize.BIGINT, - requestMessage: sequelize.BIGINT, - }); + { sequelize: db }, + ); - /** - * Create an infinitely looping function which tries to determine what - * reminders will be triggering soon, and creating a timer process for - * them. - */ - async function reminderLoop() { - console.log("Reminder loop"); - const results = await Reminders.findAll({ + 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; + } + + 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(); + } + } + + 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( - "(julianday('now') - julianday(trigger)) <= 1.0 AND (julianday('now') - julianday(trigger)) > 0", + "isValid AND (julianday(trigger) - julianday('now')) <= 1.0 AND (julianday(trigger) - julianday('now')) > 0", ), }); for (const rmd of results) { - setTimeout(async () => { - const channel = client.channels.cache.get("1234"); // TODO - if (channel?.isSendable()) { - await channel.send(`Reminder: ${rmd.get("text")}`); - } - }); + 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(); + } } - setTimeout(reminderLoop, 60 * 1000); // Loop after 60 seconds } async function execute(interaction: ChatInputCommandInteraction) { @@ -53,11 +134,12 @@ export default function (settings: { client: Client }) { interaction.options.getString("when") ?? "now", ); if (!when) { - return interaction.reply( + await interaction.reply( `Sorry, I don't understand '${when}' as a date`, ); + return; } - const reminder = await Reminders.create({ + const reminder = await Reminder.create({ userId: interaction.user.id, text: interaction.options.getString("what"), trigger: when, // TODO @@ -65,20 +147,21 @@ export default function (settings: { client: Client }) { requestChannel: interaction.channelId, isValid: true, }); - console.info(`Created Reminder(${reminder.get("id")})`); - return interaction.reply( - `Ok, I'll remind you at ${when?.toDateString()}`, + 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) { - return interaction.reply( + await interaction.reply( "Something went wrong with adding your reminder.", ); } } return { - Reminders: Reminders, - // reminderLoop: reminderLoop, + Reminder: Reminder, + loop: loop, data: new SlashCommandBuilder() .setName("remind") .setDescription("Remind me to do something") @@ -97,9 +180,11 @@ export default function (settings: { client: Client }) { .setRequired(true), ), execute: execute, - initialize: async () => { - await Reminders.sync(); - reminderLoop(); - } + initialize: async () => { + console.log("Initializing remind.js"); + await Reminder.sync(); + await loop(); + setInterval(loop, 1000 * 60); + }, }; } diff --git a/database.ts b/database.ts index 6bd8c65..78cad27 100644 --- a/database.ts +++ b/database.ts @@ -1,10 +1,12 @@ -import * as Sequelize from "sequelize"; - -export const sql = new Sequelize.Sequelize("database", "username", "password", { - dialect: "sqlite", - logging: false, - // SQLite only: - storage: "blitzcrank.sqlite", -}); +import { Sequelize } from "sequelize"; +export const sql = new Sequelize("sqlite://./blitzcrank.sqlite"); +export async function initDb() { + try { + await sql.authenticate(); + console.log("Connected to database"); + } catch (error) { + console.error("Unable to connect to the database:", error); + } +} diff --git a/index.ts b/index.ts index c712bc6..37e3890 100644 --- a/index.ts +++ b/index.ts @@ -1,17 +1,8 @@ -import { token } from "./config.json"; import { Interaction } from "discord.js"; import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js"; -import type { SlashCommandBuilder } from "discord.js"; +import type { SlashCommandBuilder, SlashCommandOptionsOnlyBuilder } from "discord.js"; -import * as _pingCommand from "./commands/calendar/ping.ts"; -import RemindCommand from "./commands/calendar/remind.ts"; - -interface Command { - data: SlashCommandBuilder; - execute: (i: Interaction) => void; -} - -const pingCommand = _pingCommand as Command; +import { sql } from "./database"; const BLITZCRANK_BANNER = ` ****++++++++++*+++ @@ -77,23 +68,63 @@ 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 { REST } from "discord.js"; + +const rest = new REST(); +rest.setToken(token); + +interface Command { + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder, + execute: (interaction: any) => Promise +}; + +import { Collection } from "discord.js"; +const commands = new Collection(); + +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 })); + +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); + } +} + client.on(Events.InteractionCreate, async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) { return; } try { - switch (interaction.commandName) { - case "ping": - return pingCommand.execute(interaction); - case "remind": - return remindCommand.execute(interaction); - default: - return console.error(`No matching command ${interaction.commandName}`); + const command = commands.get(interaction.commandName); + if (command == null) { + await interaction.followUp(`No such command ${interaction.commandName}`); + return; } + command.execute(interaction); } catch (error) { console.error(error); if (interaction.replied || interaction.deferred) { @@ -107,6 +138,7 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => { }); client.once(Events.ClientReady, async (readyClient) => { + await syncCommands(); await remindCommand.initialize(); // Print banner for (const ln of BLITZCRANK_BANNER.split("\n")) { diff --git a/sync-commands.ts b/sync-commands.ts deleted file mode 100644 index 5c1ca1e..0000000 --- a/sync-commands.ts +++ /dev/null @@ -1,24 +0,0 @@ -const { REST, Routes } = require("discord.js"); -const { appId, guildId, token } = require("./config.json"); - -const commands = [ - require("./commands/calendar/ping.js").data.toJSON(), - require("./commands/calendar/remind.js").data.toJSON(), -]; - -const rest = new REST().setToken(token); - -(async () => { - try { - console.log(`Started refreshing ${commands.length} slash commands`); - const data = await rest.put( - Routes.applicationGuildCommands(appId, guildId), - { - body: commands, - }, - ); - console.log(`Successfully reloaded ${data.length} slash commands`); - } catch (error) { - console.error(error); - } -})();