diff --git a/src/bot/commands/checkin/handlers/checkin-audit.ts b/src/bot/commands/checkin/handlers/checkin-audit.ts index 85bf8cf..17a0f3c 100644 --- a/src/bot/commands/checkin/handlers/checkin-audit.ts +++ b/src/bot/commands/checkin/handlers/checkin-audit.ts @@ -1,13 +1,13 @@ -import type { Command } from '@commands/command' import type { ChatInputCommandInteraction, Client } from 'discord.js' +import { registerCommand } from '@commands/registry' import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' -import { CHECKIN_AUDIT_ID } from '@events/interaction-create/checkin/handlers/checkin-audit-modal' +import { CHECKIN_AUDIT_ID } from '@events/interaction-create/checkin/handlers/audit-modal' import { createCheckinReviewModal, encodeSnowflake, getCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { log } from '@utils/logger' import { SlashCommandBuilder } from 'discord.js' -import { CheckinAudit } from '../../../events/interaction-create/checkin/validators/checkin-audit' +import { CheckinAudit } from '../../../events/interaction-create/checkin/validators/audit' export class CheckinAuditError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -15,7 +15,7 @@ export class CheckinAuditError extends DiscordBaseError { } } -export default { +registerCommand({ data: new SlashCommandBuilder() .setName('checkin-audit') .setDescription('Review an old check-in using its public ID.') @@ -57,4 +57,4 @@ export default { else log.error(`Failed to handle: ${CheckinAudit.ERR.UnexpectedCheckinAudit}: ${err}`) } }, -} as Command +}) diff --git a/src/bot/commands/checkin/handlers/checkin-status.ts b/src/bot/commands/checkin/handlers/checkin-status.ts index 111cf39..909a5c2 100644 --- a/src/bot/commands/checkin/handlers/checkin-status.ts +++ b/src/bot/commands/checkin/handlers/checkin-status.ts @@ -1,5 +1,5 @@ -import type { Command } from '@commands/command' import type { ChatInputCommandInteraction, Client, GuildMember } from 'discord.js' +import { registerCommand } from '@commands/registry' import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE, GRINDER_ROLE } from '@config/discord' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' @@ -13,7 +13,7 @@ export class CheckinStatusError extends DiscordBaseError { } } -export default { +registerCommand({ data: new SlashCommandBuilder() .setName('checkin-status') .setDescription('Check your current daily check-in and streak status.'), @@ -47,4 +47,4 @@ export default { else log.error(`Failed to handle: ${CheckinStatus.ERR.UnexpectedCheckinStatus}: ${err}`) } }, -} as Command +}) diff --git a/src/bot/commands/checkin/handlers/checkin.ts b/src/bot/commands/checkin/handlers/checkin.ts index 432dc16..859949f 100644 --- a/src/bot/commands/checkin/handlers/checkin.ts +++ b/src/bot/commands/checkin/handlers/checkin.ts @@ -1,8 +1,8 @@ -import type { Command } from '@commands/command' import type { ChatInputCommandInteraction } from 'discord.js' +import { registerCommand } from '@commands/registry' import { CHECKIN_CHANNEL } from '@config/discord' -import { CHECKIN_ID } from '@events/interaction-create/checkin/handlers/checkin-modal' -import { Checkin } from '@events/interaction-create/checkin/validators/checkin' +import { CHECKIN_ID } from '@events/interaction-create/checkin/handlers/modal' +import { Checkin } from '@events/interaction-create/checkin/validators' import { encodeSnowflake, getCustomId } from '@utils/component' import { getAttachments, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' @@ -16,7 +16,7 @@ export class CheckinError extends DiscordBaseError { } } -export default { +registerCommand({ data: new SlashCommandBuilder() .setName('checkin') .setDescription('Daily grind check-in.') @@ -67,4 +67,4 @@ export default { else log.error(`Failed to handle: ${Checkin.ERR.UnexpectedCheckin}: ${err}`) } }, -} as Command +}) diff --git a/src/bot/commands/checkin/validators/checkin-status.ts b/src/bot/commands/checkin/validators/checkin-status.ts index 102e750..f77da52 100644 --- a/src/bot/commands/checkin/validators/checkin-status.ts +++ b/src/bot/commands/checkin/validators/checkin-status.ts @@ -3,7 +3,7 @@ import type { Checkin as CheckinType } from '@type/checkin' import type { User } from '@type/user' import type { EmbedBuilder, Guild } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' -import { Checkin } from '@events/interaction-create/checkin/validators/checkin' +import { Checkin } from '@events/interaction-create/checkin/validators' import { createEmbed } from '@utils/component' import { DiscordAssert } from '@utils/discord' import { DUMMY } from '@utils/placeholder' diff --git a/src/bot/commands/command.d.ts b/src/bot/commands/command.d.ts index 8421c82..504bbc2 100644 --- a/src/bot/commands/command.d.ts +++ b/src/bot/commands/command.d.ts @@ -1,6 +1,6 @@ -import type { ChatInputCommandInteraction, Client } from 'discord.js' +import type { ChatInputCommandInteraction, Client, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder } from 'discord.js' export interface Command { - data: SlashCommandBuilder + data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder execute: (client: Client, interaction: ChatInputCommandInteraction) => Promise } diff --git a/src/bot/commands/embed/handlers/role-grant-create.ts b/src/bot/commands/embed/handlers/role-grant-create.ts index c8e36c0..4551ae2 100644 --- a/src/bot/commands/embed/handlers/role-grant-create.ts +++ b/src/bot/commands/embed/handlers/role-grant-create.ts @@ -1,5 +1,5 @@ -import type { Command } from '@commands/command' import type { ChatInputCommandInteraction, TextChannel } from 'discord.js' +import { registerCommand } from '@commands/registry' import { LabelBuilder, ModalBuilder, TextInputBuilder } from '@discordjs/builders' import { EMBED_ROLE_GRANT_CREATE_MODAL_ID } from '@events/interaction-create/embed/handlers/role-grant-create-modal' import { RoleGrantCreate } from '@events/interaction-create/embed/validators/role-grant-create' @@ -16,7 +16,7 @@ export class EmbedRoleGrantError extends DiscordBaseError { } } -export default { +registerCommand({ data: new SlashCommandBuilder() .setName('create-embed-role-grant') .setDescription('Create an embed in a channel w/ a role-grant button.') @@ -103,4 +103,4 @@ export default { else log.error(`Failed to handle ${EMBED_ROLE_GRANT_CREATE_MODAL_ID}: ${RoleGrantCreate.ERR.UnexpectedRoleGrantCreate}: ${err}`) } }, -} as Command +}) diff --git a/src/bot/commands/index.ts b/src/bot/commands/index.ts index 8a897f7..1a1209e 100644 --- a/src/bot/commands/index.ts +++ b/src/bot/commands/index.ts @@ -1,9 +1,7 @@ -import type { Command } from '@commands/command' import type { Client } from 'discord.js' import path from 'node:path' -import { getModuleName, readFiles } from '@utils/io' import { log } from '@utils/logger' -import { Collection } from 'discord.js' +import { commandRegistry, loadCommands } from './registry' export class CommandError extends Error { constructor(message: string, options?: { cause?: unknown }) { @@ -13,23 +11,15 @@ export class CommandError extends Error { } } -export const COMMAND_PATH = path.basename(__dirname) -const files = readFiles(__dirname) +export const COMMAND_PATH = path.join(__dirname) export async function registerCommands(client: Client) { - client.commands = new Collection() - - for (const file of files) { - const fileName = getModuleName(COMMAND_PATH, file) - log.info(`Registering command ${fileName}...`) - - try { - const { default: command } = await import(file) as { default: Command } - client.commands.set(command.data.name, command) - } - catch (err: any) { - const msg = err instanceof CommandError ? err.message : '❌ Something went wrong when importing the command' - log.error(`Failed to register a command: ${msg}: ${err.message}`) - } + try { + await loadCommands() + client.commands = commandRegistry + } + catch (err: any) { + const msg = err instanceof CommandError ? err.message : '❌ Something went wrong when importing the command' + log.error(`Failed to register an command: ${msg}: ${err.message}`) } } diff --git a/src/bot/commands/message/handlers/send.ts b/src/bot/commands/message/handlers/send.ts index 542d510..87d1189 100644 --- a/src/bot/commands/message/handlers/send.ts +++ b/src/bot/commands/message/handlers/send.ts @@ -1,5 +1,5 @@ -import type { Command } from '@commands/command' import type { ChatInputCommandInteraction, TextChannel } from 'discord.js' +import { registerCommand } from '@commands/registry' import { MESSAGE_SEND_ID } from '@events/interaction-create/message/handlers/send-modal' import { Send } from '@events/interaction-create/message/validators/send' import { encodeSnowflake, getCustomId } from '@utils/component' @@ -29,7 +29,7 @@ for (let i = 1; i <= Send.ATTACHMENT_COUNT; i++) { ) } -export default { +registerCommand({ data, async execute(_, interaction: ChatInputCommandInteraction) { try { @@ -73,4 +73,4 @@ export default { else log.error(`Failed to handle: ${Send.ERR.UnexpectedSend}: ${err}`) } }, -} as Command +}) diff --git a/src/bot/commands/registry.ts b/src/bot/commands/registry.ts new file mode 100644 index 0000000..49625f8 --- /dev/null +++ b/src/bot/commands/registry.ts @@ -0,0 +1,34 @@ +import type { Command } from '@commands/command' +import path from 'node:path' +import { readFiles } from '@utils/io' +import { log } from '@utils/logger' +import { Collection } from 'discord.js' +import { COMMAND_PATH } from '.' + +export const commandRegistry = new Collection() + +export function registerCommand(command: Command) { + if (commandRegistry.has(command.data.name)) { + throw new Error(`Duplicate command name: ${command.data.name}`) + } + + commandRegistry.set(command.data.name, command) +} + +export async function loadCommands() { + const files = readFiles(COMMAND_PATH).filter(file => !file.endsWith('/index.ts')) + + await Promise.all( + files.map(async (file) => { + const fileName = path.basename(file, path.extname(file)) + + try { + await import(file) + log.info(`Loaded command file '${fileName}'`) + } + catch (err) { + log.error(`Failed to load command file ${file}: ${err}`) + } + }), + ) +} diff --git a/src/bot/commands/utility/handlers/ping.ts b/src/bot/commands/utility/handlers/ping.ts index 3bea304..089d94d 100644 --- a/src/bot/commands/utility/handlers/ping.ts +++ b/src/bot/commands/utility/handlers/ping.ts @@ -1,5 +1,5 @@ -import type { Command } from '@commands/command' import type { ChatInputCommandInteraction, TextChannel } from 'discord.js' +import { registerCommand } from '@commands/registry' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' import { log } from '@utils/logger' @@ -12,7 +12,7 @@ export class PingError extends DiscordBaseError { } } -export default { +registerCommand({ data: new SlashCommandBuilder() .setName('ping') .setDescription('Replies with pong!'), @@ -33,4 +33,4 @@ export default { else log.error(`Failed to handle: ${Ping.ERR.UnexpectedPing}: ${err}`) } }, -} as Command +}) diff --git a/src/bot/events/client-ready/entry.ts b/src/bot/events/client-ready/entry.ts new file mode 100644 index 0000000..8e22597 --- /dev/null +++ b/src/bot/events/client-ready/entry.ts @@ -0,0 +1,21 @@ +import type { Event } from '@events/event' +import type { Client } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { clientReadyHandlers } from './registry' + +export default { + name: Events.ClientReady, + once: true, + desc: 'Runs all registered ClientReady handlers.', + async exec(client: Client) { + for (const handler of clientReadyHandlers) { + try { + await handler.exec(client) + } + catch (err) { + log.error(`ClientReady handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts b/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts index e8a6940..2e14aa4 100644 --- a/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts +++ b/src/bot/events/client-ready/jobs/handlers/reset-grinder-roles.ts @@ -1,11 +1,12 @@ -import type { Event } from '@events/event' import type { Client } from 'discord.js' import process from 'node:process' import { GRIND_ASHES_CHANNEL } from '@config/discord' +import { registerClientReadyHandler } from '@events/client-ready/registry' +import { EVENT_PATH } from '@events/index' import { getChannel } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' import { log } from '@utils/logger' -import { Events } from 'discord.js' import cron from 'node-cron' import { ResetGrinderRoles } from '../validators/reset-grinder-roles' @@ -15,10 +16,11 @@ export class ResetGrinderRolesError extends DiscordBaseError { } } -export default { - name: Events.ClientReady, +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerClientReadyHandler({ desc: `Reset Grinder roles for users that didn't do a check-in yesterday or the check-in didn't approved.`, - once: true, + errorTag: () => `${moduleName}: ${ResetGrinderRoles.ERR.UnexpectedResetGrinderRoles}`, exec(client: Client) { try { cron.schedule('0 0 * * *', async () => { @@ -34,9 +36,9 @@ export default { log.success(ResetGrinderRoles.MSG.JobSuccess) }) } - catch (err: any) { + catch (err) { if (!(err instanceof DiscordBaseError)) - log.error(`Failed to handle ${ResetGrinderRoles.ERR.UnexpectedResetGrinderRoles}: ${err}`) + throw err } }, -} as Event +}) diff --git a/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts b/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts index a4119a9..a6a6e6a 100644 --- a/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts +++ b/src/bot/events/client-ready/jobs/messages/reset-grinder-roles.ts @@ -1,5 +1,5 @@ import type { GuildMember } from 'discord.js' -import { FLAMEWARDEN_ROLE, IGNITE_PATH_CHANNEL } from '@config/discord' +import { AUDIT_FLAME_CHANNEL, FLAMEWARDEN_ROLE, IGNITE_PATH_CHANNEL } from '@config/discord' import { DiscordAssert } from '@utils/discord' export class ResetGrinderRolesMessage extends DiscordAssert { @@ -22,10 +22,11 @@ Api bukanlah padam karena kelemahan, melainkan karena ia tak disirami pada waktu Namun jangan berduka, jalan ini selalu terbuka bagi mereka yang bersedia memulai kembali. Apabila Tuan/Nona berkehendak menyalakan api kembali, silakan kembali ke <#${IGNITE_PATH_CHANNEL}> dan bangkitlah dari awal. *Aksaria menanti mereka yang konsisten.* - + `, + GoodByeNotes: ` > Apabila *check-in* Tuan/Nona masih berada dalam status menunggu peninjauan (*waiting*) dan belum memperoleh keputusan hingga mendekati pergantian hari, maka dengan ini disampaikan ketentuan berikut: > 1. Jangan terlebih dahulu memasuki ⁠<#${IGNITE_PATH_CHANNEL}>, demi menjaga ketertiban alur peninjauan. -> 2. Silakan menjalankan perintah **\`/checkin-status\`** untuk menampilkan status *check-in* terakhir Tuan/Nona. +> 2. Silakan menjalankan perintah **\`/checkin-status\`** pada <#${AUDIT_FLAME_CHANNEL}> untuk menampilkan status *check-in* terakhir Tuan/Nona. > 3. Setelah pesan status tersebut muncul, berikan reaksi “❓” pada pesan tersebut. > 4. Dari reaksi tersebut, sebuah *thread* akan tercipta secara otomatis sebagai ruang klarifikasi dan komunikasi dengan <@&${FLAMEWARDEN_ROLE}>. > ⏳ Batas waktu penantian atas status WAITING adalah maksimal 1×24 jam sejak *check-in* diajukan. diff --git a/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts b/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts index 287406c..75e1017 100644 --- a/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts +++ b/src/bot/events/client-ready/jobs/validators/reset-grinder-roles.ts @@ -1,14 +1,42 @@ import type { PrismaClient } from '@generatedDB/client' import type { CheckinStreak } from '@type/checkin-streak' import type { User } from '@type/user' -import type { Guild, GuildMember, TextChannel } from 'discord.js' +import type { Guild, GuildMember, Interaction, TextChannel } from 'discord.js' import { getGrindRoles, GRINDER_ROLE } from '@config/discord' +import { GOODBYE_NOTE_BUTTON_ID, ResetGrinderRolesButtonError } from '@events/interaction-create/jobs/handlers/reset-grinder-roles-button' +import { decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' import { isDateToday, isDateYesterday } from '@utils/date' -import { sendAsBot } from '@utils/discord' +import { DiscordAssert, sendAsBot } from '@utils/discord' import { log } from '@utils/logger' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js' import { ResetGrinderRolesMessage } from '../messages/reset-grinder-roles' export class ResetGrinderRoles extends ResetGrinderRolesMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + ] + + static getButtonId(interaction: Interaction, customId: string) { + const [prefix, guildId] = decodeSnowflakes(customId) + + if (!guildId) + throw new ResetGrinderRolesButtonError(this.ERR.GuildMissing) + if (interaction.guildId !== guildId) + throw new ResetGrinderRolesButtonError(this.ERR.NotGuild) + + return { prefix, guildId } + } + + static generateButton(guildId: string): ActionRowBuilder { + const noteButtonId = getCustomId([GOODBYE_NOTE_BUTTON_ID, encodeSnowflake(guildId)]) + const noteButton = new ButtonBuilder() + .setCustomId(noteButtonId) + .setLabel('📜 Ketentuan Peninjauan Api') + .setStyle(ButtonStyle.Primary) + + return new ActionRowBuilder().addComponents(noteButton) + } + static hasValidCheckin(checkin?: { created_at: Date, status: string }): boolean { if (!checkin) return false @@ -35,22 +63,23 @@ export class ResetGrinderRoles extends ResetGrinderRolesMessage { static async validateUsers(prisma: PrismaClient, guild: Guild, channel: TextChannel, users: User[]) { for (const user of users) { - const lastCheckin = user.checkins?.[0] - if (this.hasValidCheckin(lastCheckin)) - continue - const checkinStreak = user.checkin_streaks?.[0] if (!checkinStreak) continue + const lastCheckin = checkinStreak.checkins?.[0] + if (this.hasValidCheckin(lastCheckin)) + continue + const member = await guild.members.fetch(user.discord_id) await this.removeGrinderRoles(member) await this.breakCheckinStreakAt(prisma, checkinStreak) + const button = this.generateButton(guild.id) await sendAsBot( null, channel, - { content: ResetGrinderRoles.MSG.GoodBye(member), allowedMentions: { users: [member.id], roles: [] } }, + { content: ResetGrinderRoles.MSG.GoodBye(member), components: [button], allowedMentions: { users: [member.id], roles: [] } }, ) log.info(this.MSG.RemoveGrinderRoleFrom(member)) @@ -61,14 +90,6 @@ export class ResetGrinderRoles extends ResetGrinderRolesMessage { const users = await prisma.user.findMany({ select: { discord_id: true, - checkins: { - select: { - status: true, - created_at: true, - }, - orderBy: { created_at: 'desc' }, - take: 1, - }, checkin_streaks: { orderBy: { first_date: 'desc' }, take: 1, diff --git a/src/bot/events/client-ready/registry.ts b/src/bot/events/client-ready/registry.ts new file mode 100644 index 0000000..0a95e65 --- /dev/null +++ b/src/bot/events/client-ready/registry.ts @@ -0,0 +1,13 @@ +import type { Client } from 'discord.js' + +export interface ClientReadyHandler { + desc: string + errorTag: () => string + exec: (client: Client) => Promise | void +} + +export const clientReadyHandlers: ClientReadyHandler[] = [] + +export function registerClientReadyHandler(handler: ClientReadyHandler) { + clientReadyHandlers.push(handler) +} diff --git a/src/bot/events/client-ready/say-hello/handlers/index.ts b/src/bot/events/client-ready/say-hello/handlers/index.ts new file mode 100644 index 0000000..48a638e --- /dev/null +++ b/src/bot/events/client-ready/say-hello/handlers/index.ts @@ -0,0 +1,22 @@ +import type { Client } from 'discord.js' +import { registerClientReadyHandler } from '@events/client-ready/registry' +import { EVENT_PATH } from '@events/index' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { SayHello } from '../validators' + +export class SayHelloError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('SayHelloError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerClientReadyHandler({ + desc: 'Say こんにちは for the first load.', + errorTag: () => `${moduleName}: ${SayHello.ERR.UnexpectedSayHello}`, + exec(client: Client) { + console.warn(`こんにちは、${client.user?.tag}`) + }, +}) diff --git a/src/bot/events/client-ready/say-hello/handlers/say-hello.ts b/src/bot/events/client-ready/say-hello/handlers/say-hello.ts deleted file mode 100644 index b7add49..0000000 --- a/src/bot/events/client-ready/say-hello/handlers/say-hello.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Event } from '@events/event' -import type { Client } from 'discord.js' -import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { SayHello } from '../validators/say-hello' - -export class SayHelloError extends DiscordBaseError { - constructor(message: string, options?: { cause?: unknown }) { - super('SayHelloError', message, options) - } -} - -export default { - name: Events.ClientReady, - desc: 'Say こんにちは for the first load.', - once: true, - exec(client: Client) { - try { - console.warn(`こんにちは、${client.user?.tag}`) - } - catch (err: any) { - log.error(`Failed to handle ${SayHello.ERR.UnexpectedSayHello}: ${err}`) - } - }, -} as Event diff --git a/src/bot/events/client-ready/say-hello/messages/say-hello.ts b/src/bot/events/client-ready/say-hello/messages/index.ts similarity index 100% rename from src/bot/events/client-ready/say-hello/messages/say-hello.ts rename to src/bot/events/client-ready/say-hello/messages/index.ts diff --git a/src/bot/events/client-ready/say-hello/validators/index.ts b/src/bot/events/client-ready/say-hello/validators/index.ts new file mode 100644 index 0000000..d4d8ce2 --- /dev/null +++ b/src/bot/events/client-ready/say-hello/validators/index.ts @@ -0,0 +1,4 @@ +import { SayHelloMessage } from '../messages' + +export class SayHello extends SayHelloMessage { +} diff --git a/src/bot/events/client-ready/say-hello/validators/say-hello.ts b/src/bot/events/client-ready/say-hello/validators/say-hello.ts deleted file mode 100644 index 68db5c8..0000000 --- a/src/bot/events/client-ready/say-hello/validators/say-hello.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SayHelloMessage } from '../messages/say-hello' - -export class SayHello extends SayHelloMessage { -} diff --git a/src/bot/events/guild-member-update/entry.ts b/src/bot/events/guild-member-update/entry.ts new file mode 100644 index 0000000..feed010 --- /dev/null +++ b/src/bot/events/guild-member-update/entry.ts @@ -0,0 +1,22 @@ +import type { Event } from '@events/event' +import type { GuildMember } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { guildMemberUpdateHandlers } from './registry' + +export default { + name: Events.GuildMemberUpdate, + desc: 'Dispatch all registered GuildMemberUpdate handlers.', + async exec(client, oldMember: GuildMember, newMember: GuildMember) { + for (const handler of guildMemberUpdateHandlers) { + try { + if (handler.match && !handler.match(oldMember, newMember)) + continue + await handler.exec(client, oldMember, newMember) + } + catch (err) { + log.error(`GuildMemberUpdate handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/src/bot/events/guild-member-update/grinder-role/handlers/grinder-role.ts b/src/bot/events/guild-member-update/grinder-role/handlers/index.ts similarity index 70% rename from src/bot/events/guild-member-update/grinder-role/handlers/grinder-role.ts rename to src/bot/events/guild-member-update/grinder-role/handlers/index.ts index 078f7cb..888d012 100644 --- a/src/bot/events/guild-member-update/grinder-role/handlers/grinder-role.ts +++ b/src/bot/events/guild-member-update/grinder-role/handlers/index.ts @@ -1,11 +1,10 @@ -import type { Event } from '@events/event' -import type { GuildMember } from 'discord.js' import { GRIND_ASHES_CHANNEL, GRINDER_ROLE } from '@config/discord' +import { registerGuildMemberUpdateHandler } from '@events/guild-member-update/registry' +import { EVENT_PATH } from '@events/index' import { getChannel, sendAsBot } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { GrinderRole } from '../validators/grinder-role' +import { getModuleName } from '@utils/io' +import { GrinderRole } from '../validators' export class GrinderRoleError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -13,10 +12,13 @@ export class GrinderRoleError extends DiscordBaseError { } } -export default { - name: Events.GuildMemberUpdate, +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerGuildMemberUpdateHandler({ desc: 'Watches grinder role assignment/removal for members on guild member update.', - async exec(_, oldMember: GuildMember, newMember: GuildMember) { + errorTag: () => `${moduleName}: ${GrinderRole.ERR.UnexpectedGrinderRole}`, + match: (_, newMember) => GrinderRole.isMemberHasRole(newMember, GRINDER_ROLE), + async exec(_, oldMember, newMember) { try { if (!newMember.guild) throw new GrinderRoleError(GrinderRole.ERR.NotGuild) @@ -36,7 +38,7 @@ export default { } catch (err: any) { if (!(err instanceof DiscordBaseError)) - log.error(`Failed to handle: ${GrinderRole.ERR.UnexpectedGrinderRole}: ${err}`) + throw err } }, -} as Event +}) diff --git a/src/bot/events/guild-member-update/grinder-role/messages/grinder-role.ts b/src/bot/events/guild-member-update/grinder-role/messages/index.ts similarity index 100% rename from src/bot/events/guild-member-update/grinder-role/messages/grinder-role.ts rename to src/bot/events/guild-member-update/grinder-role/messages/index.ts diff --git a/src/bot/events/guild-member-update/grinder-role/validators/grinder-role.ts b/src/bot/events/guild-member-update/grinder-role/validators/index.ts similarity index 74% rename from src/bot/events/guild-member-update/grinder-role/validators/grinder-role.ts rename to src/bot/events/guild-member-update/grinder-role/validators/index.ts index 61330ae..9fcde8b 100644 --- a/src/bot/events/guild-member-update/grinder-role/validators/grinder-role.ts +++ b/src/bot/events/guild-member-update/grinder-role/validators/index.ts @@ -1,5 +1,5 @@ import { DiscordAssert } from '@utils/discord' -import { GrinderRoleMessage } from '../messages/grinder-role' +import { GrinderRoleMessage } from '../messages' export class GrinderRole extends GrinderRoleMessage { static override BASE_PERMS = [ diff --git a/src/bot/events/guild-member-update/registry.ts b/src/bot/events/guild-member-update/registry.ts new file mode 100644 index 0000000..076609f --- /dev/null +++ b/src/bot/events/guild-member-update/registry.ts @@ -0,0 +1,14 @@ +import type { Client, GuildMember } from 'discord.js' + +export interface GuildMemberUpdateHandler { + desc: string + errorTag: () => string + match?: (oldMember: GuildMember, newMember: GuildMember) => boolean + exec: (client: Client, oldMember: GuildMember, newMember: GuildMember) => Promise | void +} + +export const guildMemberUpdateHandlers: GuildMemberUpdateHandler[] = [] + +export function registerGuildMemberUpdateHandler(handler: GuildMemberUpdateHandler) { + guildMemberUpdateHandlers.push(handler) +} diff --git a/src/bot/events/index.ts b/src/bot/events/index.ts index 817cda5..3d90b68 100644 --- a/src/bot/events/index.ts +++ b/src/bot/events/index.ts @@ -13,22 +13,26 @@ export class EventError extends Error { } export const EVENT_PATH = path.basename(__dirname) -const files = readFiles(__dirname) - export async function registerEvents(client: Client) { + const files = readFiles(__dirname) + for (const file of files) { - const { default: event } = await import(file) as { default: Event } const fileName = getModuleName(EVENT_PATH, file) - log.info(`Registering event ${fileName}...`) + const { default: event } = await import(file) as { default: Event } + if (!event) + continue try { - if (event.once) { - client.once(event.name, (...args) => event.exec(client, ...args)) - } - else { - client.on(event.name, (...args) => { - event.exec(client, ...args) - }) + if (event) { + log.info(`Registering event ${fileName}...`) + if (event.once) { + client.once(event.name, (...args) => event.exec(client, ...args)) + } + else { + client.on(event.name, (...args) => { + event.exec(client, ...args) + }) + } } } catch (err: any) { diff --git a/src/bot/events/interaction-create/checkin/handlers/checkin-approve-button.ts b/src/bot/events/interaction-create/checkin/handlers/approve-button.ts similarity index 69% rename from src/bot/events/interaction-create/checkin/handlers/checkin-approve-button.ts rename to src/bot/events/interaction-create/checkin/handlers/approve-button.ts index 29c0199..7664cec 100644 --- a/src/bot/events/interaction-create/checkin/handlers/checkin-approve-button.ts +++ b/src/bot/events/interaction-create/checkin/handlers/approve-button.ts @@ -1,13 +1,12 @@ -import type { Event } from '@events/event' -import type { Client, Interaction, TextChannel } from 'discord.js' +import type { TextChannel } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { Checkin } from '../validators/checkin' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' export class CheckinApproveButtonError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -15,19 +14,17 @@ export class CheckinApproveButtonError extends DiscordBaseError { } } +const moduleName = getModuleName(EVENT_PATH, __filename) export const CHECKIN_APPROVE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, - desc: 'Handles check-in approve button interactions and approves user check-in.', - async exec(client: Client, interaction: Interaction) { +registerInteractionHandler({ + desc: 'Approves a user check-in from the approve button.', + id: CHECKIN_APPROVE_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { if (!interaction.isButton()) return - const isValidComponent = Checkin.assertComponentId(interaction.customId, CHECKIN_APPROVE_BUTTON_ID) - if (!isValidComponent) - return - try { await interaction.deferUpdate() @@ -54,7 +51,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${CHECKIN_APPROVE_BUTTON_ID}: ${Checkin.ERR.UnexpectedButton}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/checkin/handlers/checkin-audit-modal.ts b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts similarity index 75% rename from src/bot/events/interaction-create/checkin/handlers/checkin-audit-modal.ts rename to src/bot/events/interaction-create/checkin/handlers/audit-modal.ts index 657b2d2..842fc86 100644 --- a/src/bot/events/interaction-create/checkin/handlers/checkin-audit-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/audit-modal.ts @@ -1,15 +1,14 @@ -import type { Event } from '@events/event' import type { CheckinStatusType } from '@type/checkin' -import type { Client, Interaction, TextChannel } from 'discord.js' +import type { TextChannel } from 'discord.js' import { CHECKIN_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { Checkin } from '../validators/checkin' -import { CheckinAudit } from '../validators/checkin-audit' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' +import { CheckinAudit } from '../validators/audit' export class CheckinAuditModalError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -17,19 +16,17 @@ export class CheckinAuditModalError extends DiscordBaseError { } } -export const CHECKIN_AUDIT_ID = generateCustomId(EVENT_PATH, __filename) +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_AUDIT_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Handles modal submissions for check-in audit modal forms.', - async exec(client: Client, interaction: Interaction) { + id: CHECKIN_AUDIT_ID, + errorTag: () => `${moduleName}: ${CheckinAudit.ERR.UnexpectedModal}`, + async exec(client, interaction) { if (!interaction.isModalSubmit()) return - const isValidComponent = CheckinAudit.assertComponentId(interaction.customId, CHECKIN_AUDIT_ID) - if (!isValidComponent) - return - try { await interaction.deferUpdate() @@ -59,7 +56,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${CHECKIN_AUDIT_ID}: ${CheckinAudit.ERR.UnexpectedModal}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/checkin/handlers/checkin-custom-button-modal.ts b/src/bot/events/interaction-create/checkin/handlers/custom-button-modal.ts similarity index 73% rename from src/bot/events/interaction-create/checkin/handlers/checkin-custom-button-modal.ts rename to src/bot/events/interaction-create/checkin/handlers/custom-button-modal.ts index 46363cc..0965780 100644 --- a/src/bot/events/interaction-create/checkin/handlers/checkin-custom-button-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/custom-button-modal.ts @@ -1,14 +1,13 @@ -import type { Event } from '@events/event' import type { CheckinStatusType } from '@type/checkin' -import type { Client, Interaction, TextChannel } from 'discord.js' +import type { TextChannel } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { Checkin } from '../validators/checkin' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' export class CheckinCustomButtonModalError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -16,19 +15,17 @@ export class CheckinCustomButtonModalError extends DiscordBaseError { } } -export const CHECKIN_CUSTOM_BUTTON_MODAL_ID = generateCustomId(EVENT_PATH, __filename) +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_CUSTOM_BUTTON_MODAL_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Handles modal submissions for the custom check-in review modal.', - async exec(client: Client, interaction: Interaction) { + id: CHECKIN_CUSTOM_BUTTON_MODAL_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedModal}`, + async exec(client, interaction) { if (!interaction.isModalSubmit()) return - const isValidComponent = Checkin.assertComponentId(interaction.customId, CHECKIN_CUSTOM_BUTTON_MODAL_ID) - if (!isValidComponent) - return - try { await interaction.deferUpdate() @@ -60,7 +57,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${CHECKIN_CUSTOM_BUTTON_MODAL_ID}: ${Checkin.ERR.UnexpectedModal}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/checkin/handlers/checkin-custom-button.ts b/src/bot/events/interaction-create/checkin/handlers/custom-button.ts similarity index 73% rename from src/bot/events/interaction-create/checkin/handlers/checkin-custom-button.ts rename to src/bot/events/interaction-create/checkin/handlers/custom-button.ts index f75b367..7fa3753 100644 --- a/src/bot/events/interaction-create/checkin/handlers/checkin-custom-button.ts +++ b/src/bot/events/interaction-create/checkin/handlers/custom-button.ts @@ -1,14 +1,13 @@ -import type { Event } from '@events/event' -import type { Client, Interaction, TextChannel } from 'discord.js' +import type { TextChannel } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { createCheckinReviewModal, encodeSnowflake, generateCustomId, getCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { Checkin } from '../validators/checkin' -import { CHECKIN_CUSTOM_BUTTON_MODAL_ID } from './checkin-custom-button-modal' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' +import { CHECKIN_CUSTOM_BUTTON_MODAL_ID } from './custom-button-modal' export class CheckinCustomButtonError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -16,19 +15,17 @@ export class CheckinCustomButtonError extends DiscordBaseError { } } +const moduleName = getModuleName(EVENT_PATH, __filename) export const CHECKIN_CUSTOM_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Opens review modal for a check-in', - async exec(client: Client, interaction: Interaction) { + id: CHECKIN_CUSTOM_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { if (!interaction.isButton()) return - const isValid = Checkin.assertComponentId(interaction.customId, CHECKIN_CUSTOM_BUTTON_ID) - if (!isValid) - return - try { if (!interaction.inCachedGuild()) throw new CheckinCustomButtonError(Checkin.ERR.NotGuild) @@ -54,7 +51,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${CHECKIN_CUSTOM_BUTTON_ID}: ${Checkin.ERR.UnexpectedButton}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/checkin/handlers/checkin-modal.ts b/src/bot/events/interaction-create/checkin/handlers/modal.ts similarity index 80% rename from src/bot/events/interaction-create/checkin/handlers/checkin-modal.ts rename to src/bot/events/interaction-create/checkin/handlers/modal.ts index 14db89d..2b1cb4f 100644 --- a/src/bot/events/interaction-create/checkin/handlers/checkin-modal.ts +++ b/src/bot/events/interaction-create/checkin/handlers/modal.ts @@ -1,13 +1,12 @@ -import type { Event } from '@events/event' -import type { Attachment, Client, GuildMember, Interaction, Message } from 'discord.js' +import type { Attachment, GuildMember, Message } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId, tempStore } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { Checkin } from '../validators/checkin' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' export class CheckinModalError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -15,19 +14,17 @@ export class CheckinModalError extends DiscordBaseError { } } -export const CHECKIN_ID = generateCustomId(EVENT_PATH, __filename) +const moduleName = getModuleName(EVENT_PATH, __filename) +export const CHECKIN_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Handles modal submissions for check-in modal forms.', - async exec(client: Client, interaction: Interaction) { + id: CHECKIN_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedModal}`, + async exec(client, interaction) { if (!interaction.isModalSubmit()) return - const isValidComponent = Checkin.assertComponentId(interaction.customId, CHECKIN_ID) - if (!isValidComponent) - return - try { if (!interaction.inCachedGuild()) throw new CheckinModalError(Checkin.ERR.NotGuild) @@ -81,7 +78,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${CHECKIN_ID}: ${Checkin.ERR.UnexpectedModal}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/checkin/handlers/checkin-reject-button.ts b/src/bot/events/interaction-create/checkin/handlers/reject-button.ts similarity index 73% rename from src/bot/events/interaction-create/checkin/handlers/checkin-reject-button.ts rename to src/bot/events/interaction-create/checkin/handlers/reject-button.ts index 0a728da..ae80cea 100644 --- a/src/bot/events/interaction-create/checkin/handlers/checkin-reject-button.ts +++ b/src/bot/events/interaction-create/checkin/handlers/reject-button.ts @@ -1,13 +1,12 @@ -import type { Event } from '@events/event' -import type { Client, Interaction, TextChannel } from 'discord.js' +import type { TextChannel } from 'discord.js' import { FLAMEWARDEN_ROLE } from '@config/discord' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId } from '@utils/component' import { sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' -import { Checkin } from '../validators/checkin' +import { getModuleName } from '@utils/io' +import { Checkin } from '../validators' export class CheckinRejectButtonError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -15,19 +14,17 @@ export class CheckinRejectButtonError extends DiscordBaseError { } } +const moduleName = getModuleName(EVENT_PATH, __filename) export const CHECKIN_REJECT_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Handles check-in reject button interactions and rejects user check-in.', - async exec(client: Client, interaction: Interaction) { + id: CHECKIN_REJECT_BUTTON_ID, + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedButton}`, + async exec(client, interaction) { if (!interaction.isButton()) return - const isValidComponent = Checkin.assertComponentId(interaction.customId, CHECKIN_REJECT_BUTTON_ID) - if (!isValidComponent) - return - try { await interaction.deferUpdate() @@ -54,7 +51,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${CHECKIN_REJECT_BUTTON_ID}: ${Checkin.ERR.UnexpectedButton}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/checkin/messages/checkin-audit.ts b/src/bot/events/interaction-create/checkin/messages/audit.ts similarity index 100% rename from src/bot/events/interaction-create/checkin/messages/checkin-audit.ts rename to src/bot/events/interaction-create/checkin/messages/audit.ts diff --git a/src/bot/events/interaction-create/checkin/messages/checkin.ts b/src/bot/events/interaction-create/checkin/messages/index.ts similarity index 100% rename from src/bot/events/interaction-create/checkin/messages/checkin.ts rename to src/bot/events/interaction-create/checkin/messages/index.ts diff --git a/src/bot/events/interaction-create/checkin/validators/checkin-audit.ts b/src/bot/events/interaction-create/checkin/validators/audit.ts similarity index 95% rename from src/bot/events/interaction-create/checkin/validators/checkin-audit.ts rename to src/bot/events/interaction-create/checkin/validators/audit.ts index efea943..6881cdd 100644 --- a/src/bot/events/interaction-create/checkin/validators/checkin-audit.ts +++ b/src/bot/events/interaction-create/checkin/validators/audit.ts @@ -7,9 +7,9 @@ import { decodeSnowflakes } from '@utils/component' import { isDateToday } from '@utils/date' import { DiscordAssert } from '@utils/discord' import { PermissionsBitField } from 'discord.js' -import { CheckinAuditModalError } from '../handlers/checkin-audit-modal' -import { CheckinAuditMessage } from '../messages/checkin-audit' -import { Checkin } from './checkin' +import { CheckinAuditModalError } from '../handlers/audit-modal' +import { CheckinAuditMessage } from '../messages/audit' +import { Checkin } from '.' export class CheckinAudit extends CheckinAuditMessage { static override BASE_PERMS = [ diff --git a/src/bot/events/interaction-create/checkin/validators/checkin.ts b/src/bot/events/interaction-create/checkin/validators/index.ts similarity index 97% rename from src/bot/events/interaction-create/checkin/validators/checkin.ts rename to src/bot/events/interaction-create/checkin/validators/index.ts index 3c4244e..b0c56ed 100644 --- a/src/bot/events/interaction-create/checkin/validators/checkin.ts +++ b/src/bot/events/interaction-create/checkin/validators/index.ts @@ -8,19 +8,19 @@ import type { Attachment, EmbedBuilder, Guild, GuildMember, Interaction, Message import crypto from 'node:crypto' import { CheckinError } from '@commands/checkin/handlers/checkin' import { AURA_FARMING_CHANNEL, CHECKIN_CHANNEL, GRINDER_ROLE } from '@config/discord' -import { SubmittedCheckinError } from '@events/message-reaction-add/checkin/handlers/submitted-checkin' +import { SubmittedCheckinError } from '@events/message-reaction-add/checkin/handlers/submitted' import { createEmbed, decodeSnowflakes, encodeSnowflake, getCustomId } from '@utils/component' import { isDateToday, isDateYesterday } from '@utils/date' import { DiscordAssert, getChannel, sendAsBot } from '@utils/discord' import { attachNewGrindRole, getGrindRoleByStreakCount } from '@utils/discord/roles' import { DUMMY } from '@utils/placeholder' import { ActionRowBuilder, ButtonBuilder, ButtonStyle, messageLink, PermissionsBitField } from 'discord.js' -import { CHECKIN_APPROVE_BUTTON_ID } from '../handlers/checkin-approve-button' -import { CHECKIN_CUSTOM_BUTTON_ID } from '../handlers/checkin-custom-button' -import { CheckinCustomButtonModalError } from '../handlers/checkin-custom-button-modal' -import { CheckinModalError } from '../handlers/checkin-modal' -import { CHECKIN_REJECT_BUTTON_ID } from '../handlers/checkin-reject-button' -import { CheckinMessage } from '../messages/checkin' +import { CHECKIN_APPROVE_BUTTON_ID } from '../handlers/approve-button' +import { CHECKIN_CUSTOM_BUTTON_ID } from '../handlers/custom-button' +import { CheckinCustomButtonModalError } from '../handlers/custom-button-modal' +import { CheckinModalError } from '../handlers/modal' +import { CHECKIN_REJECT_BUTTON_ID } from '../handlers/reject-button' +import { CheckinMessage } from '../messages' export class Checkin extends CheckinMessage { static override BASE_PERMS = [ @@ -228,7 +228,7 @@ export class Checkin extends CheckinMessage { if (!checkin) throw new SubmittedCheckinError(this.ERR.PlainMessage) - await Checkin.setAttachments(prisma, checkin) + await this.setAttachments(prisma, checkin) return checkin } diff --git a/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts b/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts index 3db3a53..9c4b35f 100644 --- a/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts +++ b/src/bot/events/interaction-create/embed/handlers/role-grant-create-button.ts @@ -1,11 +1,10 @@ -import type { Event } from '@events/event' -import type { GuildMember, Interaction, TextChannel } from 'discord.js' +import type { GuildMember, TextChannel } from 'discord.js' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId } from '@utils/component' import { getRole, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' +import { getModuleName } from '@utils/io' import { RoleGrantCreate } from '../validators/role-grant-create' export class EmbedRoleGrantButtonError extends DiscordBaseError { @@ -14,19 +13,17 @@ export class EmbedRoleGrantButtonError extends DiscordBaseError { } } -export const EMBED_ROLE_GRANT_CREATE_BUTTON_ID = generateCustomId(EVENT_PATH, __filename) +const moduleName = getModuleName(EVENT_PATH, __filename) +export const EMBED_ROLE_GRANT_CREATE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Handles role assignment button interactions and adds a role for users.', - async exec(_, interaction: Interaction) { + id: EMBED_ROLE_GRANT_CREATE_BUTTON_ID, + errorTag: () => `${moduleName}: ${RoleGrantCreate.ERR.UnexpectedButton}`, + async exec(_, interaction) { if (!interaction.isButton()) return - const isValidComponent = RoleGrantCreate.assertComponentId(interaction.customId, EMBED_ROLE_GRANT_CREATE_BUTTON_ID) - if (!isValidComponent) - return - try { if (!interaction.inCachedGuild()) throw new EmbedRoleGrantButtonError(RoleGrantCreate.ERR.NotGuild) @@ -49,7 +46,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${EMBED_ROLE_GRANT_CREATE_BUTTON_ID}: ${RoleGrantCreate.ERR.UnexpectedButton}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts b/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts index 099683f..6dd72d8 100644 --- a/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts +++ b/src/bot/events/interaction-create/embed/handlers/role-grant-create-modal.ts @@ -1,11 +1,10 @@ -import type { Event } from '@events/event' -import type { Interaction } from 'discord.js' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { createEmbed, encodeSnowflake, generateCustomId, getCustomId } from '@utils/component' import { getChannel, getRole, sendAsBot, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Events } from 'discord.js' +import { getModuleName } from '@utils/io' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js' import { RoleGrantCreate } from '../validators/role-grant-create' import { EMBED_ROLE_GRANT_CREATE_BUTTON_ID } from './role-grant-create-button' @@ -15,19 +14,17 @@ export class EmbedRoleGrantModalError extends DiscordBaseError { } } -export const EMBED_ROLE_GRANT_CREATE_MODAL_ID = generateCustomId(EVENT_PATH, __filename) +const moduleName = getModuleName(EVENT_PATH, __filename) +export const EMBED_ROLE_GRANT_CREATE_MODAL_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, +registerInteractionHandler({ desc: 'Handles modal submissions for creating an embed with a role-grant button.', - async exec(_, interaction: Interaction) { + id: EMBED_ROLE_GRANT_CREATE_MODAL_ID, + errorTag: () => `${moduleName}: ${RoleGrantCreate.ERR.UnexpectedModal}`, + async exec(_, interaction) { if (!interaction.isModalSubmit()) return - const isValidComponent = RoleGrantCreate.assertComponentId(interaction.customId, EMBED_ROLE_GRANT_CREATE_MODAL_ID) - if (!isValidComponent) - return - try { if (!interaction.inCachedGuild()) throw new EmbedRoleGrantModalError(RoleGrantCreate.ERR.NotGuild) @@ -68,7 +65,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${EMBED_ROLE_GRANT_CREATE_MODAL_ID}: ${RoleGrantCreate.ERR.UnexpectedModal}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/entry.ts b/src/bot/events/interaction-create/entry.ts new file mode 100644 index 0000000..fa4848c --- /dev/null +++ b/src/bot/events/interaction-create/entry.ts @@ -0,0 +1,37 @@ +import type { Event } from '@events/event' +import type { Interaction } from 'discord.js' +import { ARCHFYRE_ROLE } from '@config/discord' +import { decodeSnowflakes } from '@utils/component' +import { sendReply } from '@utils/discord' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { interactionHandlerMap, interactionHandlers } from './registry' + +export default { + name: Events.InteractionCreate, + desc: 'Handles Discord InteractionCreate events and delegates them to registered handlers.', + async exec(client, interaction: Interaction) { + if ('customId' in interaction && interaction.customId) { + const [prefix] = decodeSnowflakes(interaction.customId) + + const handler = interactionHandlerMap.get(prefix) + if (handler) { + try { + await handler.exec(client, interaction) + return + } + catch (err) { + await sendReply(interaction, `❓ Something weird happen... kindly contact <@&${ARCHFYRE_ROLE}> :)`) + log.error(`InteractionCreate handler failed ${handler.errorTag()}: ${err}`) + } + } + } + + for (const handler of interactionHandlers) { + if (handler.match && !handler.match(interaction)) + continue + + await handler.exec(client, interaction) + } + }, +} as Event diff --git a/src/bot/events/interaction-create/execute-command.ts b/src/bot/events/interaction-create/execute-command.ts deleted file mode 100644 index 83484a7..0000000 --- a/src/bot/events/interaction-create/execute-command.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Command } from '@commands/command' -import type { Event } from '@events/event' -import type { Client, Interaction } from 'discord.js' -import { Events, MessageFlags } from 'discord.js' - -export default { - name: Events.InteractionCreate, - desc: 'Executing a command when an interaction is created.', - async exec(client: Client, interaction: Interaction) { - if (!interaction.isChatInputCommand()) - return - const command: Command | undefined = interaction.client.commands.get(interaction.commandName) - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`) - return - } - - try { - await command.execute(client, interaction) - } - catch (error) { - console.error(error) - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral }) - } - else { - await interaction.reply({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral }) - } - } - }, -} as Event diff --git a/src/bot/events/interaction-create/execute/handlers/command.ts b/src/bot/events/interaction-create/execute/handlers/command.ts new file mode 100644 index 0000000..20eea85 --- /dev/null +++ b/src/bot/events/interaction-create/execute/handlers/command.ts @@ -0,0 +1,34 @@ +import type { Command } from '@commands/command' +import { EVENT_PATH } from '@events/index' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { registerInteractionHandler } from '../../registry' +import { ExecuteCommand } from '../validators/command' + +export class ExecuteCommandError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ExecuteCommandError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerInteractionHandler({ + desc: 'Executing a command when an interaction is created.', + errorTag: () => `${moduleName}: ${ExecuteCommand.ERR.UnexpectedExecuteCommand}`, + async exec(client, interaction) { + if (!interaction.isChatInputCommand()) + return + + try { + const command: Command = ExecuteCommand.getCommand(interaction) + await command.execute(client, interaction) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/src/bot/events/interaction-create/execute/messages/command.ts b/src/bot/events/interaction-create/execute/messages/command.ts new file mode 100644 index 0000000..4bc5cfe --- /dev/null +++ b/src/bot/events/interaction-create/execute/messages/command.ts @@ -0,0 +1,9 @@ +import { DiscordAssert } from '@utils/discord' + +export class ExecuteCommandMessage extends DiscordAssert { + static override readonly ERR = { + ...DiscordAssert.ERR, + NoMatchingCommand: (commandName: string) => `❌ No command matching ${commandName} was found`, + UnexpectedExecuteCommand: '❌ Something went wrong during execute command', + } +} diff --git a/src/bot/events/interaction-create/execute/validators/command.ts b/src/bot/events/interaction-create/execute/validators/command.ts new file mode 100644 index 0000000..c63a32d --- /dev/null +++ b/src/bot/events/interaction-create/execute/validators/command.ts @@ -0,0 +1,22 @@ +import type { Command } from '@commands/command' +import type { ChatInputCommandInteraction } from 'discord.js' +import { ExecuteCommandError } from '@events/interaction-create/execute/handlers/command' +import { DiscordAssert } from '@utils/discord' +import { PermissionsBitField } from 'discord.js' +import { ExecuteCommandMessage } from '../messages/command' + +export class ExecuteCommand extends ExecuteCommandMessage { + static override BASE_PERMS = [ + ...DiscordAssert.BASE_PERMS, + PermissionsBitField.Flags.UseApplicationCommands, + ] + + static getCommand(interaction: ChatInputCommandInteraction) { + const command: Command | undefined = interaction.client.commands.get(interaction.commandName) + if (!command) { + throw new ExecuteCommandError(this.ERR.NoMatchingCommand(interaction.commandName)) + } + + return command + } +} diff --git a/src/bot/events/interaction-create/jobs/handlers/reset-grinder-roles-button.ts b/src/bot/events/interaction-create/jobs/handlers/reset-grinder-roles-button.ts new file mode 100644 index 0000000..5bfa45c --- /dev/null +++ b/src/bot/events/interaction-create/jobs/handlers/reset-grinder-roles-button.ts @@ -0,0 +1,42 @@ +import type { TextChannel } from 'discord.js' +import { ResetGrinderRoles } from '@events/client-ready/jobs/validators/reset-grinder-roles' +import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' +import { generateCustomId } from '@utils/component' +import { sendReply } from '@utils/discord' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' + +export class ResetGrinderRolesButtonError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ResetGrinderRolesButtonError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) +export const GOODBYE_NOTE_BUTTON_ID = `${generateCustomId(EVENT_PATH, __filename)}` + +registerInteractionHandler({ + desc: 'Opens goodbye note modal for users losing Grinder roles.', + id: GOODBYE_NOTE_BUTTON_ID, + errorTag: () => `${moduleName}: ${ResetGrinderRoles.ERR.UnexpectedButton}`, + async exec(_, interaction) { + if (!interaction.isButton()) + return + + try { + if (!interaction.inCachedGuild()) + throw new ResetGrinderRolesButtonError(ResetGrinderRoles.ERR.NotGuild) + + const channel = interaction.channel as TextChannel + ResetGrinderRoles.assertMissPerms(interaction.client.user, channel) + + await sendReply(interaction, ResetGrinderRoles.MSG.GoodByeNotes) + } + catch (err: any) { + if (err instanceof DiscordBaseError) + await sendReply(interaction, err.message) + else throw err + } + }, +}) diff --git a/src/bot/events/interaction-create/message/handlers/send-modal.ts b/src/bot/events/interaction-create/message/handlers/send-modal.ts index 48400c1..8fe3f81 100644 --- a/src/bot/events/interaction-create/message/handlers/send-modal.ts +++ b/src/bot/events/interaction-create/message/handlers/send-modal.ts @@ -1,11 +1,10 @@ -import type { Event } from '@events/event' -import type { Attachment, Interaction } from 'discord.js' +import type { Attachment } from 'discord.js' import { EVENT_PATH } from '@events/index' +import { registerInteractionHandler } from '@events/interaction-create/registry' import { generateCustomId, tempStore } from '@utils/component' import { getChannel, sendAsBot, sendReply } from '@utils/discord' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' +import { getModuleName } from '@utils/io' import { Send } from '../validators/send' export class SendModalError extends DiscordBaseError { @@ -14,19 +13,17 @@ export class SendModalError extends DiscordBaseError { } } -export const MESSAGE_SEND_ID = generateCustomId(EVENT_PATH, __filename) +const moduleName = getModuleName(EVENT_PATH, __filename) +export const MESSAGE_SEND_ID = `${generateCustomId(EVENT_PATH, __filename)}` -export default { - name: Events.InteractionCreate, - desc: 'Handles modal submissions for creating an embed with a role-grant button.', - async exec(_, interaction: Interaction) { +registerInteractionHandler({ + desc: 'Handles message send modal submissions, posting messages (text/attachments) as the bot in the selected channel.', + id: MESSAGE_SEND_ID, + errorTag: () => `${moduleName}: ${Send.ERR.UnexpectedModal}`, + async exec(_, interaction) { if (!interaction.isModalSubmit()) return - const isValidComponent = Send.assertComponentId(interaction.customId, MESSAGE_SEND_ID) - if (!isValidComponent) - return - try { if (!interaction.inCachedGuild()) throw new SendModalError(Send.ERR.NotGuild) @@ -51,7 +48,7 @@ export default { catch (err: any) { if (err instanceof DiscordBaseError) await sendReply(interaction, err.message) - else log.error(`Failed to handle ${MESSAGE_SEND_ID}: ${Send.ERR.UnexpectedModal}: ${err}`) + else throw err } }, -} as Event +}) diff --git a/src/bot/events/interaction-create/registry.ts b/src/bot/events/interaction-create/registry.ts new file mode 100644 index 0000000..8f35d44 --- /dev/null +++ b/src/bot/events/interaction-create/registry.ts @@ -0,0 +1,19 @@ +import type { Client, Interaction } from 'discord.js' + +export interface InteractionHandler { + desc: string + id?: string + errorTag: () => string + match?: (interaction: Interaction) => boolean + exec: (client: Client, interaction: Interaction) => Promise | void +} + +export const interactionHandlerMap = new Map() +export const interactionHandlers: InteractionHandler[] = [] + +export function registerInteractionHandler(handler: InteractionHandler) { + if (handler.id) + interactionHandlerMap.set(handler.id, handler) + else + interactionHandlers.push(handler) +} diff --git a/src/bot/events/message-create/channel/handlers/check-in.ts b/src/bot/events/message-create/channel/handlers/check-in.ts index 4126fda..b2330ae 100644 --- a/src/bot/events/message-create/channel/handlers/check-in.ts +++ b/src/bot/events/message-create/channel/handlers/check-in.ts @@ -1,9 +1,11 @@ -import type { Event } from '@events/event' -import type { Message, TextChannel } from 'discord.js' +import type { TextChannel } from 'discord.js' import { CHECKIN_CHANNEL } from '@config/discord' +import { EVENT_PATH } from '@events/index' +import { registerMessageHandler } from '@events/message-create/registry' import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' import { log } from '@utils/logger' -import { ChannelType, Events } from 'discord.js' +import { ChannelType } from 'discord.js' import { CheckIn } from '../validators/check-in' export class CheckInError extends DiscordBaseError { @@ -12,10 +14,13 @@ export class CheckInError extends DiscordBaseError { } } -export default { - name: Events.MessageCreate, +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerMessageHandler({ desc: 'Handle messages in channel for Check In event.', - async exec(_, msg: Message) { + errorTag: () => `${moduleName}: ${CheckIn.ERR.UnexpectedCheckIn}`, + match: msg => msg.channel.id === CHECKIN_CHANNEL, + async exec(_, msg) { try { if (!msg.guild) throw new CheckInError(CheckIn.ERR.NotGuild) @@ -25,10 +30,6 @@ export default { if (channel.type !== ChannelType.GuildText) return - - if (channel.id !== CHECKIN_CHANNEL) - return - if (msg.author.bot) return @@ -37,7 +38,7 @@ export default { } catch (err: any) { if (!(err instanceof DiscordBaseError)) - log.error(`Failed to handle: ${CheckIn.ERR.UnexpectedCheckIn}: ${err}`) + throw err } }, -} as Event +}) diff --git a/src/bot/events/message-create/entry.ts b/src/bot/events/message-create/entry.ts new file mode 100644 index 0000000..606ff48 --- /dev/null +++ b/src/bot/events/message-create/entry.ts @@ -0,0 +1,22 @@ +import type { Event } from '@events/event' +import type { Message } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { messageHandlers } from './registry' + +export default { + name: Events.MessageCreate, + desc: 'Dispatch all registered MessageCreate handlers.', + async exec(client, msg: Message) { + for (const handler of messageHandlers) { + try { + if (handler.match && !handler.match(msg)) + continue + await handler.exec(client, msg) + } + catch (err) { + log.error(`Message handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/src/bot/events/message-create/im-fine/handlers/im-fine.ts b/src/bot/events/message-create/im-fine/handlers/im-fine.ts deleted file mode 100644 index 3c927a2..0000000 --- a/src/bot/events/message-create/im-fine/handlers/im-fine.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Event } from '@events/event' -import type { Message } from 'discord.js' -import { DiscordBaseError } from '@utils/discord/error' -import { Events } from 'discord.js' - -export class ImFineError extends DiscordBaseError { - constructor(message: string, options?: { cause?: unknown }) { - super('ImFineError', message, options) - } -} - -export default { - name: Events.MessageCreate, - desc: 'Replying to a user when the user\'s chat contains \'fine\' word.', - async exec(_, msg: Message) { - if (!msg.author.bot && msg.content.includes('fine')) - await msg.reply('gua i\'m fine😅') - }, -} as Event diff --git a/src/bot/events/message-create/im-fine/handlers/index.ts b/src/bot/events/message-create/im-fine/handlers/index.ts new file mode 100644 index 0000000..54f21d9 --- /dev/null +++ b/src/bot/events/message-create/im-fine/handlers/index.ts @@ -0,0 +1,22 @@ +import { EVENT_PATH } from '@events/index' +import { registerMessageHandler } from '@events/message-create/registry' +import { DiscordBaseError } from '@utils/discord/error' +import { getModuleName } from '@utils/io' +import { ImFine } from '../validators' + +export class ImFineError extends DiscordBaseError { + constructor(message: string, options?: { cause?: unknown }) { + super('ImFineError', message, options) + } +} + +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerMessageHandler({ + desc: 'Replying to a user when the user\'s chat contains \'fine\' word.', + errorTag: () => `${moduleName}: ${ImFine.ERR.UnexpectedImFine}`, + match: msg => !msg.author.bot && msg.content.includes('fine'), + async exec(_, msg) { + await msg.reply('gua I\'m fine😅') + }, +}) diff --git a/src/bot/events/message-create/im-fine/messages/im-fine.ts b/src/bot/events/message-create/im-fine/messages/index.ts similarity index 100% rename from src/bot/events/message-create/im-fine/messages/im-fine.ts rename to src/bot/events/message-create/im-fine/messages/index.ts diff --git a/src/bot/events/message-create/im-fine/validators/im-fine.ts b/src/bot/events/message-create/im-fine/validators/im-fine.ts deleted file mode 100644 index 13d4941..0000000 --- a/src/bot/events/message-create/im-fine/validators/im-fine.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ImFineMessage } from '../messages/im-fine' - -export class ImFine extends ImFineMessage { -} diff --git a/src/bot/events/message-create/im-fine/validators/index.ts b/src/bot/events/message-create/im-fine/validators/index.ts new file mode 100644 index 0000000..66454d5 --- /dev/null +++ b/src/bot/events/message-create/im-fine/validators/index.ts @@ -0,0 +1,4 @@ +import { ImFineMessage } from '../messages' + +export class ImFine extends ImFineMessage { +} diff --git a/src/bot/events/message-create/registry.ts b/src/bot/events/message-create/registry.ts new file mode 100644 index 0000000..5672005 --- /dev/null +++ b/src/bot/events/message-create/registry.ts @@ -0,0 +1,14 @@ +import type { Client, Message } from 'discord.js' + +export interface MessageHandler { + desc: string + errorTag: () => string + match?: (msg: Message) => boolean + exec: (client: Client, msg: Message) => Promise | void +} + +export const messageHandlers: MessageHandler[] = [] + +export function registerMessageHandler(handler: MessageHandler) { + messageHandlers.push(handler) +} diff --git a/src/bot/events/message-reaction-add/checkin/handlers/submitted-checkin.ts b/src/bot/events/message-reaction-add/checkin/handlers/submitted.ts similarity index 71% rename from src/bot/events/message-reaction-add/checkin/handlers/submitted-checkin.ts rename to src/bot/events/message-reaction-add/checkin/handlers/submitted.ts index bbc422c..fdd185d 100644 --- a/src/bot/events/message-reaction-add/checkin/handlers/submitted-checkin.ts +++ b/src/bot/events/message-reaction-add/checkin/handlers/submitted.ts @@ -1,10 +1,9 @@ -import type { Event } from '@events/event' -import type { Client, MessageReaction, PartialMessageReaction, User } from 'discord.js' import { CHECKIN_CHANNEL, FLAMEWARDEN_ROLE } from '@config/discord' -import { Checkin } from '@events/interaction-create/checkin/validators/checkin' +import { EVENT_PATH } from '@events/index' +import { Checkin } from '@events/interaction-create/checkin/validators' +import { registerReactionHandler } from '@events/message-reaction-add/registry' import { DiscordBaseError } from '@utils/discord/error' -import { log } from '@utils/logger' -import { Events } from 'discord.js' +import { getModuleName } from '@utils/io' export class SubmittedCheckinError extends DiscordBaseError { constructor(message: string, options?: { cause?: unknown }) { @@ -12,16 +11,18 @@ export class SubmittedCheckinError extends DiscordBaseError { } } -export default { - name: Events.MessageReactionAdd, +const moduleName = getModuleName(EVENT_PATH, __filename) + +registerReactionHandler({ desc: 'Handles user-submitted checkin submissions with reacted by Flamewarden whether approved or rejected.', - async exec(client: Client, reaction: MessageReaction | PartialMessageReaction, user: User) { + errorTag: () => `${moduleName}: ${Checkin.ERR.UnexpectedSubmittedCheckinMessage}`, + match: (_, user) => !user.bot, + async exec(client, reaction, user) { const message = reaction.message const guild = message.guild - if (user.bot) - return - if (!message.inGuild() || !guild) + if (!guild || !message.inGuild()) return + if (reaction.partial) await reaction.fetch() if (message.partial) @@ -45,7 +46,7 @@ export default { } catch (err: any) { if (!(err instanceof DiscordBaseError)) - log.error(`Failed to handle: ${Checkin.ERR.UnexpectedSubmittedCheckinMessage}: ${err}`) + throw err } }, -} as Event +}) diff --git a/src/bot/events/message-reaction-add/entry.ts b/src/bot/events/message-reaction-add/entry.ts new file mode 100644 index 0000000..5278896 --- /dev/null +++ b/src/bot/events/message-reaction-add/entry.ts @@ -0,0 +1,22 @@ +import type { Event } from '@events/event' +import type { MessageReaction, User } from 'discord.js' +import { log } from '@utils/logger' +import { Events } from 'discord.js' +import { reactionHandlers } from './registry' + +export default { + name: Events.MessageReactionAdd, + desc: 'Dispatch all registered MessageReactionAdd handlers.', + async exec(client, reaction: MessageReaction, user: User) { + for (const handler of reactionHandlers) { + try { + if (handler.match && !handler.match(reaction, user)) + continue + await handler.exec(client, reaction, user) + } + catch (err) { + log.error(`Reaction handler failed ${handler.errorTag()}: ${err}`) + } + } + }, +} as Event diff --git a/src/bot/events/message-reaction-add/registry.ts b/src/bot/events/message-reaction-add/registry.ts new file mode 100644 index 0000000..c112b95 --- /dev/null +++ b/src/bot/events/message-reaction-add/registry.ts @@ -0,0 +1,14 @@ +import type { Client, MessageReaction, User } from 'discord.js' + +export interface ReactionHandler { + desc: string + errorTag: () => string + match?: (reaction: MessageReaction, user: User) => boolean + exec: (client: Client, reaction: MessageReaction, user: User) => Promise | void +} + +export const reactionHandlers: ReactionHandler[] = [] + +export function registerReactionHandler(handler: ReactionHandler) { + reactionHandlers.push(handler) +} diff --git a/src/config/discord.ts b/src/config/discord.ts index 88ce3e9..8e031c1 100644 --- a/src/config/discord.ts +++ b/src/config/discord.ts @@ -13,8 +13,9 @@ export interface GrindRole { threshold: number } -export const GRINDER_ROLE = '1403320523756146768' +export const ARCHFYRE_ROLE = '1402625885684891658' export const FLAMEWARDEN_ROLE = '1403022712938561668' +export const GRINDER_ROLE = '1403320523756146768' const GRIND_ROLES: GrindRole[] = [ { diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index ebfeb6d..86b8c47 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -1,49 +1,16 @@ -import type { Command } from '@commands/command' -import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js' -import path from 'node:path' import process from 'node:process' -import { getModuleName, readFiles } from '@utils/io' +import { commandRegistry, loadCommands } from '@commands/registry' import { log } from '@utils/logger' import { REST, Routes } from 'discord.js' -const root = path.join(__dirname, 'bot/commands') - -async function loadCommands(): Promise { - const files = readFiles(root) - - log.base('🚀 Deploying commands...') - - const results: Array = await Promise.all( - files.map(async (file) => { - const fileName = getModuleName(root, file) - log.info(`Registering command ${fileName}...`) - try { - const { default: command } = (await import(file)) as { default: Command } - if ('data' in command && 'execute' in command) { - return command.data.toJSON() - } - else { - log.error(`The command at ${file} is missing a required "data" or "execute" property.`) - return null - } - } - catch (err) { - log.error(`Failed to import command at ${file}: ${err}`) - return null - } - }), - ) - - return results.filter( - (c): c is RESTPostAPIChatInputApplicationCommandsJSONBody => c !== null, - ) -} - async function main() { - const commands = await loadCommands() - const rest = new REST().setToken(process.env.APP_TOKEN!) + log.base('🚀 Deploying commands...') try { + await loadCommands() + const commands = [...commandRegistry.values()].map(cmd => cmd.data.toJSON()) + const rest = new REST().setToken(process.env.APP_TOKEN!) + log.check(`Started refreshing ${commands.length} application (/) commands...`) const data = await rest.put( diff --git a/src/utils/discord/assert.ts b/src/utils/discord/assert.ts index f1742ba..936d4b3 100644 --- a/src/utils/discord/assert.ts +++ b/src/utils/discord/assert.ts @@ -109,13 +109,6 @@ export class DiscordAssert extends DiscordMessage { } } - static assertComponentId(modalId: string, id: string): boolean { - if (!modalId.startsWith(id)) - return false - - return true - } - static isMemberHasRole(member: GuildMember, roleId: string): boolean { return member.roles.cache.has(roleId) } diff --git a/src/utils/io.ts b/src/utils/io.ts index a50a3db..62a0d26 100644 --- a/src/utils/io.ts +++ b/src/utils/io.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import { resolve } from 'node:path' -const EXCLUDED_FILES = new Set(['index.ts', 'messages.ts', 'validators.ts']) +const EXCLUDED_FILES = new Set(['registry.ts', 'messages.ts', 'validators.ts']) const EXCLUDED_FOLDERS = new Set(['validators', 'messages']) const EXCLUDED_PATTERNS: RegExp[] = [/\.d\.ts$/]