Refactor to more consistent structure (e.g. "plugins")
This commit is contained in:
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports.biome": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
Justfile
4
Justfile
@@ -3,8 +3,8 @@ fmt:
|
|||||||
just format
|
just format
|
||||||
|
|
||||||
format:
|
format:
|
||||||
bunx --bun @biomejs/biome format --write *.ts commands/
|
bunx --bun @biomejs/biome format --write *.ts plugins/
|
||||||
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
bunx --bun @biomejs/biome lint --fix *.ts commands/
|
bunx --bun @biomejs/biome lint --fix *.ts plugins/
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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}.`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
33
database.ts
33
database.ts
@@ -3,32 +3,41 @@ import {Model, Sequelize, STRING} from 'sequelize';
|
|||||||
import 'node:fs/promises';
|
import 'node:fs/promises';
|
||||||
import {readFile} from 'node:fs';
|
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 {
|
export class GuildSetting extends Model {
|
||||||
|
// Discord ID for the Guild (server)
|
||||||
declare guildId: string;
|
declare guildId: string;
|
||||||
|
// Name of the option
|
||||||
declare key: string;
|
declare key: string;
|
||||||
|
// Value being set
|
||||||
declare value?: string;
|
declare value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
GuildSetting.init(
|
export async function initializeDatabase() {
|
||||||
{
|
|
||||||
guildId: {type: STRING},
|
|
||||||
key: {type: STRING},
|
|
||||||
value: {type: STRING},
|
|
||||||
},
|
|
||||||
{sequelize: sql},
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function initDb() {
|
|
||||||
try {
|
try {
|
||||||
await sql.authenticate();
|
await sequelize.authenticate();
|
||||||
console.log('Connected to database');
|
console.log('Connected to database');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unable to connect to the database:', 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) {
|
export async function readSettingsFromFile(path: string) {
|
||||||
readFile(path, async (err, data) => {
|
readFile(path, async (err, data) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
17
environ.ts
Normal file
17
environ.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
152
index.ts
152
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 = `
|
const BLITZCRANK_BANNER = `
|
||||||
****++++++++++*+++
|
****++++++++++*+++
|
||||||
**+*+******+********+******+*++*
|
**+*+******+********+******+*++*
|
||||||
@@ -62,6 +53,29 @@ const BLITZCRANK_BANNER = `
|
|||||||
"FIRED UP AND READY TO SERVE!"
|
"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({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
@@ -70,84 +84,45 @@ const client = new Client({
|
|||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
import {Routes} from 'discord.js';
|
|
||||||
import {guildId, appId, token, remindersChannelId} from './config.json';
|
|
||||||
import {REST} from 'discord.js';
|
|
||||||
|
|
||||||
const rest = new REST();
|
const rest = new REST();
|
||||||
rest.setToken(token);
|
rest.setToken(envSettings.BLITZCRANK_API_TOKEN);
|
||||||
|
|
||||||
interface Command {
|
|
||||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder;
|
|
||||||
execute: (interaction: any) => Promise<void>;
|
|
||||||
initialize: (any) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
import {Collection} from 'discord.js';
|
|
||||||
const commands = new Collection<string, Command>();
|
const commands = new Collection<string, Command>();
|
||||||
|
|
||||||
import PingCommand from './commands/calendar/ping';
|
const settings = {
|
||||||
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';
|
|
||||||
|
|
||||||
import {Manager} from './commands/calendar/nag/service';
|
|
||||||
const nagManager = new Manager({
|
|
||||||
client: client,
|
client: client,
|
||||||
db: sql,
|
database: sequelize,
|
||||||
});
|
}
|
||||||
nagManager.start();
|
|
||||||
|
|
||||||
console.debug(`${remindersChannelId}`);
|
const plugins = [
|
||||||
|
new NagPlugin(settings),
|
||||||
|
new QuotePlugin(settings),
|
||||||
|
new RemindPlugin(settings),
|
||||||
|
new PingPlugin(settings),
|
||||||
|
]
|
||||||
|
|
||||||
commands.set('ping', PingCommand({client: client, db: sql}));
|
async function addApplicationGuildCommands() {
|
||||||
commands.set(
|
for (const plugin of plugins) {
|
||||||
'remind',
|
for (const command of plugin.commands) {
|
||||||
RemindCommand({
|
commands.set(command.data.name, command);
|
||||||
client: client,
|
}
|
||||||
db: sql,
|
}
|
||||||
publicChannel: remindersChannelId,
|
for (const [_guildId, guild] of client.guilds.cache) {
|
||||||
responseMode: 'public',
|
try {
|
||||||
}),
|
console.log(`Started refreshing slash commands`);
|
||||||
);
|
const _data = await rest.put(
|
||||||
commands.set('quote', QuoteCommand({}));
|
Routes.applicationGuildCommands(
|
||||||
commands.set(
|
envSettings.BLITZCRANK_APP_ID,
|
||||||
'nag',
|
guild.id,
|
||||||
NagCommand({
|
),
|
||||||
client: client,
|
{
|
||||||
db: sql,
|
body: commands.mapValues(cmd => cmd.data.toJSON()),
|
||||||
}),
|
},
|
||||||
);
|
);
|
||||||
commands.set(
|
console.log(`Successfully reloaded slash commands`);
|
||||||
'unnag',
|
} catch (error) {
|
||||||
UnnagCommand({
|
console.error(error);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,21 +150,16 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.once(Events.ClientReady, async readyClient => {
|
client.once(Events.ClientReady, async readyClient => {
|
||||||
await syncCommands();
|
await initializeDatabase();
|
||||||
initDb(); // TODO
|
await initializeModels();
|
||||||
GuildSetting.sync(); // TODO
|
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')) {
|
for (const ln of BLITZCRANK_BANNER.split('\n')) {
|
||||||
console.log(ln);
|
console.log(ln);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Logged in as ${readyClient.user.tag}`);
|
console.log(`Logged in as ${readyClient.user.tag}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.login(token);
|
client.login(envSettings.BLITZCRANK_API_TOKEN);
|
||||||
|
|||||||
32
plugin.ts
Normal file
32
plugin.ts
Normal file
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
commands: Command[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BasePlugin implements Plugin {
|
||||||
|
settings: Settings;
|
||||||
|
commands: Command[]
|
||||||
|
|
||||||
|
constructor(settings: Settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {}
|
||||||
|
async stop() {}
|
||||||
|
}
|
||||||
44
plugins/nag/checkin.ts
Normal file
44
plugins/nag/checkin.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
import {
|
||||||
nextCheckInDate,
|
|
||||||
initAndSyncTables,
|
|
||||||
Nag,
|
|
||||||
CheckIn,
|
|
||||||
findGuiltyNags,
|
findGuiltyNags,
|
||||||
getCheckIn,
|
getCheckIn,
|
||||||
} from './service';
|
nextCheckInDate,
|
||||||
import {Sequelize, literal, Op} from 'sequelize';
|
} from '.';
|
||||||
|
import { CheckIn, initializeModels, Nag } from './models'
|
||||||
|
|
||||||
describe('nextCheckInDate', () => {
|
describe('nextCheckInDate', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -18,7 +16,7 @@ describe('nextCheckInDate', () => {
|
|||||||
});
|
});
|
||||||
it('Returns 9AM if called before 9AM that day', () => {
|
it('Returns 9AM if called before 9AM that day', () => {
|
||||||
const now = new Date(Date.now());
|
const now = new Date(Date.now());
|
||||||
let at9AM = new Date(
|
const at9AM = new Date(
|
||||||
now.getFullYear(),
|
now.getFullYear(),
|
||||||
now.getMonth(),
|
now.getMonth(),
|
||||||
now.getDate(),
|
now.getDate(),
|
||||||
@@ -34,7 +32,7 @@ describe('nextCheckInDate', () => {
|
|||||||
const dayInMS = 24 * 60 * 60 * 1000;
|
const dayInMS = 24 * 60 * 60 * 1000;
|
||||||
const now = new Date(Date.now());
|
const now = new Date(Date.now());
|
||||||
const tomorrow = new Date(Date.now() + dayInMS);
|
const tomorrow = new Date(Date.now() + dayInMS);
|
||||||
let tomorrow9AM = new Date(
|
const tomorrow9AM = new Date(
|
||||||
tomorrow.getFullYear(),
|
tomorrow.getFullYear(),
|
||||||
tomorrow.getMonth(),
|
tomorrow.getMonth(),
|
||||||
tomorrow.getDate(),
|
tomorrow.getDate(),
|
||||||
@@ -59,15 +57,17 @@ describe('Finding nags without check-ins', async () => {
|
|||||||
mentionHere: false,
|
mentionHere: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
await initAndSyncTables(sequelize);
|
await initializeModels(sequelize);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
await Nag.sync();
|
||||||
|
await CheckIn.sync();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Nag.destroy({where: {}});
|
await Nag.drop();
|
||||||
await CheckIn.destroy({where: {}});
|
await CheckIn.drop();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,14 +82,16 @@ describe('Finding nags without check-ins', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Ignores nags with a recent check-in', async () => {
|
it('Ignores nags with a recent check-in', async () => {
|
||||||
|
const currentCheckInTime = getCheckIn(9, 0);
|
||||||
|
vi.setSystemTime(currentCheckInTime);
|
||||||
const newNag = await Nag.create(exampleNag);
|
const newNag = await Nag.create(exampleNag);
|
||||||
newNag.save();
|
newNag.save();
|
||||||
const currentCheckInTime = getCheckIn(9, 0);
|
|
||||||
const newCheckIn = await CheckIn.create({
|
const newCheckIn = await CheckIn.create({
|
||||||
nagId: newNag.id,
|
nagId: newNag.id,
|
||||||
// 1 hour previously; i.e. we checked in before the required time
|
// 1 hour previously; i.e. we checked in before the required time
|
||||||
lastCheckIn: new Date(currentCheckInTime.getTime() - 60 * 60 * 1000),
|
lastCheckIn: new Date(currentCheckInTime.getTime() - 60 * 60 * 1000),
|
||||||
});
|
});
|
||||||
|
console.log(newCheckIn.lastCheckIn);
|
||||||
newCheckIn.save();
|
newCheckIn.save();
|
||||||
const results = await findGuiltyNags();
|
const results = await findGuiltyNags();
|
||||||
expect(results.map(nag => nag.userId)).toEqual([]);
|
expect(results.map(nag => nag.userId)).toEqual([]);
|
||||||
@@ -1,84 +1,11 @@
|
|||||||
import {type Client, TextChannel} from 'discord.js';
|
import { TextChannel } from 'discord.js';
|
||||||
import {
|
import { Op } from 'sequelize';
|
||||||
type Sequelize,
|
import { BasePlugin, type Command, type Settings } from '../../plugin'
|
||||||
Model,
|
import CheckinCommand from './checkin'
|
||||||
INTEGER,
|
import checkin from './checkin';
|
||||||
STRING,
|
import { CheckIn, initializeModels, Nag } from './models'
|
||||||
BOOLEAN,
|
import NagCommand from './nag'
|
||||||
DATE,
|
import UnnagCommand from './unnag'
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -105,10 +32,9 @@ export async function findGuiltyNags() {
|
|||||||
const currentCheckIn = getCheckIn(9, 0);
|
const currentCheckIn = getCheckIn(9, 0);
|
||||||
|
|
||||||
for (const nag of results) {
|
for (const nag of results) {
|
||||||
console.log('Checking nag: ', nag.id);
|
|
||||||
const checkInResults = await CheckIn.findAll({
|
const checkInResults = await CheckIn.findAll({
|
||||||
where: {
|
where: {
|
||||||
nagId: nag.id,
|
id: nag.id,
|
||||||
lastCheckIn: {
|
lastCheckIn: {
|
||||||
[Op.between]: [prevCheckIn, currentCheckIn],
|
[Op.between]: [prevCheckIn, currentCheckIn],
|
||||||
},
|
},
|
||||||
@@ -149,20 +75,24 @@ function nextCheckInMs() {
|
|||||||
* Handle state for the various nag-related commands, mainly related to
|
* Handle state for the various nag-related commands, mainly related to
|
||||||
* tracking of timers and triggers.
|
* 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
|
// 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
|
// thought for quite a bit about what to name these classes and I just can't
|
||||||
// come up with anything solid. (Pun intended.)
|
// come up with anything solid. (Pun intended.)
|
||||||
|
|
||||||
settings: Settings;
|
|
||||||
interval?: NodeJS.Timeout;
|
interval?: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(settings: Settings) {
|
constructor(settings: Settings) {
|
||||||
this.settings = settings;
|
super(settings);
|
||||||
initAndSyncTables(this.settings.db);
|
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());
|
this.interval = setTimeout(this.loop, nextCheckInMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +143,7 @@ export class Manager {
|
|||||||
this.interval = setTimeout(this.loop, nextCheckInMs());
|
this.interval = setTimeout(this.loop, nextCheckInMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
async stop() {
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
72
plugins/nag/models.ts
Normal file
72
plugins/nag/models.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,34 +1,10 @@
|
|||||||
|
import {Chrono} from 'chrono-node';
|
||||||
import {
|
import {
|
||||||
type ChatInputCommandInteraction,
|
type ChatInputCommandInteraction,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
import type { Settings } from '../../plugin'
|
||||||
import {Nag, CheckIn, type Settings} from './service';
|
import {CheckIn, Nag} from './models';
|
||||||
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) {}
|
|
||||||
|
|
||||||
async function execute(interaction: ChatInputCommandInteraction) {
|
async function execute(interaction: ChatInputCommandInteraction) {
|
||||||
const text = interaction.options.getString('text');
|
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 {
|
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,
|
execute,
|
||||||
initialize: async () => await initialize(settings),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
25
plugins/nag/unnag.ts
Normal file
25
plugins/nag/unnag.ts
Normal file
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
22
plugins/ping.ts
Normal file
22
plugins/ping.ts
Normal file
@@ -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}.`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
plugins/quote/index.ts
Normal file
29
plugins/quote/index.ts
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
136
plugins/remind/index.ts
Normal file
136
plugins/remind/index.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
plugins/remind/models.ts
Normal file
41
plugins/remind/models.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user