diff --git a/src/commands/rmfp.ts b/src/commands/rmfp.ts index 65f9a34..c84a410 100644 --- a/src/commands/rmfp.ts +++ b/src/commands/rmfp.ts @@ -1,24 +1,45 @@ import { SlashCommandBuilder, SlashCommandSubcommandGroupBuilder } from 'discord.js'; -import endSeason from './subcommands/endSeason.js'; -import extend from './subcommands/extend.js'; +import type { SubCommand } from './subcommands/index.js'; import leaderboard from './subcommands/leaderboard.js'; -import startSeason from './subcommands/start/season.js'; -import startWeek from './subcommands/start/week.js'; +import endSeason from './subcommands/season/end.js'; +import startSeason from './subcommands/season/start.js'; +import endWeek from './subcommands/week/end.js'; +import extend from './subcommands/week/extend.js'; +import startWeek from './subcommands/week/start.js'; import type { Command } from './index.js'; +const seasonCommands = new Map([ + [startSeason.name, startSeason], + [endSeason.name, endSeason], +]); + +const weekCommands = new Map([ + [startWeek.name, startWeek], + [endWeek.name, endWeek], + [extend.name, extend], +]); + +const miscCommands = new Map([[leaderboard.name, leaderboard]]); + export default { data: new SlashCommandBuilder() .setName('rmfp') .setDescription('Perform various RMFP-related tasks.') .addSubcommandGroup( new SlashCommandSubcommandGroupBuilder() - .setName('start') - .setDescription('Start a new RMFP session') + .setName('season') + .setDescription('Controls an RMFP season.') .addSubcommand(startSeason.subCommandOption) - .addSubcommand(startWeek.subCommandOption), + .addSubcommand(endSeason.subCommandOption), + ) + .addSubcommandGroup( + new SlashCommandSubcommandGroupBuilder() + .setName('week') + .setDescription('Controls an RMFP week.') + .addSubcommand(startWeek.subCommandOption) + .addSubcommand(extend.subCommandOption) + .addSubcommand(endWeek.subCommandOption), ) - .addSubcommand(endSeason.subCommandOption) - .addSubcommand(extend.subCommandOption) .addSubcommand(leaderboard.subCommandOption) .toJSON(), async execute(interaction) { @@ -26,24 +47,26 @@ export default { return; } - switch (interaction.options.getSubcommand()) { - case startSeason.name: - await startSeason.execute(interaction); - break; - case startWeek.name: - await startWeek.execute(interaction); - break; - case endSeason.name: - await endSeason.execute(interaction); - break; - case leaderboard.name: - await leaderboard.execute(interaction); - break; - case extend.name: - await extend.execute(interaction); - break; - default: - break; + if (interaction.options.getSubcommandGroup() === null) { + const subCommand = miscCommands.get(interaction.options.getSubcommand()); + await subCommand?.execute(interaction); + return; } + + let subCommand: SubCommand | undefined; + if (interaction.options.getSubcommandGroup() === 'season') { + subCommand = seasonCommands.get(interaction.options.getSubcommand()); + } else { + subCommand = weekCommands.get(interaction.options.getSubcommand()); + } + + if (subCommand === undefined) { + console.error( + `Could not find a subcommand matching for "${interaction.options.getSubcommandGroup()} ${interaction.options.getSubcommand()}"`, + ); + return; + } + + await subCommand.execute(interaction); }, } satisfies Command; diff --git a/src/commands/subcommands/endSeason.ts b/src/commands/subcommands/season/end.ts similarity index 87% rename from src/commands/subcommands/endSeason.ts rename to src/commands/subcommands/season/end.ts index d4c1670..4e10054 100644 --- a/src/commands/subcommands/endSeason.ts +++ b/src/commands/subcommands/season/end.ts @@ -1,14 +1,14 @@ import { ButtonBuilder, ButtonStyle, ActionRowBuilder, GuildScheduledEventStatus } from 'discord.js'; -import { isRMFPOwner, isRMFPOwnerFilter } from '../../common/isRMFPOwner.js'; -import { prisma } from '../../common/prisma.js'; -import type { SubCommand } from './index.js'; +import { isRMFPOwner, isRMFPOwnerFilter } from '../../../common/isRMFPOwner.js'; +import { prisma } from '../../../common/prisma.js'; +import type { SubCommand } from '../index.js'; /** * Ends the current RMFP season. */ export default { - subCommandOption: (subCommand) => subCommand.setName('end_season').setDescription('Ends this season of RMFP.'), - name: 'end_season', + subCommandOption: (subCommand) => subCommand.setName('end').setDescription('Ends this season of RMFP.'), + name: 'end', async execute(interaction) { if (!isRMFPOwner(interaction.guild, interaction.member)) { await interaction.reply({ diff --git a/src/commands/subcommands/start/season.ts b/src/commands/subcommands/season/start.ts similarity index 94% rename from src/commands/subcommands/start/season.ts rename to src/commands/subcommands/season/start.ts index 10fb2bc..26c1240 100644 --- a/src/commands/subcommands/start/season.ts +++ b/src/commands/subcommands/season/start.ts @@ -7,8 +7,8 @@ import type { SubCommand } from '../index.js'; * Begins a new RMFP season. Informs the user if one is ongoing that they must invoke `/rmfp end_season` first. */ export default { - subCommandOption: (subCommand) => subCommand.setName('season').setDescription('Starts a new season of RMFP!'), - name: 'season', + subCommandOption: (subCommand) => subCommand.setName('start').setDescription('Starts a new season of RMFP!'), + name: 'start', async execute(interaction) { if (!isRMFPOwner(interaction.guild, interaction.member)) { await interaction.reply({ diff --git a/src/commands/subcommands/week/end.ts b/src/commands/subcommands/week/end.ts new file mode 100644 index 0000000..cbc7431 --- /dev/null +++ b/src/commands/subcommands/week/end.ts @@ -0,0 +1,81 @@ +import process from 'node:process'; +import type { Week } from '@prisma/client'; +import type { Client } from 'discord.js'; +import { isRMFPOwner } from '../../../common/isRMFPOwner.js'; +import { prisma } from '../../../common/prisma.js'; +import type { SubCommand } from '../index.js'; + +const closeRMFPWeek = async (week: Week, client: Client) => { + const guild = await client.guilds.fetch(process.env.GUILD_ID!); + const rmfpOwnerRole = guild.roles.cache.get(process.env.RMFP_OWNER_ROLE_ID!)!; + if (rmfpOwnerRole === null) { + console.error(`[Close RMFP Week] No RMFP owner role was detected!`); + return; + } + + const rmfpOwners = rmfpOwnerRole.members.values(); + + console.log(`[Close RMFP Week] RMFP Owners found: ${rmfpOwnerRole.members.size}`); + + const winners = await prisma.week.winners(week.number); + const content = [ + `The winner(s) of RMFP S${week.seasonNumber}W${week.number} are:`, + ...winners.map((winner, idx) => `${idx + 1}. <@${winner.userId}>'s [message](${winner.messageUrl})`), + ].join('\n'); + + for (const owner of rmfpOwners) { + console.log(`Dispatching announcement message to: ${owner.user.username}`); + await owner.send(content); + } + + console.log('[Close RMFP Week] Marking week as ended...'); + await prisma.week.update({ + where: { + id: week.id, + }, + data: { + ended: true, + }, + }); + console.log('[Close RMFP Week] Week has been ended.'); +}; + +/** + * Starts a new RMFP week with the provided theme. Informs the user that they must end an ongoing week, if there is one. + */ +export default { + subCommandOption: (subCommand) => subCommand.setName('end').setDescription('Ends the active week of RMFP.'), + name: 'end', + async execute(interaction) { + if (!isRMFPOwner(interaction.guild, interaction.member)) { + await interaction.reply({ + content: 'Only the owner of RMFP may end the week.', + ephemeral: true, + }); + return; + } + + const currentSeason = await prisma.season.current(); + + if (currentSeason === null) { + await interaction.reply({ + content: + "There's no RMFP season ongoing. You need to start a season (`/rmfp start season`) before running this command.", + ephemeral: true, + }); + return; + } + + const currentWeek = await prisma.week.current(); + if (currentWeek === null) { + await interaction.reply({ + content: "There's no RMFP week ongoing. This command will do nothing.", + ephemeral: true, + }); + return; + } + + await interaction.reply({ content: 'Ending the current week!', ephemeral: true }); + await closeRMFPWeek(currentWeek, interaction.client); + }, +} satisfies SubCommand; diff --git a/src/commands/subcommands/extend.ts b/src/commands/subcommands/week/extend.ts similarity index 89% rename from src/commands/subcommands/extend.ts rename to src/commands/subcommands/week/extend.ts index 91649f3..bc36f9e 100644 --- a/src/commands/subcommands/extend.ts +++ b/src/commands/subcommands/week/extend.ts @@ -1,7 +1,7 @@ import { Temporal } from '@js-temporal/polyfill'; -import { isRMFPOwner } from '../../common/isRMFPOwner.js'; -import { prisma } from '../../common/prisma.js'; -import type { SubCommand } from './index.js'; +import { isRMFPOwner } from '../../../common/isRMFPOwner.js'; +import { prisma } from '../../../common/prisma.js'; +import type { SubCommand } from '../index.js'; export default { subCommandOption: (subCommand) => diff --git a/src/commands/subcommands/start/week.ts b/src/commands/subcommands/week/start.ts similarity index 98% rename from src/commands/subcommands/start/week.ts rename to src/commands/subcommands/week/start.ts index 96b7878..a77cb23 100644 --- a/src/commands/subcommands/start/week.ts +++ b/src/commands/subcommands/week/start.ts @@ -15,12 +15,12 @@ const THEME_OPTION = 'theme'; export default { subCommandOption: (subCommand) => subCommand - .setName('week') + .setName('start') .setDescription('Starts a new week of RMFP.') .addStringOption((option) => option.setName(THEME_OPTION).setDescription("What's this week's theme?").setRequired(true), ), - name: 'week', + name: 'start', async execute(interaction) { if (!isRMFPOwner(interaction.guild, interaction.member)) { await interaction.reply({ diff --git a/src/index.ts b/src/index.ts index 4bf76f5..8d8f059 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import process from 'node:process'; import { URL } from 'node:url'; import { Client, GatewayIntentBits, Partials } from 'discord.js'; -import { loadCommands, loadEvents, loadJobs } from './util/loaders.js'; +import { loadCommands, loadEvents } from './util/loaders.js'; import { registerEvents } from './util/registerEvents.js'; // Initialize the client @@ -18,10 +18,8 @@ const client = new Client({ // Load the events and commands const eventsUrl = new URL('events/', import.meta.url); const commandsUrl = new URL('commands/', import.meta.url); -const jobsUrl = new URL('jobs/', import.meta.url); const events = await loadEvents(eventsUrl); const commands = await loadCommands(commandsUrl); -await loadJobs(jobsUrl); // Register the event handlers registerEvents(commands, events, client); diff --git a/src/jobs/index.ts b/src/jobs/index.ts deleted file mode 100644 index f1d1da3..0000000 --- a/src/jobs/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CronJobParams } from 'cron'; -import type { StructurePredicate } from '../util/loaders.ts'; - -export type Job = Pick; -// Defines the predicate to check if an object is a valid Event type. -export const predicate: StructurePredicate = (structure): structure is Job => - Boolean(structure) && - typeof structure === 'object' && - 'cronTime' in structure! && - 'onTick' in structure! && - 'start' in structure! && - 'timeZone' in structure! && - typeof structure.cronTime === 'string' && - typeof structure.onTick === 'function' && - typeof structure.start === 'boolean' && - typeof structure.timeZone === 'string'; diff --git a/src/jobs/pollWeek.ts b/src/jobs/pollWeek.ts deleted file mode 100644 index 51d505c..0000000 --- a/src/jobs/pollWeek.ts +++ /dev/null @@ -1,75 +0,0 @@ -import process from 'node:process'; -import { Temporal } from '@js-temporal/polyfill'; -import type { Week } from '@prisma/client'; -import { Client, GatewayIntentBits, Partials } from 'discord.js'; -import { prisma } from '../common/prisma.js'; -import type { Job } from './index.js'; - -const closeRMFPWeek = async (week: Week, client: Client) => { - const guild = await client.guilds.fetch(process.env.GUILD_ID!); - const rmfpOwnerRole = await guild.roles.fetch(process.env.RMFP_OWNER_ROLE_ID!); - if (rmfpOwnerRole === null) { - console.error(`[Close RMFP Week] No RMFP owner role was detected!`); - return; - } - - const rmfpOwners = rmfpOwnerRole.members.values(); - - console.log(`[Close RMFP Week] RMFP Owners found`); - - const winners = await prisma.week.winners(week.number); - const content = [ - `The winner(s) of RMFP S${week.seasonNumber}W${week.number} are:`, - ...winners.map((winner, idx) => `${idx + 1}. <@${winner.userId}>'s [message](${winner.messageUrl})`), - ].join('\n'); - - for (const owner of rmfpOwners) { - console.log(`Dispatching announcement message to: ${owner.user.username}`); - await owner.send(content); - } - - console.log('[Close RMFP Week] Marking week as ended...'); - await prisma.week.update({ - where: { - id: week.id, - }, - data: { - ended: true, - }, - }); - console.log('[Close RMFP Week] Week has been ended.'); -}; - -export default { - cronTime: '0 * * * * *', - start: true, - onTick: async () => { - console.log('[Poll Week] Checking for week expiration...'); - const week = await prisma.week.findFirst({ - where: { - ended: false, - }, - }); - - if (week === null) { - console.warn('[Poll Week] Week not found.'); - return; - } - - console.log(`[Poll Week] Week found. Expiration: ${week.scheduledEnd}`); - if (week.scheduledEnd.getTime() > Date.now()) { - return; - } - - console.log(`[Poll Week] Week has expired! Ending week.`); - - // The current week has ended -- Time to let the owners know! - const client = new Client({ - intents: [GatewayIntentBits.GuildMessages, GatewayIntentBits.Guilds], - partials: [Partials.Message, Partials.Channel], - }); - await client.login(process.env.DISCORD_TOKEN!); - await closeRMFPWeek(week, client); - }, - timeZone: 'UTC', -} satisfies Job; diff --git a/src/util/loaders.ts b/src/util/loaders.ts index 9fde8d3..5f1f6d9 100644 --- a/src/util/loaders.ts +++ b/src/util/loaders.ts @@ -1,13 +1,10 @@ import type { PathLike } from 'node:fs'; import { readdir, stat } from 'node:fs/promises'; import { URL } from 'node:url'; -import { CronJob } from 'cron'; import { predicate as commandPredicate } from '../commands/index.js'; import type { Command } from '../commands/index.ts'; import { predicate as eventPredicate } from '../events/index.js'; import type { Event } from '../events/index.js'; -import type { Job } from '../jobs/index.js'; -import { predicate as jobPredicate } from '../jobs/index.js'; /** * A predicate to check if the structure is valid @@ -77,10 +74,3 @@ export async function loadCommands(dir: PathLike, recursive = true): Promise { return loadStructures(dir, eventPredicate, recursive); } - -export async function loadJobs(dir: PathLike, recursive = true): Promise { - const jobs = await loadStructures(dir, jobPredicate, recursive); - return jobs.map( - (jobParams) => new CronJob(jobParams.cronTime, jobParams.onTick, undefined, jobParams.start, jobParams.timeZone), - ); -}