From 888ba8397968041dd4a02bf7a84ae3ff5ea07a10 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:25:52 +0100 Subject: [PATCH 01/77] Normalise formatting to 2-space indentation Add .editorconfig and reformat src/**/*.ts (no functional changes). Co-Authored-By: Claude Sonnet 5.5 --- .editorconfig | 9 + src/client/NovaClient.ts | 53 ++--- src/events/guildCreate.ts | 4 +- src/events/guildDelete.ts | 4 +- src/events/guildMemberAdd.ts | 5 +- src/events/guildMemberRemove.ts | 15 +- src/events/guildMemberUpdate.ts | 38 ++-- src/events/interactionCreate.ts | 102 +++++---- src/events/messageCreate.ts | 15 +- src/events/messageDelete.ts | 10 +- src/events/ready.ts | 19 +- src/slash-commands/Admin/noroles.ts | 69 +++--- src/slash-commands/Admin/post.ts | 63 ++++-- src/slash-commands/Admin/rolesince.ts | 52 ++--- src/slash-commands/Admin/simulate.ts | 153 +++++++------ .../Config/Subcommands/announcements.ts | 129 ++++++----- .../Config/Subcommands/audit.ts | 129 ++++++----- .../Config/Subcommands/birthdays.ts | 152 +++++++------ .../Config/Subcommands/debug.ts | 115 +++++----- .../Config/Subcommands/honeypot.ts | 33 +-- .../Config/Subcommands/newroles.ts | 210 ++++++++++-------- .../Config/Subcommands/systemMsgs.ts | 117 +++++----- .../Config/Subcommands/welcome.ts | 195 ++++++++-------- src/slash-commands/Config/config.ts | 105 ++++----- src/slash-commands/Info/about.ts | 80 +++---- src/slash-commands/Info/mybirthday.ts | 162 +++++++------- src/slash-commands/Info/ping.ts | 44 ++-- src/slash-commands/Info/profile.ts | 103 +++++---- src/types/Command.ts | 7 +- src/types/SlashCommand.ts | 30 +-- src/utilities/BirthdayManager.ts | 71 ++---- src/utilities/ChannelService.ts | 2 +- src/utilities/CommandRegistrar.ts | 19 +- src/utilities/ConfigManager.ts | 122 +++++----- src/utilities/ConfigService.ts | 14 +- src/utilities/Logger.ts | 14 +- src/utilities/UserProfileService.ts | 14 +- 37 files changed, 1265 insertions(+), 1213 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts index b411b88a..751245b3 100644 --- a/src/client/NovaClient.ts +++ b/src/client/NovaClient.ts @@ -7,17 +7,12 @@ import { sequelize } from './database/sequelize'; import { SlashCommand } from '../types/SlashCommand'; class NovaClient extends Client { - public events: Collection = new Collection(); - public slashCommands: Collection = new Collection(); + public events: Collection = new Collection(); + public slashCommands: Collection = new Collection(); public constructor() { super({ - partials: [ - Partials.Message, - Partials.Channel, - Partials.Reaction, - Partials.GuildMember, - ], + partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.GuildMember], intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, @@ -30,33 +25,29 @@ class NovaClient extends Client { } public async start(): Promise { - await sequelize.sync({alter: true,}); + await sequelize.sync({ alter: true }); - const eventFiles: string[] = await glob( - `${__dirname}/../events/**/*{.js,.ts}` - ); - const slashCommandFiles: string[] = await glob( - `${__dirname}/../slash-commands/*/*{.js,.ts}` - ); + const eventFiles: string[] = await glob(`${__dirname}/../events/**/*{.js,.ts}`); + const slashCommandFiles: string[] = await glob(`${__dirname}/../slash-commands/*/*{.js,.ts}`); - eventFiles.forEach(async (eventFile: string) => { - const importedEvent = await import(eventFile); - const event = (importedEvent.default ?? importedEvent) as Event; - this.events.set(event.name, event); - this.on(event.name, event.run.bind(null, this)); - }); + eventFiles.forEach(async (eventFile: string) => { + const importedEvent = await import(eventFile); + const event = (importedEvent.default ?? importedEvent) as Event; + this.events.set(event.name, event); + this.on(event.name, event.run.bind(null, this)); + }); - slashCommandFiles.forEach(async (slashCommandFile: string) => { - const importedCommand = await import(slashCommandFile); - const cmd = (importedCommand.default ?? importedCommand) as SlashCommand; - this.slashCommands.set(cmd.commandData.name, cmd); - }); + slashCommandFiles.forEach(async (slashCommandFile: string) => { + const importedCommand = await import(slashCommandFile); + const cmd = (importedCommand.default ?? importedCommand) as SlashCommand; + this.slashCommands.set(cmd.commandData.name, cmd); + }); - process.on('SIGTERM', () => { - Logger.writeLog('SIGTERM Received, destroying client & shutting down.'); - this.destroy(); - process.exit(); - }); + process.on('SIGTERM', () => { + Logger.writeLog('SIGTERM Received, destroying client & shutting down.'); + this.destroy(); + process.exit(); + }); await this.login(process.env.TOKEN); Logger.writeLog('Logged in'); diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index c210eece..36bd6d2e 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -9,9 +9,7 @@ export const run: RunFunction = async (client: NovaClient, guild: Guild) => { ConfigService.getConfig(guild.id) .then((config) => { if (config) { - return Logger.writeLog( - `Bot added to new guild: ${guild.name} (${guild.id}).` - ); + return Logger.writeLog(`Bot added to new guild: ${guild.name} (${guild.id}).`); } return Logger.writeError( `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).` diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 8d805efc..6396a6d4 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -8,9 +8,7 @@ import { UserProfileService } from '../utilities/UserProfileService'; export const name = 'guildDelete'; export const run: RunFunction = async (client: NovaClient, guild: Guild) => { const configDeleted = await ConfigService.deleteConfig(guild.id); - const userProfilesDeleted = await UserProfileService.deleteUsersByServer( - guild.id - ); + const userProfilesDeleted = await UserProfileService.deleteUsersByServer(guild.id); Logger.writeLog(`Bot removed from guild: ${guild.name} (${guild.id}).`); Logger.writeLog(`Config deleted: ${configDeleted}.`); diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 6a9f6422..0db6f99b 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -7,10 +7,7 @@ import { ConfigService } from '../utilities/ConfigService'; import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; export const name = 'guildMemberAdd'; -export const run: RunFunction = async ( - client: NovaClient, - member: GuildMember -) => { +export const run: RunFunction = async (client: NovaClient, member: GuildMember) => { const serverConfig = await ConfigService.getConfig(member.guild.id); const audit = new EmbedCompatLayer() diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 0044246e..8aedbf25 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -9,10 +9,7 @@ import { UserProfileService } from '../utilities/UserProfileService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; export const name = 'guildMemberRemove'; -export const run: RunFunction = async ( - client: NovaClient, - member: GuildMember -) => { +export const run: RunFunction = async (client: NovaClient, member: GuildMember) => { if (member.user.bot) { return; } @@ -22,10 +19,7 @@ export const run: RunFunction = async ( return; } - const dataDeleted = await UserProfileService.deleteUser( - member.guild.id, - member.user.id - ); + const dataDeleted = await UserProfileService.deleteUser(member.guild.id, member.user.id); const audit = new EmbedCompatLayer() .setColor(EmbedColours.negative) @@ -35,10 +29,7 @@ export const run: RunFunction = async ( }) .setDescription('Member left') .addField('ID', member.user.id) - .addField( - 'Member data cleanup', - dataDeleted ? 'Deleted' : 'No stored member data' - ) + .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data') .setTimestamp(); const serverConfig = await ConfigService.getConfig(member.guild.id); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 31676abd..20cbf91e 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -24,23 +24,15 @@ const applyText = (canvas: Canvas, text: string, baseSize: number) => { }; const getWelcomeMessage = async (config: ServerConfig, member: GuildMember) => { - return config.welcomeMessage.replace( - '{member}', - `<@${member.id}>` - ); + return config.welcomeMessage.replace('{member}', `<@${member.id}>`); }; const getWelcomeImage = async (config: ServerConfig, member: GuildMember) => { - GlobalFonts.registerFromPath( - `${__dirname}/../resources/fonts/Roboto-Regular.ttf`, - 'Roboto' - ); + GlobalFonts.registerFromPath(`${__dirname}/../resources/fonts/Roboto-Regular.ttf`, 'Roboto'); const canvas = createCanvas(700, 250); const ctx = canvas.getContext('2d'); - const joinedTs = DateTime.fromMillis(member.joinedTimestamp).toLocaleString( - DateTime.DATE_FULL - ); + const joinedTs = DateTime.fromMillis(member.joinedTimestamp).toLocaleString(DateTime.DATE_FULL); // Draw background const background = await loadImage(config.welcomeMessageBackgroundUrl); @@ -77,21 +69,21 @@ const getWelcomeImage = async (config: ServerConfig, member: GuildMember) => { ); ctx.drawImage(avatar, 50, 50, 150, 150); - const attachment = new AttachmentBuilder(await canvas.encode('png')).setName( - 'welcome-image.png' - ); + const attachment = new AttachmentBuilder(await canvas.encode('png')).setName('welcome-image.png'); return attachment; }; -const addGuestRoles = async(serverConfig: ServerConfig, newMember: GuildMember, client: NovaClient) => { +const addGuestRoles = async ( + serverConfig: ServerConfig, + newMember: GuildMember, + client: NovaClient +) => { try { const guestRoleIds = serverConfig.guestRoleIds.split(','); const guildRoles = await newMember.guild.roles.fetch(); - await newMember.roles.add( - guildRoles.filter((role) => guestRoleIds.includes(role.id)) - ); + await newMember.roles.add(guildRoles.filter((role) => guestRoleIds.includes(role.id))); } catch (e) { Logger.writeError( `Adding guest roles failed in guildMemberUpdate for server: ${serverConfig.id}.`, @@ -111,7 +103,11 @@ const addGuestRoles = async(serverConfig: ServerConfig, newMember: GuildMember, } }; -const sendScreenAudit = async(serverConfig: ServerConfig, newMember: GuildMember, client: NovaClient) => { +const sendScreenAudit = async ( + serverConfig: ServerConfig, + newMember: GuildMember, + client: NovaClient +) => { try { const audit = new EmbedCompatLayer() .setColor(EmbedColours.neutral) @@ -144,8 +140,7 @@ export const run: RunFunction = async ( const serverConfig = await ConfigService.getConfig(newMember.guild.id); const notPassedScreen = - oldMember.pending || - (oldMember.pending === null && newMember.roles.cache.size === 1); + oldMember.pending || (oldMember.pending === null && newMember.roles.cache.size === 1); if (notPassedScreen && !newMember.pending) { sendScreenAudit(serverConfig, newMember, client); @@ -173,7 +168,6 @@ export const run: RunFunction = async ( content: content ?? undefined, files: attachment ? [attachment] : undefined, }); - } catch (e) { Logger.writeError( `Sending welcome message failed in guildMemberUpdate for server: ${serverConfig.serverId}.`, diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index d9f23cac..31b890af 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,47 +1,55 @@ -import { ChatInputCommandInteraction, Interaction } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { ConfigService } from '../utilities/ConfigService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export const name = 'interactionCreate'; - -const runCommand = async (client: NovaClient, cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const slashCmd = client.slashCommands.get(cmd.commandName); - - if (!slashCmd) { - return; - } - - await slashCmd.execute(cmd, config) - .catch((err: string) => { - if (cmd.deferred && !cmd.replied) { - cmd.editReply({ content: 'Something went wrong, was this command run in the correct place?' }); - } else if (!cmd.replied) { - cmd.reply({ content: 'Something went wrong, was this command run in the correct place?', ephemeral: true }); - } - Logger.writeError(err); - }); -}; - -export const run: RunFunction = async (client: NovaClient, interaction: Interaction) => { - if (!interaction.isChatInputCommand()) { - return; - } - - if (!interaction.guild) { - return interaction.reply({ - content: 'Dingir only supports interactions in Discord Servers.', - ephemeral: true - }); - } - - const serverConfig = await ConfigService.getConfigByMessage(interaction); - await UserProfileService.incrementActivityScore(interaction.guild.id, interaction.user.id); - - if (serverConfig) { - await runCommand(client, interaction, serverConfig); - } -}; +import { ChatInputCommandInteraction, Interaction } from 'discord.js'; +import { NovaClient } from '../client/NovaClient'; +import { RunFunction } from '../types/Event'; +import { ConfigService } from '../utilities/ConfigService'; +import { Logger } from '../utilities/Logger'; +import { UserProfileService } from '../utilities/UserProfileService'; +import { ServerConfig } from '../client/models/ServerConfig'; + +export const name = 'interactionCreate'; + +const runCommand = async ( + client: NovaClient, + cmd: ChatInputCommandInteraction, + config: ServerConfig +) => { + const slashCmd = client.slashCommands.get(cmd.commandName); + + if (!slashCmd) { + return; + } + + await slashCmd.execute(cmd, config).catch((err: string) => { + if (cmd.deferred && !cmd.replied) { + cmd.editReply({ + content: 'Something went wrong, was this command run in the correct place?', + }); + } else if (!cmd.replied) { + cmd.reply({ + content: 'Something went wrong, was this command run in the correct place?', + ephemeral: true, + }); + } + Logger.writeError(err); + }); +}; + +export const run: RunFunction = async (client: NovaClient, interaction: Interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } + + if (!interaction.guild) { + return interaction.reply({ + content: 'Dingir only supports interactions in Discord Servers.', + ephemeral: true, + }); + } + + const serverConfig = await ConfigService.getConfigByMessage(interaction); + await UserProfileService.incrementActivityScore(interaction.guild.id, interaction.user.id); + + if (serverConfig) { + await runCommand(client, interaction, serverConfig); + } +}; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 7032bbdb..5d102f33 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -38,10 +38,7 @@ const sendAudit = async ( }; export const name = 'messageCreate'; -export const run: RunFunction = async ( - client: NovaClient, - message: Message -) => { +export const run: RunFunction = async (client: NovaClient, message: Message) => { if (!message.guild || message.author.bot) { return; } @@ -52,8 +49,7 @@ export const run: RunFunction = async ( } const member = - message.member ?? - (await message.guild.members.fetch(message.author.id).catch(() => null)); + message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { return; } @@ -88,13 +84,16 @@ export const run: RunFunction = async ( } await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { - Logger.writeError(`Could not delete profile data for honey-pot ban ${member.id}.`, formatError(error)); + Logger.writeError( + `Could not delete profile data for honey-pot ban ${member.id}.`, + formatError(error) + ); }); await sendAudit( client, message, 'Honey-pot triggered', - 'Banned and deleted the member\'s messages from the past 7 days.' + "Banned and deleted the member's messages from the past 7 days." ).catch((error) => { Logger.writeError('Could not send honey-pot success audit.', formatError(error)); }); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 0875baba..e6644d1d 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -9,10 +9,7 @@ import { UserProfileService } from '../utilities/UserProfileService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; export const name = 'messageDelete'; -export const run: RunFunction = async ( - client: NovaClient, - message: Message -) => { +export const run: RunFunction = async (client: NovaClient, message: Message) => { if (!message.author || !message.guild) { return; } @@ -23,10 +20,7 @@ export const run: RunFunction = async ( const serverConfig = await ConfigService.getConfig(message.guild.id); - await UserProfileService.decrementActivityScore( - message.guild.id, - message.author.id - ); + await UserProfileService.decrementActivityScore(message.guild.id, message.author.id); const audit = new EmbedCompatLayer() .setColor(EmbedColours.neutral) diff --git a/src/events/ready.ts b/src/events/ready.ts index cb5f6105..309ebf59 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -12,16 +12,11 @@ export const run: RunFunction = async (client: NovaClient) => { Logger.writeLog('Online'); await CommandRegistrar.registerGlobalCommands(client); - - const birthdaySchedule = schedule.scheduleJob( - process.env.JOB_SCHEDULE, - () => { - DataCheck.dataCleanup(client); - BirthdayManager.notifyBirthdays(client); - BirthdayManager.populateCalendars(client); - } - ); - Logger.writeLog( - `Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}` - ); + + const birthdaySchedule = schedule.scheduleJob(process.env.JOB_SCHEDULE, () => { + DataCheck.dataCleanup(client); + BirthdayManager.notifyBirthdays(client); + BirthdayManager.populateCalendars(client); + }); + Logger.writeLog(`Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}`); }; diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts index 432cdd9f..62dc7ec1 100644 --- a/src/slash-commands/Admin/noroles.ts +++ b/src/slash-commands/Admin/noroles.ts @@ -1,44 +1,47 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, InteractionContextType } from 'discord.js'; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + InteractionContextType, +} from 'discord.js'; import { SlashCommand } from '../../types/SlashCommand'; const execute = async (cmd: ChatInputCommandInteraction) => { - const allMembers = await cmd.guild.members.fetch(); + const allMembers = await cmd.guild.members.fetch(); - const members = allMembers.filter((member) => member.roles.cache.size === 1); - - let response: string; - - if (members.size < 1) { - response = 'There are no users with no roles.'; - } else { - response = '**Users with no roles**\n------\n'; - members.each(async (mem) => { - if (mem.partial) { - await mem.fetch(); - } - response += `${mem.toString()} joined \n`; - }); - } - - return cmd.reply({ - content: response, - allowedMentions: { - parse: [], - }, - ephemeral: true + const members = allMembers.filter((member) => member.roles.cache.size === 1); + + let response: string; + + if (members.size < 1) { + response = 'There are no users with no roles.'; + } else { + response = '**Users with no roles**\n------\n'; + members.each(async (mem) => { + if (mem.partial) { + await mem.fetch(); + } + response += `${mem.toString()} joined \n`; }); + } + + return cmd.reply({ + content: response, + allowedMentions: { + parse: [], + }, + ephemeral: true, + }); }; const commandData = new SlashCommandBuilder() - .setName('noroles') - .setDescription('Returns members of this guild with no roles assigned') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); + .setName('noroles') + .setDescription('Returns members of this guild with no roles assigned') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setContexts([InteractionContextType.Guild]); const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute + commandData: commandData, + execute: execute, }; -export = slashCommand; \ No newline at end of file +export = slashCommand; diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts index fba67ad0..23046844 100644 --- a/src/slash-commands/Admin/post.ts +++ b/src/slash-commands/Admin/post.ts @@ -1,11 +1,25 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, SlashCommandChannelOption, SlashCommandStringOption, SlashCommandAttachmentOption, Attachment, InteractionContextType } from 'discord.js'; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + SlashCommandChannelOption, + SlashCommandStringOption, + SlashCommandAttachmentOption, + Attachment, + InteractionContextType, +} from 'discord.js'; import { ServerConfig } from '../../client/models/ServerConfig'; import { EmbedColours } from '../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; import { SlashCommand } from '../../types/SlashCommand'; import { ChannelService } from '../../utilities/ChannelService'; -const sendAudit = async (cmd: ChatInputCommandInteraction, config: ServerConfig, file: Attachment, content: string) => { +const sendAudit = async ( + cmd: ChatInputCommandInteraction, + config: ServerConfig, + file: Attachment, + content: string +) => { const embed = new EmbedCompatLayer(); const member = cmd.guild.members.cache.get(cmd.user.id); @@ -31,51 +45,52 @@ const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) = if (!content && !attachment) { return cmd.editReply({ - content: 'You must provide at least text or an attachment' + content: 'You must provide at least text or an attachment', }); } const guildChannel = cmd.guild.channels.cache.get(channel.id); if (!guildChannel || !guildChannel.isTextBased()) { return cmd.editReply({ - content: 'The provided channel is not valid' + content: 'The provided channel is not valid', }); } - await guildChannel.send({ - content: content, - files: attachment ? [attachment] : null - }).catch(() => { - return cmd.editReply({ - content: 'An error was encountered sending this message' + await guildChannel + .send({ + content: content, + files: attachment ? [attachment] : null, + }) + .catch(() => { + return cmd.editReply({ + content: 'An error was encountered sending this message', + }); }); - }); sendAudit(cmd, config, attachment, content); return cmd.editReply({ - content: 'Message successfully sent' + content: 'Message successfully sent', }); }; const commandData = new SlashCommandBuilder() .setName('post') .setDescription('Posts a simple message and/or attachment to the given channel') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to post in') - .setRequired(true)) - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Optional simple message to send')) - .addAttachmentOption((opt: SlashCommandAttachmentOption) => opt - .setName('attachment') - .setDescription('Optional attachment to send')) + .addChannelOption((opt: SlashCommandChannelOption) => + opt.setName('channel').setDescription('Channel to post in').setRequired(true) + ) + .addStringOption((opt: SlashCommandStringOption) => + opt.setName('content').setDescription('Optional simple message to send') + ) + .addAttachmentOption((opt: SlashCommandAttachmentOption) => + opt.setName('attachment').setDescription('Optional attachment to send') + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setContexts([InteractionContextType.Guild]); const slashCommand: SlashCommand = { commandData: commandData, - execute: execute + execute: execute, }; -export = slashCommand; \ No newline at end of file +export = slashCommand; diff --git a/src/slash-commands/Admin/rolesince.ts b/src/slash-commands/Admin/rolesince.ts index f8a02c3f..076651af 100644 --- a/src/slash-commands/Admin/rolesince.ts +++ b/src/slash-commands/Admin/rolesince.ts @@ -1,4 +1,11 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, SlashCommandRoleOption, SlashCommandNumberOption, InteractionContextType } from 'discord.js'; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + SlashCommandRoleOption, + SlashCommandNumberOption, + InteractionContextType, +} from 'discord.js'; import { DateTime } from 'luxon'; import { SlashCommand } from '../../types/SlashCommand'; @@ -10,31 +17,25 @@ const execute = async (cmd: ChatInputCommandInteraction) => { const members = allMembers.filter((member) => { const joined = DateTime.fromMillis(member.joinedTimestamp).startOf('day'); - const daysInServer = DateTime.local() - .startOf('day') - .diff(joined, 'days') - .toObject().days; - return ( - member.roles.cache.find((r) => r.id === role.id) && - daysInServer >= days - ); + const daysInServer = DateTime.local().startOf('day').diff(joined, 'days').toObject().days; + return member.roles.cache.find((r) => r.id === role.id) && daysInServer >= days; }); let response: string; if (members.size < 1) { - response = `There are no users in ${role.toString()} that have been in the server for at least ${days ?? 0 - } days.`; + response = `There are no users in ${role.toString()} that have been in the server for at least ${ + days ?? 0 + } days.`; } else { - response = `**Users in ${role.toString()} that have been in the server for at least ${days ?? 0 - } days.**\n------\n`; + response = `**Users in ${role.toString()} that have been in the server for at least ${ + days ?? 0 + } days.**\n------\n`; members.each(async (mem) => { if (mem.partial) { await mem.fetch(); } - response += `${mem.toString()} joined \n`; + response += `${mem.toString()} joined \n`; }); } @@ -43,25 +44,24 @@ const execute = async (cmd: ChatInputCommandInteraction) => { allowedMentions: { parse: [], }, - ephemeral: true + ephemeral: true, }); }; const commandData = new SlashCommandBuilder() .setName('rolesince') .setDescription('Returns members in the given role for the specified number of days') - .addRoleOption((opt: SlashCommandRoleOption) => opt - .setName('role') - .setDescription('Role to search again') - .setRequired(true)) - .addNumberOption((opt: SlashCommandNumberOption) => opt - .setName('days') - .setDescription('Minimum number of days in the role')) + .addRoleOption((opt: SlashCommandRoleOption) => + opt.setName('role').setDescription('Role to search again').setRequired(true) + ) + .addNumberOption((opt: SlashCommandNumberOption) => + opt.setName('days').setDescription('Minimum number of days in the role') + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setContexts([InteractionContextType.Guild]); const slashCommand: SlashCommand = { commandData: commandData, - execute: execute + execute: execute, }; -export = slashCommand; \ No newline at end of file +export = slashCommand; diff --git a/src/slash-commands/Admin/simulate.ts b/src/slash-commands/Admin/simulate.ts index 0b7d26bd..69fdcaba 100644 --- a/src/slash-commands/Admin/simulate.ts +++ b/src/slash-commands/Admin/simulate.ts @@ -1,70 +1,83 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, SlashCommandSubcommandBuilder, SlashCommandUserOption, InteractionContextType } from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; - -const testJoin = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const guildMember = cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); - cmd.client.emit('guildMemberAdd', guildMember); - - cmd.reply({ content: `Emitted guildMemberAdd for ${guildMember.toString()}`, ephemeral: true }); -}; - -const testScreen = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const guildMember = cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); - - const oldMemberMock = Object.assign({}, - guildMember, - { - pending: true, - } - ); - const newMemberMock = guildMember; - - cmd.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); - - cmd.reply({ content: `Emitted guildMemberUpdate for ${guildMember.toString()}`, ephemeral: true }); -}; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const subCommand = cmd.options.getSubcommand(); - - if (!subCommand) { - cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); - } - - if (subCommand == 'join') { - return testJoin(cmd); - } else if (subCommand == 'screen') { - return testScreen(cmd); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const commandData = new SlashCommandBuilder() - .setName('simulate') - .setDescription('Simulate events in this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('join') - .setDescription('Simulate someone joining this server') - .addUserOption((opt: SlashCommandUserOption) => opt - .setName('member') - .setDescription('Member to simulate joining as'))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('screen') - .setDescription('Simulate someone passing screening in this server') - .addUserOption((opt: SlashCommandUserOption) => opt - .setName('member') - .setDescription('Member to simulate passing screening as'))); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + SlashCommandSubcommandBuilder, + SlashCommandUserOption, + InteractionContextType, +} from 'discord.js'; +import { SlashCommand } from '../../types/SlashCommand'; + +const testJoin = async (cmd: ChatInputCommandInteraction) => { + const user = cmd.options.getUser('member'); + const guildMember = + cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); + cmd.client.emit('guildMemberAdd', guildMember); + + cmd.reply({ content: `Emitted guildMemberAdd for ${guildMember.toString()}`, ephemeral: true }); +}; + +const testScreen = async (cmd: ChatInputCommandInteraction) => { + const user = cmd.options.getUser('member'); + const guildMember = + cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); + + const oldMemberMock = Object.assign({}, guildMember, { + pending: true, + }); + const newMemberMock = guildMember; + + cmd.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); + + cmd.reply({ + content: `Emitted guildMemberUpdate for ${guildMember.toString()}`, + ephemeral: true, + }); +}; + +const execute = async (cmd: ChatInputCommandInteraction) => { + const subCommand = cmd.options.getSubcommand(); + + if (!subCommand) { + cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); + } + + if (subCommand == 'join') { + return testJoin(cmd); + } else if (subCommand == 'screen') { + return testScreen(cmd); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const commandData = new SlashCommandBuilder() + .setName('simulate') + .setDescription('Simulate events in this server') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setContexts([InteractionContextType.Guild]) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('join') + .setDescription('Simulate someone joining this server') + .addUserOption((opt: SlashCommandUserOption) => + opt.setName('member').setDescription('Member to simulate joining as') + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('screen') + .setDescription('Simulate someone passing screening in this server') + .addUserOption((opt: SlashCommandUserOption) => + opt.setName('member').setDescription('Member to simulate passing screening as') + ) + ); + +const slashCommand: SlashCommand = { + commandData: commandData, + execute: execute, +}; +export = slashCommand; diff --git a/src/slash-commands/Config/Subcommands/announcements.ts b/src/slash-commands/Config/Subcommands/announcements.ts index 152db580..c43bc69f 100644 --- a/src/slash-commands/Config/Subcommands/announcements.ts +++ b/src/slash-commands/Config/Subcommands/announcements.ts @@ -1,60 +1,69 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, SlashCommandChannelOption, ChatInputCommandInteraction } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ConfigManager } from '../../../utilities/ConfigManager'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.updateChannelNew(cmd, config, 'announcementsChannelId'); - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = await ConfigManager.getChannel(cmd, config, 'announcementsChannelId'); - return cmd.reply({ - content: `The announcements channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.clearChannel(config, 'announcementsChannelId'); - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('announcements') - .setDescription('Configure the announcements channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s the channel to post announcements in') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to set as the announcements channel') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s the channel announcements are posted in')) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Disables the announcements channel feature')); - -export const AnnouncementsCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandBuilder, + SlashCommandChannelOption, + ChatInputCommandInteraction, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { ConfigManager } from '../../../utilities/ConfigManager'; + +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + ConfigManager.updateChannelNew(cmd, config, 'announcementsChannelId'); + return get(cmd, config); +}; + +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const channel = await ConfigManager.getChannel(cmd, config, 'announcementsChannelId'); + return cmd.reply({ + content: `The announcements channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, + ephemeral: true, + }); +}; + +const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + ConfigManager.clearChannel(config, 'announcementsChannelId'); + return get(cmd, config); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'set') { + return set(cmd, config); + } else if (subCommand == 'get') { + return get(cmd, config); + } else if (subCommand == 'clear') { + return clear(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('announcements') + .setDescription('Configure the announcements channel for this server') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('set') + .setDescription("Set's the channel to post announcements in") + .addChannelOption((opt: SlashCommandChannelOption) => + opt + .setName('channel') + .setDescription('Channel to set as the announcements channel') + .setRequired(true) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('get').setDescription("Get's the channel announcements are posted in") + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('clear').setDescription('Disables the announcements channel feature') + ); + +export const AnnouncementsCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/Subcommands/audit.ts b/src/slash-commands/Config/Subcommands/audit.ts index 369eefae..8e4762b4 100644 --- a/src/slash-commands/Config/Subcommands/audit.ts +++ b/src/slash-commands/Config/Subcommands/audit.ts @@ -1,60 +1,69 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, SlashCommandChannelOption, ChatInputCommandInteraction } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ConfigManager } from '../../../utilities/ConfigManager'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.updateChannelNew(cmd, config, 'auditChannelId'); - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = await ConfigManager.getChannel(cmd, config, 'auditChannelId'); - return cmd.reply({ - content: `The audit channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.clearChannel(config, 'auditChannelId'); - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('audit') - .setDescription('Configure the audit channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s the channel to post audits in') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to set as the audits channel') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s the channel audits are posted in')) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Disables the audits channel feature')); - -export const AuditCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandBuilder, + SlashCommandChannelOption, + ChatInputCommandInteraction, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { ConfigManager } from '../../../utilities/ConfigManager'; + +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + ConfigManager.updateChannelNew(cmd, config, 'auditChannelId'); + return get(cmd, config); +}; + +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const channel = await ConfigManager.getChannel(cmd, config, 'auditChannelId'); + return cmd.reply({ + content: `The audit channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, + ephemeral: true, + }); +}; + +const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + ConfigManager.clearChannel(config, 'auditChannelId'); + return get(cmd, config); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'set') { + return set(cmd, config); + } else if (subCommand == 'get') { + return get(cmd, config); + } else if (subCommand == 'clear') { + return clear(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('audit') + .setDescription('Configure the audit channel for this server') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('set') + .setDescription("Set's the channel to post audits in") + .addChannelOption((opt: SlashCommandChannelOption) => + opt + .setName('channel') + .setDescription('Channel to set as the audits channel') + .setRequired(true) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('get').setDescription("Get's the channel audits are posted in") + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('clear').setDescription('Disables the audits channel feature') + ); + +export const AuditCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index 6af34829..cd19ff98 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -1,72 +1,80 @@ -import { SlashCommandSubcommandBuilder, SlashCommandChannelOption, ChatInputCommandInteraction, SlashCommandSubcommandGroupBuilder } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { BirthdayManager } from '../../../utilities/BirthdayManager'; - -const create = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const { id } = cmd.options.getChannel('channel'); - const channel = cmd.guild.channels.cache.get(id); - - if (!channel || !channel.isTextBased()) { - return cmd.reply('Provided channel is not a text channel'); - } - - const birthdaysCalendar = await channel.send({ - content: 'Placeholder calendar message - populating...' - }); - config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; - await config.save(); - await BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); - - cmd.reply({ content: 'Birthday calendar has been created.', ephemeral: true }); -}; - -const sync = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - try { - await BirthdayManager.populateCalendars(cmd.client, config.serverId); - } catch { - return cmd.reply({ - content: 'An error ocurred running the calendar sync for this server.', - ephemeral: true - }); - } - - return cmd.reply({ - content: `Calendar successfully synchronised for ${cmd.guild.name}.`, - ephemeral: true - }); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'create') { - return create(cmd, config); - } else if (subCommand == 'sync') { - return sync(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('birthdays') - .setDescription('Configure the birthday calendar for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('create') - .setDescription('Creates or recreates a birthday calendar for this server') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to create the birthday calendar in') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('sync') - .setDescription('Syncs the birthday calendar for this server')); - - -export const BirthdaysConfigCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandBuilder, + SlashCommandChannelOption, + ChatInputCommandInteraction, + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { BirthdayManager } from '../../../utilities/BirthdayManager'; + +const create = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const { id } = cmd.options.getChannel('channel'); + const channel = cmd.guild.channels.cache.get(id); + + if (!channel || !channel.isTextBased()) { + return cmd.reply('Provided channel is not a text channel'); + } + + const birthdaysCalendar = await channel.send({ + content: 'Placeholder calendar message - populating...', + }); + config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; + await config.save(); + await BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); + + cmd.reply({ content: 'Birthday calendar has been created.', ephemeral: true }); +}; + +const sync = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + try { + await BirthdayManager.populateCalendars(cmd.client, config.serverId); + } catch { + return cmd.reply({ + content: 'An error ocurred running the calendar sync for this server.', + ephemeral: true, + }); + } + + return cmd.reply({ + content: `Calendar successfully synchronised for ${cmd.guild.name}.`, + ephemeral: true, + }); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'create') { + return create(cmd, config); + } else if (subCommand == 'sync') { + return sync(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('birthdays') + .setDescription('Configure the birthday calendar for this server') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('create') + .setDescription('Creates or recreates a birthday calendar for this server') + .addChannelOption((opt: SlashCommandChannelOption) => + opt + .setName('channel') + .setDescription('Channel to create the birthday calendar in') + .setRequired(true) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('sync').setDescription('Syncs the birthday calendar for this server') + ); + +export const BirthdaysConfigCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/slash-commands/Config/Subcommands/debug.ts index 4fc7239e..b2fa66ff 100644 --- a/src/slash-commands/Config/Subcommands/debug.ts +++ b/src/slash-commands/Config/Subcommands/debug.ts @@ -1,53 +1,62 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandBooleanOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled'); - config.debug = value; - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config.debug; - - return cmd.reply({ - content: `Debug mode is: ${value ? 'enabled' : 'disabled'}`, - ephemeral: true - }); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('debug') - .setDescription('Toggle the debugging mode for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s whether debug mode is enabled for this server') - .addBooleanOption((opt: SlashCommandBooleanOption) => opt - .setName('enabled') - .setDescription('Whether to enable debug mode or not') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s whether debug mode is enabled or not')); - -export const DebugCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandBuilder, + ChatInputCommandInteraction, + SlashCommandBooleanOption, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; + +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const value = cmd.options.getBoolean('enabled'); + config.debug = value; + config.save(); + + return get(cmd, config); +}; + +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const value = config.debug; + + return cmd.reply({ + content: `Debug mode is: ${value ? 'enabled' : 'disabled'}`, + ephemeral: true, + }); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'set') { + return set(cmd, config); + } else if (subCommand == 'get') { + return get(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('debug') + .setDescription('Toggle the debugging mode for this server') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('set') + .setDescription("Set's whether debug mode is enabled for this server") + .addBooleanOption((opt: SlashCommandBooleanOption) => + opt + .setName('enabled') + .setDescription('Whether to enable debug mode or not') + .setRequired(true) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('get').setDescription("Get's whether debug mode is enabled or not") + ); + +export const DebugCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/Subcommands/honeypot.ts b/src/slash-commands/Config/Subcommands/honeypot.ts index 7d8becd5..b337f713 100644 --- a/src/slash-commands/Config/Subcommands/honeypot.ts +++ b/src/slash-commands/Config/Subcommands/honeypot.ts @@ -8,10 +8,7 @@ import { import { ServerConfig } from '../../../client/models/ServerConfig'; import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -const get = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { const channel = config.honeyPotChannelId ? cmd.guild.channels.cache.get(config.honeyPotChannelId) : null; @@ -24,15 +21,9 @@ const get = async ( }); }; -const set = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { const channel = cmd.options.getChannel('channel', true); - if ( - channel.type !== ChannelType.GuildText && - channel.type !== ChannelType.GuildAnnouncement - ) { + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { return cmd.reply({ content: 'Honey-pot channels must be Text or Announcement Channels.', ephemeral: true, @@ -48,10 +39,7 @@ const set = async ( }); }; -const clear = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { +const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { config.honeyPotChannelId = null; await config.save(); @@ -61,10 +49,7 @@ const clear = async ( }); }; -const exec = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { switch (cmd.options.getSubcommand()) { case 'set': return set(cmd, config); @@ -96,14 +81,10 @@ const data = new SlashCommandSubcommandGroupBuilder() ) ) .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('get') - .setDescription('Get the configured honey-pot channel') + sub.setName('get').setDescription('Get the configured honey-pot channel') ) .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('clear') - .setDescription('Disable the honey-pot channel') + sub.setName('clear').setDescription('Disable the honey-pot channel') ); export const HoneyPotCommand: SlashSubGroupCommand = { diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index a210494e..fcd1b16e 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -1,98 +1,112 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandRoleOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { EmbedColours } from '../../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../../types/EmbedCompatLayer'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ChannelService } from '../../../utilities/ChannelService'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const role1 = cmd.options.getRole('role-one'); - const role2 = cmd.options.getRole('role-two'); - - const newGuestRoles: string = [role1.id, role2?.id].filter(Boolean).join(','); - config.guestRoleIds = newGuestRoles; - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - if (!config.guestRoleIds) { - return cmd.reply('No guest roles configured for this server'); - } - - const roleIds: string[] = config.guestRoleIds.split(','); - const roles = cmd.guild.roles.cache.filter(r => roleIds.includes(r.id)); - const member = cmd.guild.members.cache.get(cmd.user.id); - - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.info) - .setAuthor({ - name: member.displayName, - iconURL: cmd.user.displayAvatarURL() - }) - .setDescription(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`) - .setTimestamp(); - - if (config.guestRoleIds) { - audit.addField('New user roles', roles.map(r => r.toString()).join('\n')); - } - - if (roles.size !== roleIds.length) { - audit.addField('WARNING', 'Not all roles configured are available in this server, please reconfigure new user roles'); - } - - ChannelService.sendAuditMessage(cmd.client, config, audit); - cmd.reply({ - content: `New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config.guestRoleIds = null; - config.save(); - - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('newroles') - .setDescription('Control the roles assigned to newly screened members') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Gets the roles assigned to newly screened members')) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Sets the roles of newly screened members') - .addRoleOption((option: SlashCommandRoleOption) => option - .setName('role-one') - .setDescription('First role to give to newly screened members') - .setRequired(true)) - .addRoleOption((option: SlashCommandRoleOption) => option - .setName('role-two') - .setDescription('Second optional role to give to newly screened members'))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Clears the roles assigned to newly screened members')); - -export const NewRolesCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandBuilder, + ChatInputCommandInteraction, + SlashCommandRoleOption, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { EmbedColours } from '../../../resources/EmbedColours'; +import { EmbedCompatLayer } from '../../../types/EmbedCompatLayer'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { ChannelService } from '../../../utilities/ChannelService'; + +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const role1 = cmd.options.getRole('role-one'); + const role2 = cmd.options.getRole('role-two'); + + const newGuestRoles: string = [role1.id, role2?.id].filter(Boolean).join(','); + config.guestRoleIds = newGuestRoles; + config.save(); + + return get(cmd, config); +}; + +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + if (!config.guestRoleIds) { + return cmd.reply('No guest roles configured for this server'); + } + + const roleIds: string[] = config.guestRoleIds.split(','); + const roles = cmd.guild.roles.cache.filter((r) => roleIds.includes(r.id)); + const member = cmd.guild.members.cache.get(cmd.user.id); + + const audit = new EmbedCompatLayer() + .setColor(EmbedColours.info) + .setAuthor({ + name: member.displayName, + iconURL: cmd.user.displayAvatarURL(), + }) + .setDescription(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`) + .setTimestamp(); + + if (config.guestRoleIds) { + audit.addField('New user roles', roles.map((r) => r.toString()).join('\n')); + } + + if (roles.size !== roleIds.length) { + audit.addField( + 'WARNING', + 'Not all roles configured are available in this server, please reconfigure new user roles' + ); + } + + ChannelService.sendAuditMessage(cmd.client, config, audit); + cmd.reply({ + content: `New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`, + ephemeral: true, + }); +}; + +const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + config.guestRoleIds = null; + config.save(); + + return get(cmd, config); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'set') { + return set(cmd, config); + } else if (subCommand == 'get') { + return get(cmd, config); + } else if (subCommand == 'clear') { + return clear(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('newroles') + .setDescription('Control the roles assigned to newly screened members') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('get').setDescription('Gets the roles assigned to newly screened members') + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('set') + .setDescription('Sets the roles of newly screened members') + .addRoleOption((option: SlashCommandRoleOption) => + option + .setName('role-one') + .setDescription('First role to give to newly screened members') + .setRequired(true) + ) + .addRoleOption((option: SlashCommandRoleOption) => + option + .setName('role-two') + .setDescription('Second optional role to give to newly screened members') + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('clear').setDescription('Clears the roles assigned to newly screened members') + ); + +export const NewRolesCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/Subcommands/systemMsgs.ts b/src/slash-commands/Config/Subcommands/systemMsgs.ts index 8d2bfacc..b67c1524 100644 --- a/src/slash-commands/Config/Subcommands/systemMsgs.ts +++ b/src/slash-commands/Config/Subcommands/systemMsgs.ts @@ -1,53 +1,64 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandBooleanOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled'); - config.systemMessagesEnabled = value; - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config.systemMessagesEnabled; - - return cmd.reply({ - content: `Bot System messages are: ${value ? 'enabled' : 'disabled'}`, - ephemeral: true - }); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('sysmsgs') - .setDescription('Toggle Bot created System Messages for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s whether system messages are posted in the default channel for this server') - .addBooleanOption((opt: SlashCommandBooleanOption) => opt - .setName('enabled') - .setDescription('Whether to post bot created system messages or not') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s whether bot created system messages are posted or not')); - -export const SystemMessagesCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandBuilder, + ChatInputCommandInteraction, + SlashCommandBooleanOption, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; + +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const value = cmd.options.getBoolean('enabled'); + config.systemMessagesEnabled = value; + config.save(); + + return get(cmd, config); +}; + +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const value = config.systemMessagesEnabled; + + return cmd.reply({ + content: `Bot System messages are: ${value ? 'enabled' : 'disabled'}`, + ephemeral: true, + }); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'set') { + return set(cmd, config); + } else if (subCommand == 'get') { + return get(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('sysmsgs') + .setDescription('Toggle Bot created System Messages for this server') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('set') + .setDescription( + "Set's whether system messages are posted in the default channel for this server" + ) + .addBooleanOption((opt: SlashCommandBooleanOption) => + opt + .setName('enabled') + .setDescription('Whether to post bot created system messages or not') + .setRequired(true) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub.setName('get').setDescription("Get's whether bot created system messages are posted or not") + ); + +export const SystemMessagesCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts index 22cd5105..302b9160 100644 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ b/src/slash-commands/Config/Subcommands/welcome.ts @@ -1,87 +1,108 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandStringOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const imageChoice = { - name: 'Welcome Image', value: 'welcomeMessageBackgroundUrl' -}; -const msgChoice = { - name: 'Welcome Message', value: 'welcomeMessage' -}; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - console.log(cmd.options); - config[cmd.options.getString('content')] = cmd.options.getString('value'); - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config[cmd.options.getString('content')]; - - return cmd.reply({ - content: `Current value is: ${value ? value : 'Not Set'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config[cmd.options.getString('content')] = null; - config.save(); - - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('welcome') - .setDescription('Control welcome information for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s the welcome information for this server') - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Set the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice)) - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('value') - .setDescription('URL to the welcome image, or the welcome text') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s the welcome information for this server') - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Get the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Clear\'s the welcome information for this server') - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Clear the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice))); - -export const WelcomeCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file +import { + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandBuilder, + ChatInputCommandInteraction, + SlashCommandStringOption, +} from 'discord.js'; +import { ServerConfig } from '../../../client/models/ServerConfig'; +import { SlashSubGroupCommand } from '../../../types/SlashCommand'; + +const imageChoice = { + name: 'Welcome Image', + value: 'welcomeMessageBackgroundUrl', +}; +const msgChoice = { + name: 'Welcome Message', + value: 'welcomeMessage', +}; + +const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + console.log(cmd.options); + config[cmd.options.getString('content')] = cmd.options.getString('value'); + config.save(); + + return get(cmd, config); +}; + +const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const value = config[cmd.options.getString('content')]; + + return cmd.reply({ + content: `Current value is: ${value ? value : 'Not Set'}`, + ephemeral: true, + }); +}; + +const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + config[cmd.options.getString('content')] = null; + config.save(); + + return get(cmd, config); +}; + +const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommand(); + if (subCommand == 'set') { + return set(cmd, config); + } else if (subCommand == 'get') { + return get(cmd, config); + } else if (subCommand == 'clear') { + return clear(cmd, config); + } else { + return cmd.reply({ + content: 'A valid option was not supplied for this command', + ephemeral: true, + }); + } +}; + +const data = new SlashCommandSubcommandGroupBuilder() + .setName('welcome') + .setDescription('Control welcome information for this server') + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('set') + .setDescription("Set's the welcome information for this server") + .addStringOption((opt: SlashCommandStringOption) => + opt + .setName('content') + .setDescription('Set the welcome image, or text?') + .setRequired(true) + .addChoices(imageChoice, msgChoice) + ) + .addStringOption((opt: SlashCommandStringOption) => + opt + .setName('value') + .setDescription('URL to the welcome image, or the welcome text') + .setRequired(true) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('get') + .setDescription("Get's the welcome information for this server") + .addStringOption((opt: SlashCommandStringOption) => + opt + .setName('content') + .setDescription('Get the welcome image, or text?') + .setRequired(true) + .addChoices(imageChoice, msgChoice) + ) + ) + .addSubcommand((sub: SlashCommandSubcommandBuilder) => + sub + .setName('clear') + .setDescription("Clear's the welcome information for this server") + .addStringOption((opt: SlashCommandStringOption) => + opt + .setName('content') + .setDescription('Clear the welcome image, or text?') + .setRequired(true) + .addChoices(imageChoice, msgChoice) + ) + ); + +export const WelcomeCommand: SlashSubGroupCommand = { + commandData: data, + execute: exec, +}; diff --git a/src/slash-commands/Config/config.ts b/src/slash-commands/Config/config.ts index a7007ab1..5af66f26 100644 --- a/src/slash-commands/Config/config.ts +++ b/src/slash-commands/Config/config.ts @@ -1,53 +1,58 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, InteractionContextType } from 'discord.js'; -import { ServerConfig } from '../../client/models/ServerConfig'; -import { SlashCommand, SlashSubCommand, SlashSubGroupCommand } from '../../types/SlashCommand'; -import { AnnouncementsCommand } from './Subcommands/announcements'; -import { AuditCommand } from './Subcommands/audit'; -import { BirthdaysConfigCommand } from './Subcommands/birthdays'; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + InteractionContextType, +} from 'discord.js'; +import { ServerConfig } from '../../client/models/ServerConfig'; +import { SlashCommand, SlashSubCommand, SlashSubGroupCommand } from '../../types/SlashCommand'; +import { AnnouncementsCommand } from './Subcommands/announcements'; +import { AuditCommand } from './Subcommands/audit'; +import { BirthdaysConfigCommand } from './Subcommands/birthdays'; import { DebugCommand } from './Subcommands/debug'; import { HoneyPotCommand } from './Subcommands/honeypot'; -import { NewRolesCommand } from './Subcommands/newroles'; -import { SystemMessagesCommand } from './Subcommands/systemMsgs'; -import { WelcomeCommand } from './Subcommands/welcome'; - -const cmdMap: {[key: string]: SlashSubGroupCommand | SlashSubCommand} = { - announcements: AnnouncementsCommand, - debug: DebugCommand, - sysmsgs: SystemMessagesCommand, - audit: AuditCommand, - welcome: WelcomeCommand, - newroles: NewRolesCommand, - birthdays: BirthdaysConfigCommand, - honeypot: HoneyPotCommand -}; - -const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommandGroup() ?? cmd.options.getSubcommand(); - const subCmd = cmdMap[subCommand]; - - if (!subCommand) { - cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); - } - - subCmd.execute(cmd, config); -}; - -const commandData = new SlashCommandBuilder() - .setName('config') - .setDescription('Manage configuration data for this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]) - .addSubcommandGroup(AnnouncementsCommand.commandData) - .addSubcommandGroup(DebugCommand.commandData) - .addSubcommandGroup(SystemMessagesCommand.commandData) - .addSubcommandGroup(AuditCommand.commandData) - .addSubcommandGroup(WelcomeCommand.commandData) - .addSubcommandGroup(NewRolesCommand.commandData) - .addSubcommandGroup(BirthdaysConfigCommand.commandData) - .addSubcommandGroup(HoneyPotCommand.commandData); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; +import { NewRolesCommand } from './Subcommands/newroles'; +import { SystemMessagesCommand } from './Subcommands/systemMsgs'; +import { WelcomeCommand } from './Subcommands/welcome'; + +const cmdMap: { [key: string]: SlashSubGroupCommand | SlashSubCommand } = { + announcements: AnnouncementsCommand, + debug: DebugCommand, + sysmsgs: SystemMessagesCommand, + audit: AuditCommand, + welcome: WelcomeCommand, + newroles: NewRolesCommand, + birthdays: BirthdaysConfigCommand, + honeypot: HoneyPotCommand, +}; + +const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { + const subCommand = cmd.options.getSubcommandGroup() ?? cmd.options.getSubcommand(); + const subCmd = cmdMap[subCommand]; + + if (!subCommand) { + cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); + } + + subCmd.execute(cmd, config); +}; + +const commandData = new SlashCommandBuilder() + .setName('config') + .setDescription('Manage configuration data for this server') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setContexts([InteractionContextType.Guild]) + .addSubcommandGroup(AnnouncementsCommand.commandData) + .addSubcommandGroup(DebugCommand.commandData) + .addSubcommandGroup(SystemMessagesCommand.commandData) + .addSubcommandGroup(AuditCommand.commandData) + .addSubcommandGroup(WelcomeCommand.commandData) + .addSubcommandGroup(NewRolesCommand.commandData) + .addSubcommandGroup(BirthdaysConfigCommand.commandData) + .addSubcommandGroup(HoneyPotCommand.commandData); + +const slashCommand: SlashCommand = { + commandData: commandData, + execute: execute, +}; export = slashCommand; diff --git a/src/slash-commands/Info/about.ts b/src/slash-commands/Info/about.ts index 99b9c510..02c1916a 100644 --- a/src/slash-commands/Info/about.ts +++ b/src/slash-commands/Info/about.ts @@ -1,38 +1,42 @@ -import { ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder } from 'discord.js'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; -import * as packageJson from '../../../package.json'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const version = packageJson.version; - const projectUrl = packageJson.repository; - - const embed = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: cmd.client.user.tag, - iconURL: cmd.client.user.displayAvatarURL(), - }) - .setDescription('Dingir Discord Bot') - .addField('Version', version.length > 0 ? version : 'Unknown') - .addField('Project URL', projectUrl.url) - .addField('Servers', cmd.client.guilds.cache.size.toString()) - .setTimestamp(); - - return cmd.reply({ - embeds: [embed], - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('about') - .setDescription('Returns info about the bot and server') - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file +import { + ChatInputCommandInteraction, + InteractionContextType, + SlashCommandBuilder, +} from 'discord.js'; +import { EmbedColours } from '../../resources/EmbedColours'; +import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; +import { SlashCommand } from '../../types/SlashCommand'; +import * as packageJson from '../../../package.json'; + +const execute = async (cmd: ChatInputCommandInteraction) => { + const version = packageJson.version; + const projectUrl = packageJson.repository; + + const embed = new EmbedCompatLayer() + .setColor(EmbedColours.positive) + .setAuthor({ + name: cmd.client.user.tag, + iconURL: cmd.client.user.displayAvatarURL(), + }) + .setDescription('Dingir Discord Bot') + .addField('Version', version.length > 0 ? version : 'Unknown') + .addField('Project URL', projectUrl.url) + .addField('Servers', cmd.client.guilds.cache.size.toString()) + .setTimestamp(); + + return cmd.reply({ + embeds: [embed], + ephemeral: true, + }); +}; + +const commandData = new SlashCommandBuilder() + .setName('about') + .setDescription('Returns info about the bot and server') + .setContexts([InteractionContextType.Guild]); + +const slashCommand: SlashCommand = { + commandData: commandData, + execute: execute, +}; +export = slashCommand; diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index a30262c6..437b05ac 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -1,78 +1,84 @@ -import { ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder, SlashCommandNumberOption } from 'discord.js'; -import { DateTime } from 'luxon'; -import { SlashCommand } from '../../types/SlashCommand'; -import { BirthdayManager } from '../../utilities/BirthdayManager'; -import { UserProfileService } from '../../utilities/UserProfileService'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - let day = cmd.options.getNumber('day'); - const month = cmd.options.getNumber('month'); - - let alteredForLeap = false; - - const now = DateTime.local(); - - if (!now.isInLeapYear && day === 29 && month === 2) { - day--; - alteredForLeap = true; - } - - let nextDate = DateTime.local(now.year, month, day); - - if (nextDate <= now) { - nextDate = nextDate.plus({ - year: 1, - }); - if (nextDate.isInLeapYear && alteredForLeap) { - nextDate = nextDate.plus({ - day: 1, - }); - } - } - - if (!nextDate.isValid) { - return cmd.reply({ - content: 'It looks like that date was invalid, make sure a valid day and month were given', - ephemeral: true - }); - } - - const userProfile = await UserProfileService.getUserProfile( - cmd.guild.id, - cmd.user.id - ); - - userProfile.birthdayDay = day; - userProfile.birthdayMonth = month; - - await userProfile.save(); - - cmd.reply({ - content: `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!`, - ephemeral: true - }); - return BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); -}; - -const commandData = new SlashCommandBuilder() - .setName('mybirthday') - .setDescription('Set your birthday in this server') - .addNumberOption((opt: SlashCommandNumberOption) => opt - .setName('day') - .setDescription('Day of the month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(31)) - .addNumberOption((opt: SlashCommandNumberOption) => opt - .setName('month') - .setDescription('Month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(12)) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file +import { + ChatInputCommandInteraction, + InteractionContextType, + SlashCommandBuilder, + SlashCommandNumberOption, +} from 'discord.js'; +import { DateTime } from 'luxon'; +import { SlashCommand } from '../../types/SlashCommand'; +import { BirthdayManager } from '../../utilities/BirthdayManager'; +import { UserProfileService } from '../../utilities/UserProfileService'; + +const execute = async (cmd: ChatInputCommandInteraction) => { + let day = cmd.options.getNumber('day'); + const month = cmd.options.getNumber('month'); + + let alteredForLeap = false; + + const now = DateTime.local(); + + if (!now.isInLeapYear && day === 29 && month === 2) { + day--; + alteredForLeap = true; + } + + let nextDate = DateTime.local(now.year, month, day); + + if (nextDate <= now) { + nextDate = nextDate.plus({ + year: 1, + }); + if (nextDate.isInLeapYear && alteredForLeap) { + nextDate = nextDate.plus({ + day: 1, + }); + } + } + + if (!nextDate.isValid) { + return cmd.reply({ + content: 'It looks like that date was invalid, make sure a valid day and month were given', + ephemeral: true, + }); + } + + const userProfile = await UserProfileService.getUserProfile(cmd.guild.id, cmd.user.id); + + userProfile.birthdayDay = day; + userProfile.birthdayMonth = month; + + await userProfile.save(); + + cmd.reply({ + content: `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!`, + ephemeral: true, + }); + return BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); +}; + +const commandData = new SlashCommandBuilder() + .setName('mybirthday') + .setDescription('Set your birthday in this server') + .addNumberOption((opt: SlashCommandNumberOption) => + opt + .setName('day') + .setDescription('Day of the month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(31) + ) + .addNumberOption((opt: SlashCommandNumberOption) => + opt + .setName('month') + .setDescription('Month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(12) + ) + .setContexts([InteractionContextType.Guild]); + +const slashCommand: SlashCommand = { + commandData: commandData, + execute: execute, +}; +export = slashCommand; diff --git a/src/slash-commands/Info/ping.ts b/src/slash-commands/Info/ping.ts index e8f985ba..31454f00 100644 --- a/src/slash-commands/Info/ping.ts +++ b/src/slash-commands/Info/ping.ts @@ -1,20 +1,24 @@ -import { ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder } from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - return cmd.reply({ - content: 'Pong!', - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('ping') - .setDescription('Pings Dingir') - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file +import { + ChatInputCommandInteraction, + InteractionContextType, + SlashCommandBuilder, +} from 'discord.js'; +import { SlashCommand } from '../../types/SlashCommand'; + +const execute = async (cmd: ChatInputCommandInteraction) => { + return cmd.reply({ + content: 'Pong!', + ephemeral: true, + }); +}; + +const commandData = new SlashCommandBuilder() + .setName('ping') + .setDescription('Pings Dingir') + .setContexts([InteractionContextType.Guild]); + +const slashCommand: SlashCommand = { + commandData: commandData, + execute: execute, +}; +export = slashCommand; diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index 76aefa71..0a6c9a66 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -1,52 +1,51 @@ -import { ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, SlashCommandUserOption } from 'discord.js'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; -import { UserProfileService } from '../../utilities/UserProfileService'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const member = cmd.guild.members.cache.get(user.id); - - const userProfile = await UserProfileService.getUserProfile( - cmd.guild.id, - member.id - ); - - const embed = new EmbedCompatLayer() - .setThumbnail(member.displayAvatarURL()) - .setColor(EmbedColours.info) - .setTitle('User Profile') - .setTimestamp() - .addField('Member', member.toString()) - .addField('Nickname', member.nickname ? member.nickname : 'Not set') - .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) - .addField('Joined', ``) - .addField('Screening', member.pending ? 'Not completed' : 'Passed') - .addField( - 'Activity Score', - userProfile ? userProfile.activityScore.toString() : 'Not found' - ) - .addField('ID', member.user.id); - - return cmd.reply({ - embeds: [embed], - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('profile') - .setDescription('Fetches profiles for server members') - .addUserOption((opt: SlashCommandUserOption) => opt - .setName('member') - .setDescription('Member to fetch profile for') - .setRequired(true)) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file +import { + ChatInputCommandInteraction, + InteractionContextType, + PermissionFlagsBits, + SlashCommandBuilder, + SlashCommandUserOption, +} from 'discord.js'; +import { EmbedColours } from '../../resources/EmbedColours'; +import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; +import { SlashCommand } from '../../types/SlashCommand'; +import { UserProfileService } from '../../utilities/UserProfileService'; + +const execute = async (cmd: ChatInputCommandInteraction) => { + const user = cmd.options.getUser('member'); + const member = cmd.guild.members.cache.get(user.id); + + const userProfile = await UserProfileService.getUserProfile(cmd.guild.id, member.id); + + const embed = new EmbedCompatLayer() + .setThumbnail(member.displayAvatarURL()) + .setColor(EmbedColours.info) + .setTitle('User Profile') + .setTimestamp() + .addField('Member', member.toString()) + .addField('Nickname', member.nickname ? member.nickname : 'Not set') + .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) + .addField('Joined', ``) + .addField('Screening', member.pending ? 'Not completed' : 'Passed') + .addField('Activity Score', userProfile ? userProfile.activityScore.toString() : 'Not found') + .addField('ID', member.user.id); + + return cmd.reply({ + embeds: [embed], + ephemeral: true, + }); +}; + +const commandData = new SlashCommandBuilder() + .setName('profile') + .setDescription('Fetches profiles for server members') + .addUserOption((opt: SlashCommandUserOption) => + opt.setName('member').setDescription('Member to fetch profile for').setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setContexts([InteractionContextType.Guild]); + +const slashCommand: SlashCommand = { + commandData: commandData, + execute: execute, +}; +export = slashCommand; diff --git a/src/types/Command.ts b/src/types/Command.ts index 80922d50..2cddd938 100644 --- a/src/types/Command.ts +++ b/src/types/Command.ts @@ -4,12 +4,7 @@ import { NovaClient } from '../client/NovaClient'; export interface Command { name: string; - run( - client: NovaClient, - message: Message, - config: ServerConfig, - args: any[] - ): Promise; + run(client: NovaClient, message: Message, config: ServerConfig, args: any[]): Promise; title: string; description: string; usage: string; diff --git a/src/types/SlashCommand.ts b/src/types/SlashCommand.ts index 78cdcef0..c26838e7 100644 --- a/src/types/SlashCommand.ts +++ b/src/types/SlashCommand.ts @@ -1,23 +1,25 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandsOnlyBuilder} from 'discord.js'; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, + SlashCommandSubcommandsOnlyBuilder, +} from 'discord.js'; import { ServerConfig } from '../client/models/ServerConfig'; export interface SlashCommand { - commandData: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder | SlashCommandOptionsOnlyBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise + commandData: + SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder | SlashCommandOptionsOnlyBuilder; + execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise; } export interface SlashSubGroupCommand { - commandData: SlashCommandSubcommandGroupBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise + commandData: SlashCommandSubcommandGroupBuilder; + execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise; } export interface SlashSubCommand { - commandData: SlashCommandSubcommandBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise -} \ No newline at end of file + commandData: SlashCommandSubcommandBuilder; + execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise; +} diff --git a/src/utilities/BirthdayManager.ts b/src/utilities/BirthdayManager.ts index 77cd6ad6..4d0931ee 100644 --- a/src/utilities/BirthdayManager.ts +++ b/src/utilities/BirthdayManager.ts @@ -9,29 +9,21 @@ import { ServerConfig } from '../client/models/ServerConfig'; import { UserProfile } from '../client/models/UserProfile'; export class BirthdayManager { - public static async populateCalendars (client: Client, serverId?: string): Promise { - Logger.writeLog('Running birthday calendar update job.'); + public static async populateCalendars(client: Client, serverId?: string): Promise { + Logger.writeLog('Running birthday calendar update job.'); let parsedConfigs: ServerConfig[]; if (serverId) { - const serverConfig = await ConfigService.getConfig(serverId).catch( - (err) => { - return Logger.writeError( - 'Error fetching server config for single run.', - err - ); - } - ); + const serverConfig = await ConfigService.getConfig(serverId).catch((err) => { + return Logger.writeError('Error fetching server config for single run.', err); + }); if (serverConfig) { parsedConfigs = [serverConfig]; } } else { const serverConfigs = await ConfigService.getConfigs().catch((err) => { - return Logger.writeError( - 'Error fetching server configs for bulk run.', - err - ); + return Logger.writeError('Error fetching server configs for bulk run.', err); }); if (serverConfigs) { parsedConfigs = serverConfigs; @@ -39,37 +31,24 @@ export class BirthdayManager { } if (parsedConfigs.length < 1) { - return Logger.writeLog( - 'No server configs to run birthday calendar population for.' - ); + return Logger.writeLog('No server configs to run birthday calendar population for.'); } - parsedConfigs = parsedConfigs.filter( - (config) => config.birthdayCalendarMessagePath - ); + parsedConfigs = parsedConfigs.filter((config) => config.birthdayCalendarMessagePath); for (const config of parsedConfigs) { - const profiles = await UserProfileService.getServerBirthdays( - config.serverId - ); - const [channelId, messageId] = - config.birthdayCalendarMessagePath.split('/'); + const profiles = await UserProfileService.getServerBirthdays(config.serverId); + const [channelId, messageId] = config.birthdayCalendarMessagePath.split('/'); let messageContent = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; const chanToEdit = await client.channels.fetch(channelId); if (!chanToEdit || !chanToEdit.isTextBased()) { - Logger.writeError( - `Could not find birthday channel for server ${config.serverId}` - ); + Logger.writeError(`Could not find birthday channel for server ${config.serverId}`); } - const birthdayMessage = await (chanToEdit as TextChannel).messages.fetch( - messageId - ); + const birthdayMessage = await (chanToEdit as TextChannel).messages.fetch(messageId); if (!birthdayMessage) { - Logger.writeError( - `Could not find birthday message for server ${config.serverId}` - ); + Logger.writeError(`Could not find birthday message for server ${config.serverId}`); } if (profiles.length < 1) { @@ -81,20 +60,12 @@ export class BirthdayManager { const now = DateTime.local(); - if ( - !now.isInLeapYear && - u.birthdayDay === 29 && - u.birthdayMonth === 2 - ) { + if (!now.isInLeapYear && u.birthdayDay === 29 && u.birthdayMonth === 2) { u.birthdayDay--; alteredForLeap = true; } - let nextDate = DateTime.local( - now.year, - u.birthdayMonth, - u.birthdayDay - ); + let nextDate = DateTime.local(now.year, u.birthdayMonth, u.birthdayDay); if (nextDate <= now) { nextDate = nextDate.plus({ @@ -121,9 +92,7 @@ export class BirthdayManager { messageContent += '-------------'; for (const group in groupedSort) { - const date = groupedSort[group][0].birthday.toLocaleString( - DateTime.DATE_FULL - ); + const date = groupedSort[group][0].birthday.toLocaleString(DateTime.DATE_FULL); let members = ''; groupedSort[group].forEach((m) => { @@ -145,13 +114,17 @@ export class BirthdayManager { Logger.writeLog('Finished birthday calendar update job.'); } - public static async notifyServerBirthdays(client: NovaClient, profiles: UserProfile[], config: ServerConfig) { + public static async notifyServerBirthdays( + client: NovaClient, + profiles: UserProfile[], + config: ServerConfig + ) { if (config && config.announcementsChannelId) { const server: Guild = client.guilds.cache.get(config.serverId); if (!server) { return Logger.writeError(`Server missing with id ${config.serverId}`); } - + const announcementsChannel = server.channels.cache.get(config.announcementsChannelId); if (!announcementsChannel) { return Logger.writeError(`Announcements Channel missing for: ${server.toString()}`); diff --git a/src/utilities/ChannelService.ts b/src/utilities/ChannelService.ts index 6aa9b2c0..bda634e7 100644 --- a/src/utilities/ChannelService.ts +++ b/src/utilities/ChannelService.ts @@ -19,7 +19,7 @@ export class ChannelService { await auditChannel.send({ embeds: [embed], - files: attachment ? [attachment] : undefined + files: attachment ? [attachment] : undefined, }); } } diff --git a/src/utilities/CommandRegistrar.ts b/src/utilities/CommandRegistrar.ts index 4e8f168c..38a65617 100644 --- a/src/utilities/CommandRegistrar.ts +++ b/src/utilities/CommandRegistrar.ts @@ -3,14 +3,17 @@ import { NovaClient } from '../client/NovaClient'; import { Logger } from './Logger'; export class CommandRegistrar { - public static async registerGlobalCommands(client: NovaClient) { - Logger.writeLog('Registering Global Commands...'); - const cmdData = client.slashCommands.map(v => v.commandData.toJSON()); + public static async registerGlobalCommands(client: NovaClient) { + Logger.writeLog('Registering Global Commands...'); + const cmdData = client.slashCommands.map((v) => v.commandData.toJSON()); - const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); + const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); - rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: cmdData }) - .then((data: any) => Logger.writeLog(`Successfully registered ${data.length} application commands.`)) - .catch(console.error); - } + rest + .put(Routes.applicationCommands(process.env.CLIENT_ID), { body: cmdData }) + .then((data: any) => + Logger.writeLog(`Successfully registered ${data.length} application commands.`) + ) + .catch(console.error); + } } diff --git a/src/utilities/ConfigManager.ts b/src/utilities/ConfigManager.ts index 42afece0..256d77aa 100644 --- a/src/utilities/ConfigManager.ts +++ b/src/utilities/ConfigManager.ts @@ -2,71 +2,79 @@ import { ChannelType, ChatInputCommandInteraction, InteractionResponse } from 'd import { ServerConfig } from '../client/models/ServerConfig'; export class ConfigManager { - public static async updateChannel(serverConfig: ServerConfig, interaction: ChatInputCommandInteraction, field: string): Promise { - const chan = interaction.options.getChannel('channel'); + public static async updateChannel( + serverConfig: ServerConfig, + interaction: ChatInputCommandInteraction, + field: string + ): Promise { + const chan = interaction.options.getChannel('channel'); - let messageContent: string; - if (!chan && serverConfig[field]) { - serverConfig[field] = null; - } else if (!chan && !serverConfig[field]) { - messageContent = 'Please tag the channel.'; - } else if (!chan) { - messageContent = - 'Channel not found, or I do not have permission to access it.'; - } else if ( - chan.type !== ChannelType.GuildText && - chan.type !== ChannelType.GuildNews - ) { - messageContent = 'Channel must be a Text Channel.'; - } + let messageContent: string; + if (!chan && serverConfig[field]) { + serverConfig[field] = null; + } else if (!chan && !serverConfig[field]) { + messageContent = 'Please tag the channel.'; + } else if (!chan) { + messageContent = 'Channel not found, or I do not have permission to access it.'; + } else if (chan.type !== ChannelType.GuildText && chan.type !== ChannelType.GuildNews) { + messageContent = 'Channel must be a Text Channel.'; + } - if (messageContent) { - return interaction.reply({ - content: messageContent, - ephemeral: true - }); - } + if (messageContent) { + return interaction.reply({ + content: messageContent, + ephemeral: true, + }); + } - if (chan) { - serverConfig[field] = chan.id; - } + if (chan) { + serverConfig[field] = chan.id; + } - await serverConfig.save(); + await serverConfig.save(); - if (serverConfig[field]) { - return interaction.reply({ - content: `Channel updated to ${chan.toString()}.`, - ephemeral: true - }); - } else { - return interaction.reply({ - content: 'Channel disabled.', - ephemeral: true - }); - } - } + if (serverConfig[field]) { + return interaction.reply({ + content: `Channel updated to ${chan.toString()}.`, + ephemeral: true, + }); + } else { + return interaction.reply({ + content: 'Channel disabled.', + ephemeral: true, + }); + } + } - public static async getChannel(interaction: ChatInputCommandInteraction, serverConfig: ServerConfig, field: string) { - if (serverConfig[field]) { - return interaction.guild.channels.cache.get(serverConfig[field]); - } + public static async getChannel( + interaction: ChatInputCommandInteraction, + serverConfig: ServerConfig, + field: string + ) { + if (serverConfig[field]) { + return interaction.guild.channels.cache.get(serverConfig[field]); + } - return null; - } + return null; + } - public static async updateChannelNew(interaction: ChatInputCommandInteraction, serverConfig: ServerConfig, field: string) { - const chan = interaction.options.getChannel('channel'); - if (!chan) { - return interaction.reply('Provided channel is not valid'); - } - serverConfig[field] = chan.id; - serverConfig.save(); + public static async updateChannelNew( + interaction: ChatInputCommandInteraction, + serverConfig: ServerConfig, + field: string + ) { + const chan = interaction.options.getChannel('channel'); + if (!chan) { + return interaction.reply('Provided channel is not valid'); + } + serverConfig[field] = chan.id; + serverConfig.save(); - return chan; - } + return chan; + } - public static async clearChannel(serverConfig: ServerConfig, field: string) { - serverConfig[field] = null; - serverConfig.save(); - } + public static async clearChannel(serverConfig: ServerConfig, field: string) { + serverConfig[field] = null; + serverConfig.save(); + } } diff --git a/src/utilities/ConfigService.ts b/src/utilities/ConfigService.ts index e646c482..be8f1976 100644 --- a/src/utilities/ConfigService.ts +++ b/src/utilities/ConfigService.ts @@ -2,12 +2,14 @@ import { ChatInputCommandInteraction, Message } from 'discord.js'; import { ServerConfig } from '../client/models/ServerConfig'; export class ConfigService { - public static async getConfigByMessage (message: Message | ChatInputCommandInteraction): Promise { - const [ config ] = await ServerConfig.findOrCreate({ - where: { - serverId: message.guild.id - } - }); + public static async getConfigByMessage( + message: Message | ChatInputCommandInteraction + ): Promise { + const [config] = await ServerConfig.findOrCreate({ + where: { + serverId: message.guild.id, + }, + }); return config; } diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index 034951cf..0e5d65c5 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -2,21 +2,13 @@ import { DateTime } from 'luxon'; export class Logger { public static writeLog(log: string): void { - console.log( - `[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}` - ); + console.log(`[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}`); } public static writeError(log: string, error?: string): void { - console.error( - `[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}` - ); + console.error(`[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}`); if (error) { - console.error( - `[${DateTime.local().toLocaleString( - DateTime.DATETIME_SHORT - )}]: ${error}` - ); + console.error(`[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${error}`); } } } diff --git a/src/utilities/UserProfileService.ts b/src/utilities/UserProfileService.ts index a35779f3..5f000b0f 100644 --- a/src/utilities/UserProfileService.ts +++ b/src/utilities/UserProfileService.ts @@ -30,9 +30,7 @@ export class UserProfileService { }); } - public static async getServerBirthdays( - serverId: string - ): Promise { + public static async getServerBirthdays(serverId: string): Promise { return UserProfile.findAll({ where: { birthdayDay: { @@ -46,10 +44,7 @@ export class UserProfileService { }); } - public static async getUserProfile( - serverId: string, - userId: string - ): Promise { + public static async getUserProfile(serverId: string, userId: string): Promise { const [profiles] = await UserProfile.findOrCreate({ where: { serverId: serverId, @@ -98,10 +93,7 @@ export class UserProfileService { return this.getUserProfile(serverId, userId); } - public static async deleteUser( - serverId: string, - userId: string - ): Promise { + public static async deleteUser(serverId: string, userId: string): Promise { const recordsDeleted = await UserProfile.destroy({ where: { serverId: serverId, From 8d8b0eecfcf84cf83936752646e3ebdb41175b7e Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:31:53 +0100 Subject: [PATCH 02/77] Add tests, typed lint, strictNullChecks and env validation - node:test harness (tsconfig.test.json, npm test, CI step) with env, Logger and in-memory sequelize tests; createSequelize factory - src/config/env.ts validates runtime config at startup; .env.example updated - Logger accepts unknown errors (prints stack), adds level helpers, ISO timestamps - typed linting with no-floating-promises / no-misused-promises; fix violations - enable strictNullChecks and fix fallout Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 19 ++- .github/workflows/lint-and-build.yml | 3 + eslint.config.mjs | 15 +++ package.json | 1 + src/client/NovaClient.ts | 25 ++-- src/client/database/createSequelize.ts | 14 +++ src/client/database/sequelize.ts | 14 +-- src/client/models/ServerConfig.ts | 30 ++--- src/client/models/UserProfile.ts | 14 +-- src/config/env.ts | 116 ++++++++++++++++++ src/events/guildMemberUpdate.ts | 34 +++-- src/events/interactionCreate.ts | 26 ++-- src/events/messageCreate.ts | 4 +- src/events/ready.ts | 18 ++- src/index.ts | 24 +++- src/slash-commands/Admin/noroles.ts | 9 +- src/slash-commands/Admin/post.ts | 26 ++-- src/slash-commands/Admin/rolesince.ts | 15 +-- src/slash-commands/Admin/simulate.ts | 30 +++-- .../Config/Subcommands/announcements.ts | 4 +- .../Config/Subcommands/audit.ts | 4 +- .../Config/Subcommands/birthdays.ts | 11 +- .../Config/Subcommands/debug.ts | 4 +- .../Config/Subcommands/honeypot.ts | 3 +- .../Config/Subcommands/newroles.ts | 21 ++-- .../Config/Subcommands/systemMsgs.ts | 4 +- .../Config/Subcommands/welcome.ts | 10 +- src/slash-commands/Config/config.ts | 4 +- src/slash-commands/Info/mybirthday.ts | 12 +- src/slash-commands/Info/profile.ts | 13 +- src/utilities/BirthdayManager.ts | 22 ++-- src/utilities/ChannelService.ts | 2 +- src/utilities/CommandRegistrar.ts | 5 +- src/utilities/ConfigManager.ts | 10 +- src/utilities/ConfigService.ts | 4 + src/utilities/Logger.ts | 68 +++++++++- src/utilities/requireGuild.ts | 8 ++ tests/database.test.ts | 25 ++++ tests/env.test.ts | 81 ++++++++++++ tests/logger.test.ts | 58 +++++++++ tests/tsconfig.json | 7 ++ tsconfig.json | 1 + tsconfig.test.json | 7 ++ 43 files changed, 661 insertions(+), 164 deletions(-) create mode 100644 src/client/database/createSequelize.ts create mode 100644 src/config/env.ts create mode 100644 src/utilities/requireGuild.ts create mode 100644 tests/database.test.ts create mode 100644 tests/env.test.ts create mode 100644 tests/logger.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tsconfig.test.json diff --git a/.env.example b/.env.example index 1c6470f9..059f87cf 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,20 @@ +# Required: bot token and application (client) ID from the Discord developer portal TOKEN= CLIENT_ID= -JOB_SCHEDULE=0 9 * * * \ No newline at end of file + +# Cron schedule (5 or 6 fields) for cleanup, birthday notifications and calendars +JOB_SCHEDULE=0 9 * * * + +# IANA time zone used for scheduling and birthday calculations +BOT_TIMEZONE=Europe/London + +# SQLite database file (directory is created if missing); use :memory: for throwaway runs +DB_PATH=data/dingir.sqlite + +# Data for unreachable guilds is only purged after at least this many failed checks... +PURGE_MIN_FAILURES=3 +# ...spanning at least this many days +PURGE_GRACE_DAYS=7 + +# Optional: register slash commands to this guild only (instant updates during development) +# DEV_GUILD_ID= diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 5bea0189..198bb34c 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -40,3 +40,6 @@ jobs: - name: Build TypeScript run: npm run build + + - name: Run tests + run: npm test diff --git a/eslint.config.mjs b/eslint.config.mjs index 3690be9b..a998912e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,6 +8,12 @@ export default defineConfig([ tseslint.configs.recommended, { files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, rules: { 'no-tabs': ['error', { allowIndentationTabs: true }], 'quotes': [ @@ -19,6 +25,15 @@ export default defineConfig([ 'brace-style': 'error', 'curly': ['error', 'all'], '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + }, + }, + { + // node:test's top-level test()/describe() calls return promises that the runner tracks + files: ['tests/**/*.ts'], + rules: { + '@typescript-eslint/no-floating-promises': 'off', }, }, ]); diff --git a/package.json b/package.json index ea411a7c..0ced080e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "tsc", "postbuild": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist/src/", "lint": "eslint .", + "test": "tsc -p tsconfig.test.json && node --test \"dist/tests/**/*.test.js\"", "dev": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"npm run copy-and-start\"", "copy-and-start": "npm run postbuild && npm start", "start": "node dist/src/index.js" diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts index 751245b3..79d94fdb 100644 --- a/src/client/NovaClient.ts +++ b/src/client/NovaClient.ts @@ -1,8 +1,8 @@ +import { env } from '../config/env'; import { glob } from 'glob'; import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; import { Event } from '../types/Event'; import { Logger } from '../utilities/Logger'; -import 'dotenv/config'; import { sequelize } from './database/sequelize'; import { SlashCommand } from '../types/SlashCommand'; @@ -30,26 +30,31 @@ class NovaClient extends Client { const eventFiles: string[] = await glob(`${__dirname}/../events/**/*{.js,.ts}`); const slashCommandFiles: string[] = await glob(`${__dirname}/../slash-commands/*/*{.js,.ts}`); - eventFiles.forEach(async (eventFile: string) => { + for (const eventFile of eventFiles) { const importedEvent = await import(eventFile); const event = (importedEvent.default ?? importedEvent) as Event; this.events.set(event.name, event); - this.on(event.name, event.run.bind(null, this)); - }); - - slashCommandFiles.forEach(async (slashCommandFile: string) => { + this.on(event.name, (...args: any[]) => { + event.run(this, ...args).catch((err: unknown) => { + Logger.writeError(`Unhandled error in ${event.name} event handler.`, err); + }); + }); + } + + for (const slashCommandFile of slashCommandFiles) { const importedCommand = await import(slashCommandFile); const cmd = (importedCommand.default ?? importedCommand) as SlashCommand; this.slashCommands.set(cmd.commandData.name, cmd); - }); + } process.on('SIGTERM', () => { Logger.writeLog('SIGTERM Received, destroying client & shutting down.'); - this.destroy(); - process.exit(); + this.destroy() + .catch((err: unknown) => Logger.writeError('Error destroying client.', err)) + .finally(() => process.exit()); }); - await this.login(process.env.TOKEN); + await this.login(env.token); Logger.writeLog('Logged in'); } } diff --git a/src/client/database/createSequelize.ts b/src/client/database/createSequelize.ts new file mode 100644 index 00000000..cd6b7d01 --- /dev/null +++ b/src/client/database/createSequelize.ts @@ -0,0 +1,14 @@ +import { Sequelize } from 'sequelize-typescript'; +import { Logger } from '../../utilities/Logger'; + +import { ServerConfig } from '../models/ServerConfig'; +import { UserProfile } from '../models/UserProfile'; + +export function createSequelize(storage: string): Sequelize { + return new Sequelize({ + dialect: 'sqlite', + storage, + models: [ServerConfig, UserProfile], + logging: (msg) => Logger.writeLog(msg), + }); +} diff --git a/src/client/database/sequelize.ts b/src/client/database/sequelize.ts index 1a0f3c54..ded915a6 100644 --- a/src/client/database/sequelize.ts +++ b/src/client/database/sequelize.ts @@ -1,12 +1,4 @@ -import { Sequelize } from 'sequelize-typescript'; -import { Logger } from '../../utilities/Logger'; +import { env } from '../../config/env'; +import { createSequelize } from './createSequelize'; -import { ServerConfig } from '../models/ServerConfig'; -import { UserProfile } from '../models/UserProfile'; - -export const sequelize = new Sequelize({ - dialect: 'sqlite', - storage: 'data/dingir.sqlite', - models: [ServerConfig, UserProfile], - logging: (msg) => Logger.writeLog(msg), -}); +export const sequelize = createSequelize(env.dbPath); diff --git a/src/client/models/ServerConfig.ts b/src/client/models/ServerConfig.ts index dd0c65d7..8f17974d 100644 --- a/src/client/models/ServerConfig.ts +++ b/src/client/models/ServerConfig.ts @@ -1,4 +1,4 @@ -import { Table, Column, Model, PrimaryKey, Default } from 'sequelize-typescript'; +import { Table, Column, DataType, Model, PrimaryKey, Default } from 'sequelize-typescript'; @Table export class ServerConfig extends Model { @@ -16,35 +16,35 @@ export class ServerConfig extends Model { @Column declare rulesMessage: string; - @Column - declare guestRoleIds: string; + @Column(DataType.STRING) + declare guestRoleIds: string | null; @Column declare adminRoleId: string; - @Column - declare welcomeMessage: string; + @Column(DataType.STRING) + declare welcomeMessage: string | null; @Default(false) @Column declare debug: boolean; - @Column - declare auditChannelId: string; + @Column(DataType.STRING) + declare auditChannelId: string | null; - @Column - declare welcomeMessageBackgroundUrl: string; + @Column(DataType.STRING) + declare welcomeMessageBackgroundUrl: string | null; @Default(false) @Column declare systemMessagesEnabled: boolean; - @Column - declare announcementsChannelId: string; + @Column(DataType.STRING) + declare announcementsChannelId: string | null; - @Column - declare birthdayCalendarMessagePath: string; + @Column(DataType.STRING) + declare birthdayCalendarMessagePath: string | null; - @Column - declare honeyPotChannelId: string; + @Column(DataType.STRING) + declare honeyPotChannelId: string | null; } diff --git a/src/client/models/UserProfile.ts b/src/client/models/UserProfile.ts index 9dd885f6..ca54f777 100644 --- a/src/client/models/UserProfile.ts +++ b/src/client/models/UserProfile.ts @@ -1,4 +1,4 @@ -import { Table, Column, Model, Default } from 'sequelize-typescript'; +import { Table, Column, DataType, Model, Default } from 'sequelize-typescript'; @Table export class UserProfile extends Model { @@ -8,14 +8,14 @@ export class UserProfile extends Model { @Column declare userId: string; - @Column - declare birthdayYear: number; + @Column(DataType.INTEGER) + declare birthdayYear: number | null; - @Column - declare birthdayMonth: number; + @Column(DataType.INTEGER) + declare birthdayMonth: number | null; - @Column - declare birthdayDay: number; + @Column(DataType.INTEGER) + declare birthdayDay: number | null; @Default(0) @Column diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 00000000..e888e9fe --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,116 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { IANAZone } from 'luxon'; + +export interface Env { + readonly token: string; + readonly clientId: string; + readonly jobSchedule: string; + readonly timezone: string; + readonly dbPath: string; + readonly purgeMinFailures: number; + readonly purgeGraceDays: number; + readonly devGuildId?: string; +} + +type Source = Record; + +const CRON_FIELD = /^[\w*/,?#-]+$/; + +function parseInteger(value: string, min: number): number | undefined { + if (!/^-?\d+$/.test(value)) { + return undefined; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= min ? parsed : undefined; +} + +export function loadEnv(source: Source = process.env): Readonly { + const problems: string[] = []; + const read = (key: string): string | undefined => { + const value = source[key]?.trim(); + return value ? value : undefined; + }; + const required = (key: string): string => { + const value = read(key); + if (!value) { + problems.push(`${key} is required`); + } + return value ?? ''; + }; + + const token = required('TOKEN'); + const clientId = required('CLIENT_ID'); + + const jobSchedule = read('JOB_SCHEDULE') ?? '0 9 * * *'; + const cronFields = jobSchedule.split(/\s+/); + if ( + (cronFields.length !== 5 && cronFields.length !== 6) || + !cronFields.every((field) => CRON_FIELD.test(field)) + ) { + problems.push(`JOB_SCHEDULE must be a 5- or 6-field cron expression (got "${jobSchedule}")`); + } + + const timezone = read('BOT_TIMEZONE') ?? 'Europe/London'; + if (!IANAZone.isValidZone(timezone)) { + problems.push(`BOT_TIMEZONE must be a valid IANA time zone (got "${timezone}")`); + } + + const dbPath = read('DB_PATH') ?? 'data/dingir.sqlite'; + + let purgeMinFailures = 3; + const rawFailures = read('PURGE_MIN_FAILURES'); + if (rawFailures !== undefined) { + const parsed = parseInteger(rawFailures, 1); + if (parsed === undefined) { + problems.push(`PURGE_MIN_FAILURES must be an integer >= 1 (got "${rawFailures}")`); + } else { + purgeMinFailures = parsed; + } + } + + let purgeGraceDays = 7; + const rawGrace = read('PURGE_GRACE_DAYS'); + if (rawGrace !== undefined) { + const parsed = parseInteger(rawGrace, 0); + if (parsed === undefined) { + problems.push(`PURGE_GRACE_DAYS must be an integer >= 0 (got "${rawGrace}")`); + } else { + purgeGraceDays = parsed; + } + } + + const devGuildId = read('DEV_GUILD_ID'); + + if (problems.length > 0) { + throw new Error(`Invalid environment configuration:\n - ${problems.join('\n - ')}`); + } + + return Object.freeze({ + token, + clientId, + jobSchedule, + timezone, + dbPath, + purgeMinFailures, + purgeGraceDays, + devGuildId, + }); +} + +let cached: Readonly | undefined; + +/** Loads and validates process.env, ensures the DB directory exists, and caches the result. */ +export function initEnv(source: Source = process.env): Readonly { + const loaded = loadEnv(source); + if (loaded.dbPath !== ':memory:') { + fs.mkdirSync(path.dirname(loaded.dbPath), { recursive: true }); + } + cached = loaded; + return loaded; +} + +/** Typed accessor for the validated environment; initialises lazily from process.env. */ +export const env: Readonly = new Proxy({} as Env, { + get: (_target, prop) => (cached ?? initEnv())[prop as keyof Env], +}); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 20cbf91e..bc95d93b 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -24,7 +24,7 @@ const applyText = (canvas: Canvas, text: string, baseSize: number) => { }; const getWelcomeMessage = async (config: ServerConfig, member: GuildMember) => { - return config.welcomeMessage.replace('{member}', `<@${member.id}>`); + return (config.welcomeMessage ?? '').replace('{member}', `<@${member.id}>`); }; const getWelcomeImage = async (config: ServerConfig, member: GuildMember) => { @@ -32,10 +32,17 @@ const getWelcomeImage = async (config: ServerConfig, member: GuildMember) => { const canvas = createCanvas(700, 250); const ctx = canvas.getContext('2d'); + if (member.joinedTimestamp === null) { + throw new Error(`Join date unavailable for member ${member.id}.`); + } + const backgroundUrl = config.welcomeMessageBackgroundUrl; + if (!backgroundUrl) { + throw new Error('Welcome message background URL is not configured.'); + } const joinedTs = DateTime.fromMillis(member.joinedTimestamp).toLocaleString(DateTime.DATE_FULL); // Draw background - const background = await loadImage(config.welcomeMessageBackgroundUrl); + const background = await loadImage(backgroundUrl); ctx.drawImage(background, 0, 0, canvas.width, canvas.height); // Draw Username @@ -80,7 +87,7 @@ const addGuestRoles = async ( client: NovaClient ) => { try { - const guestRoleIds = serverConfig.guestRoleIds.split(','); + const guestRoleIds = (serverConfig.guestRoleIds ?? '').split(','); const guildRoles = await newMember.guild.roles.fetch(); await newMember.roles.add(guildRoles.filter((role) => guestRoleIds.includes(role.id))); @@ -143,10 +150,15 @@ export const run: RunFunction = async ( oldMember.pending || (oldMember.pending === null && newMember.roles.cache.size === 1); if (notPassedScreen && !newMember.pending) { - sendScreenAudit(serverConfig, newMember, client); + await sendScreenAudit(serverConfig, newMember, client); if (serverConfig.guestRoleIds) { - addGuestRoles(serverConfig, newMember, client); + await addGuestRoles(serverConfig, newMember, client).catch((err: unknown) => + Logger.writeError( + `Guest role audit failed in guildMemberUpdate for ${serverConfig.id}.`, + err + ) + ); } if ( @@ -154,8 +166,8 @@ export const run: RunFunction = async ( (serverConfig.welcomeMessage || serverConfig.welcomeMessageBackgroundUrl) ) { try { - let attachment: AttachmentBuilder; - let content: string; + let attachment: AttachmentBuilder | undefined; + let content: string | undefined; if (serverConfig.welcomeMessageBackgroundUrl) { attachment = await getWelcomeImage(serverConfig, newMember); } @@ -164,8 +176,12 @@ export const run: RunFunction = async ( content = await getWelcomeMessage(serverConfig, newMember); } - await newMember.guild.systemChannel.send({ - content: content ?? undefined, + const systemChannel = newMember.guild.systemChannel; + if (!systemChannel) { + throw new Error(`No system channel for guild ${newMember.guild.id}.`); + } + await systemChannel.send({ + content, files: attachment ? [attachment] : undefined, }); } catch (e) { diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 31b890af..6c521ce4 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -19,18 +19,22 @@ const runCommand = async ( return; } - await slashCmd.execute(cmd, config).catch((err: string) => { - if (cmd.deferred && !cmd.replied) { - cmd.editReply({ - content: 'Something went wrong, was this command run in the correct place?', - }); - } else if (!cmd.replied) { - cmd.reply({ - content: 'Something went wrong, was this command run in the correct place?', - ephemeral: true, - }); + await slashCmd.execute(cmd, config).catch(async (err: unknown) => { + try { + if (cmd.deferred && !cmd.replied) { + await cmd.editReply({ + content: 'Something went wrong, was this command run in the correct place?', + }); + } else if (!cmd.replied) { + await cmd.reply({ + content: 'Something went wrong, was this command run in the correct place?', + ephemeral: true, + }); + } + } catch (replyErr) { + Logger.writeError('Failed to send command error reply.', replyErr); } - Logger.writeError(err); + Logger.writeError(`Command ${cmd.commandName} failed.`, err); }); }; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 5d102f33..c778dc98 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -17,7 +17,7 @@ const formatError = (error: unknown): string => { const sendAudit = async ( client: NovaClient, - message: Message, + message: Message, description: string, action: string ): Promise => { @@ -39,7 +39,7 @@ const sendAudit = async ( export const name = 'messageCreate'; export const run: RunFunction = async (client: NovaClient, message: Message) => { - if (!message.guild || message.author.bot) { + if (!message.inGuild() || message.author.bot) { return; } diff --git a/src/events/ready.ts b/src/events/ready.ts index 309ebf59..441b4c11 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,3 +1,4 @@ +import { env } from '../config/env'; import schedule from 'node-schedule'; import { NovaClient } from '../client/NovaClient'; import { RunFunction } from '../types/Event'; @@ -8,15 +9,22 @@ import { Logger } from '../utilities/Logger'; export const name = 'ready'; export const run: RunFunction = async (client: NovaClient) => { - client.user.setPresence({ status: 'online' }); + client.user!.setPresence({ status: 'online' }); Logger.writeLog('Online'); await CommandRegistrar.registerGlobalCommands(client); - const birthdaySchedule = schedule.scheduleJob(process.env.JOB_SCHEDULE, () => { - DataCheck.dataCleanup(client); - BirthdayManager.notifyBirthdays(client); - BirthdayManager.populateCalendars(client); + const logFailure = (job: string) => (err: unknown) => + Logger.writeError(`Scheduled job failed: ${job}.`, err); + + const birthdaySchedule = schedule.scheduleJob(env.jobSchedule, () => { + DataCheck.dataCleanup(client).catch(logFailure('data cleanup')); + BirthdayManager.notifyBirthdays(client).catch(logFailure('birthday notifications')); + BirthdayManager.populateCalendars(client).catch(logFailure('birthday calendars')); }); + if (!birthdaySchedule) { + Logger.writeError(`Could not schedule job with "${env.jobSchedule}".`); + return; + } Logger.writeLog(`Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}`); }; diff --git a/src/index.ts b/src/index.ts index c2ea7de8..8fcd6d93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,22 @@ -import { NovaClient } from './client/NovaClient'; +import 'dotenv/config'; +import { initEnv } from './config/env'; +import { Logger } from './utilities/Logger'; -const novaClient = new NovaClient(); -novaClient.start(); +async function main(): Promise { + try { + initEnv(); + } catch (error) { + Logger.writeError(error instanceof Error ? error.message : String(error)); + Logger.writeError('Invalid configuration, shutting down.'); + process.exit(1); + } + + const { NovaClient } = await import('./client/NovaClient.js'); + const novaClient = new NovaClient(); + await novaClient.start(); +} + +main().catch((error: unknown) => { + Logger.writeError('Fatal error during startup.', error); + process.exit(1); +}); diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts index 62dc7ec1..5b1147e4 100644 --- a/src/slash-commands/Admin/noroles.ts +++ b/src/slash-commands/Admin/noroles.ts @@ -5,9 +5,10 @@ import { InteractionContextType, } from 'discord.js'; import { SlashCommand } from '../../types/SlashCommand'; +import { requireGuild } from '../../utilities/requireGuild'; const execute = async (cmd: ChatInputCommandInteraction) => { - const allMembers = await cmd.guild.members.fetch(); + const allMembers = await requireGuild(cmd).members.fetch(); const members = allMembers.filter((member) => member.roles.cache.size === 1); @@ -17,12 +18,12 @@ const execute = async (cmd: ChatInputCommandInteraction) => { response = 'There are no users with no roles.'; } else { response = '**Users with no roles**\n------\n'; - members.each(async (mem) => { + for (const mem of members.values()) { if (mem.partial) { await mem.fetch(); } - response += `${mem.toString()} joined \n`; - }); + response += `${mem.toString()} joined \n`; + } } return cmd.reply({ diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts index 23046844..1170e72f 100644 --- a/src/slash-commands/Admin/post.ts +++ b/src/slash-commands/Admin/post.ts @@ -13,33 +13,35 @@ import { EmbedColours } from '../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; import { SlashCommand } from '../../types/SlashCommand'; import { ChannelService } from '../../utilities/ChannelService'; +import { Logger } from '../../utilities/Logger'; +import { requireGuild } from '../../utilities/requireGuild'; const sendAudit = async ( cmd: ChatInputCommandInteraction, config: ServerConfig, - file: Attachment, - content: string + file: Attachment | null, + content: string | null ) => { const embed = new EmbedCompatLayer(); - const member = cmd.guild.members.cache.get(cmd.user.id); + const member = requireGuild(cmd).members.cache.get(cmd.user.id); embed .setColor(EmbedColours.neutral) .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), + name: member?.displayName ?? cmd.user.username, + iconURL: (member ?? cmd.user).displayAvatarURL(), }) .addField('Content', content ? content : 'No') .addField('Attachment', file ? 'Yes' : 'No') .setDescription('Post created via Dingir') .setTimestamp(); - ChannelService.sendAuditMessage(cmd.client, config, embed, file); + await ChannelService.sendAuditMessage(cmd.client, config, embed, file ?? undefined); }; const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { await cmd.deferReply({ ephemeral: true }); - const channel = cmd.options.getChannel('channel'); + const channel = cmd.options.getChannel('channel', true); const content = cmd.options.getString('content'); const attachment = cmd.options.getAttachment('attachment'); @@ -49,7 +51,7 @@ const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) = }); } - const guildChannel = cmd.guild.channels.cache.get(channel.id); + const guildChannel = requireGuild(cmd).channels.cache.get(channel.id); if (!guildChannel || !guildChannel.isTextBased()) { return cmd.editReply({ content: 'The provided channel is not valid', @@ -58,8 +60,8 @@ const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) = await guildChannel .send({ - content: content, - files: attachment ? [attachment] : null, + content: content ?? undefined, + files: attachment ? [attachment] : undefined, }) .catch(() => { return cmd.editReply({ @@ -67,7 +69,9 @@ const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) = }); }); - sendAudit(cmd, config, attachment, content); + await sendAudit(cmd, config, attachment, content).catch((err: unknown) => + Logger.writeError('Could not send post audit.', err) + ); return cmd.editReply({ content: 'Message successfully sent', diff --git a/src/slash-commands/Admin/rolesince.ts b/src/slash-commands/Admin/rolesince.ts index 076651af..12fd283d 100644 --- a/src/slash-commands/Admin/rolesince.ts +++ b/src/slash-commands/Admin/rolesince.ts @@ -8,16 +8,17 @@ import { } from 'discord.js'; import { DateTime } from 'luxon'; import { SlashCommand } from '../../types/SlashCommand'; +import { requireGuild } from '../../utilities/requireGuild'; const execute = async (cmd: ChatInputCommandInteraction) => { - const role = cmd.options.getRole('role'); + const role = cmd.options.getRole('role', true); const days = cmd.options.getNumber('days') ?? 0; - const allMembers = await cmd.guild.members.fetch(); + const allMembers = await requireGuild(cmd).members.fetch(); const members = allMembers.filter((member) => { - const joined = DateTime.fromMillis(member.joinedTimestamp).startOf('day'); - const daysInServer = DateTime.local().startOf('day').diff(joined, 'days').toObject().days; + const joined = DateTime.fromMillis(member.joinedTimestamp ?? Date.now()).startOf('day'); + const daysInServer = DateTime.local().startOf('day').diff(joined, 'days').days; return member.roles.cache.find((r) => r.id === role.id) && daysInServer >= days; }); @@ -31,12 +32,12 @@ const execute = async (cmd: ChatInputCommandInteraction) => { response = `**Users in ${role.toString()} that have been in the server for at least ${ days ?? 0 } days.**\n------\n`; - members.each(async (mem) => { + for (const mem of members.values()) { if (mem.partial) { await mem.fetch(); } - response += `${mem.toString()} joined \n`; - }); + response += `${mem.toString()} joined \n`; + } } return cmd.reply({ diff --git a/src/slash-commands/Admin/simulate.ts b/src/slash-commands/Admin/simulate.ts index 69fdcaba..b9d93383 100644 --- a/src/slash-commands/Admin/simulate.ts +++ b/src/slash-commands/Admin/simulate.ts @@ -7,20 +7,32 @@ import { InteractionContextType, } from 'discord.js'; import { SlashCommand } from '../../types/SlashCommand'; +import { requireGuild } from '../../utilities/requireGuild'; -const testJoin = async (cmd: ChatInputCommandInteraction) => { +const findMember = (cmd: ChatInputCommandInteraction) => { + const members = requireGuild(cmd).members.cache; const user = cmd.options.getUser('member'); - const guildMember = - cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); + return (user ? members.get(user.id) : undefined) ?? members.get(cmd.user.id); +}; + +const testJoin = async (cmd: ChatInputCommandInteraction) => { + const guildMember = findMember(cmd); + if (!guildMember) { + return cmd.reply({ content: 'Could not find that member.', ephemeral: true }); + } cmd.client.emit('guildMemberAdd', guildMember); - cmd.reply({ content: `Emitted guildMemberAdd for ${guildMember.toString()}`, ephemeral: true }); + await cmd.reply({ + content: `Emitted guildMemberAdd for ${guildMember.toString()}`, + ephemeral: true, + }); }; const testScreen = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const guildMember = - cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); + const guildMember = findMember(cmd); + if (!guildMember) { + return cmd.reply({ content: 'Could not find that member.', ephemeral: true }); + } const oldMemberMock = Object.assign({}, guildMember, { pending: true, @@ -29,7 +41,7 @@ const testScreen = async (cmd: ChatInputCommandInteraction) => { cmd.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); - cmd.reply({ + await cmd.reply({ content: `Emitted guildMemberUpdate for ${guildMember.toString()}`, ephemeral: true, }); @@ -39,7 +51,7 @@ const execute = async (cmd: ChatInputCommandInteraction) => { const subCommand = cmd.options.getSubcommand(); if (!subCommand) { - cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); + return cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); } if (subCommand == 'join') { diff --git a/src/slash-commands/Config/Subcommands/announcements.ts b/src/slash-commands/Config/Subcommands/announcements.ts index c43bc69f..35f0e5ab 100644 --- a/src/slash-commands/Config/Subcommands/announcements.ts +++ b/src/slash-commands/Config/Subcommands/announcements.ts @@ -9,7 +9,7 @@ import { SlashSubGroupCommand } from '../../../types/SlashCommand'; import { ConfigManager } from '../../../utilities/ConfigManager'; const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.updateChannelNew(cmd, config, 'announcementsChannelId'); + await ConfigManager.updateChannelNew(cmd, config, 'announcementsChannelId'); return get(cmd, config); }; @@ -22,7 +22,7 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { }; const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.clearChannel(config, 'announcementsChannelId'); + await ConfigManager.clearChannel(config, 'announcementsChannelId'); return get(cmd, config); }; diff --git a/src/slash-commands/Config/Subcommands/audit.ts b/src/slash-commands/Config/Subcommands/audit.ts index 8e4762b4..719a0a04 100644 --- a/src/slash-commands/Config/Subcommands/audit.ts +++ b/src/slash-commands/Config/Subcommands/audit.ts @@ -9,7 +9,7 @@ import { SlashSubGroupCommand } from '../../../types/SlashCommand'; import { ConfigManager } from '../../../utilities/ConfigManager'; const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.updateChannelNew(cmd, config, 'auditChannelId'); + await ConfigManager.updateChannelNew(cmd, config, 'auditChannelId'); return get(cmd, config); }; @@ -22,7 +22,7 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { }; const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.clearChannel(config, 'auditChannelId'); + await ConfigManager.clearChannel(config, 'auditChannelId'); return get(cmd, config); }; diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index cd19ff98..d9b13d7b 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -7,10 +7,11 @@ import { import { ServerConfig } from '../../../client/models/ServerConfig'; import { SlashSubGroupCommand } from '../../../types/SlashCommand'; import { BirthdayManager } from '../../../utilities/BirthdayManager'; +import { requireGuild } from '../../../utilities/requireGuild'; const create = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const { id } = cmd.options.getChannel('channel'); - const channel = cmd.guild.channels.cache.get(id); + const { id } = cmd.options.getChannel('channel', true); + const channel = requireGuild(cmd).channels.cache.get(id); if (!channel || !channel.isTextBased()) { return cmd.reply('Provided channel is not a text channel'); @@ -21,9 +22,9 @@ const create = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => }); config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; await config.save(); - await BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); + await BirthdayManager.populateCalendars(cmd.client, requireGuild(cmd).id); - cmd.reply({ content: 'Birthday calendar has been created.', ephemeral: true }); + return cmd.reply({ content: 'Birthday calendar has been created.', ephemeral: true }); }; const sync = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { @@ -37,7 +38,7 @@ const sync = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { } return cmd.reply({ - content: `Calendar successfully synchronised for ${cmd.guild.name}.`, + content: `Calendar successfully synchronised for ${requireGuild(cmd).name}.`, ephemeral: true, }); }; diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/slash-commands/Config/Subcommands/debug.ts index b2fa66ff..c7a3e420 100644 --- a/src/slash-commands/Config/Subcommands/debug.ts +++ b/src/slash-commands/Config/Subcommands/debug.ts @@ -8,9 +8,9 @@ import { ServerConfig } from '../../../client/models/ServerConfig'; import { SlashSubGroupCommand } from '../../../types/SlashCommand'; const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled'); + const value = cmd.options.getBoolean('enabled', true); config.debug = value; - config.save(); + await config.save(); return get(cmd, config); }; diff --git a/src/slash-commands/Config/Subcommands/honeypot.ts b/src/slash-commands/Config/Subcommands/honeypot.ts index b337f713..3df8439b 100644 --- a/src/slash-commands/Config/Subcommands/honeypot.ts +++ b/src/slash-commands/Config/Subcommands/honeypot.ts @@ -7,10 +7,11 @@ import { } from 'discord.js'; import { ServerConfig } from '../../../client/models/ServerConfig'; import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { requireGuild } from '../../../utilities/requireGuild'; const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { const channel = config.honeyPotChannelId - ? cmd.guild.channels.cache.get(config.honeyPotChannelId) + ? requireGuild(cmd).channels.cache.get(config.honeyPotChannelId) : null; return cmd.reply({ diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index fcd1b16e..7532aab3 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -9,14 +9,16 @@ import { EmbedColours } from '../../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../../types/EmbedCompatLayer'; import { SlashSubGroupCommand } from '../../../types/SlashCommand'; import { ChannelService } from '../../../utilities/ChannelService'; +import { Logger } from '../../../utilities/Logger'; +import { requireGuild } from '../../../utilities/requireGuild'; const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const role1 = cmd.options.getRole('role-one'); + const role1 = cmd.options.getRole('role-one', true); const role2 = cmd.options.getRole('role-two'); const newGuestRoles: string = [role1.id, role2?.id].filter(Boolean).join(','); config.guestRoleIds = newGuestRoles; - config.save(); + await config.save(); return get(cmd, config); }; @@ -27,13 +29,14 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { } const roleIds: string[] = config.guestRoleIds.split(','); - const roles = cmd.guild.roles.cache.filter((r) => roleIds.includes(r.id)); - const member = cmd.guild.members.cache.get(cmd.user.id); + const guild = requireGuild(cmd); + const roles = guild.roles.cache.filter((r) => roleIds.includes(r.id)); + const member = guild.members.cache.get(cmd.user.id); const audit = new EmbedCompatLayer() .setColor(EmbedColours.info) .setAuthor({ - name: member.displayName, + name: member?.displayName ?? cmd.user.username, iconURL: cmd.user.displayAvatarURL(), }) .setDescription(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`) @@ -50,8 +53,10 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { ); } - ChannelService.sendAuditMessage(cmd.client, config, audit); - cmd.reply({ + await ChannelService.sendAuditMessage(cmd.client, config, audit).catch((err: unknown) => + Logger.writeError('Could not send new roles audit.', err) + ); + await cmd.reply({ content: `New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`, ephemeral: true, }); @@ -59,7 +64,7 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { config.guestRoleIds = null; - config.save(); + await config.save(); return get(cmd, config); }; diff --git a/src/slash-commands/Config/Subcommands/systemMsgs.ts b/src/slash-commands/Config/Subcommands/systemMsgs.ts index b67c1524..5b9d0539 100644 --- a/src/slash-commands/Config/Subcommands/systemMsgs.ts +++ b/src/slash-commands/Config/Subcommands/systemMsgs.ts @@ -8,9 +8,9 @@ import { ServerConfig } from '../../../client/models/ServerConfig'; import { SlashSubGroupCommand } from '../../../types/SlashCommand'; const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled'); + const value = cmd.options.getBoolean('enabled', true); config.systemMessagesEnabled = value; - config.save(); + await config.save(); return get(cmd, config); }; diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts index 302b9160..df4275ba 100644 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ b/src/slash-commands/Config/Subcommands/welcome.ts @@ -18,14 +18,14 @@ const msgChoice = { const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { console.log(cmd.options); - config[cmd.options.getString('content')] = cmd.options.getString('value'); - config.save(); + config[cmd.options.getString('content', true)] = cmd.options.getString('value', true); + await config.save(); return get(cmd, config); }; const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config[cmd.options.getString('content')]; + const value = config[cmd.options.getString('content', true)]; return cmd.reply({ content: `Current value is: ${value ? value : 'Not Set'}`, @@ -34,8 +34,8 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { }; const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config[cmd.options.getString('content')] = null; - config.save(); + config[cmd.options.getString('content', true)] = null; + await config.save(); return get(cmd, config); }; diff --git a/src/slash-commands/Config/config.ts b/src/slash-commands/Config/config.ts index 5af66f26..167cd42e 100644 --- a/src/slash-commands/Config/config.ts +++ b/src/slash-commands/Config/config.ts @@ -31,10 +31,10 @@ const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) = const subCmd = cmdMap[subCommand]; if (!subCommand) { - cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); + return cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); } - subCmd.execute(cmd, config); + await subCmd.execute(cmd, config); }; const commandData = new SlashCommandBuilder() diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index 437b05ac..c4aa88de 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -8,10 +8,11 @@ import { DateTime } from 'luxon'; import { SlashCommand } from '../../types/SlashCommand'; import { BirthdayManager } from '../../utilities/BirthdayManager'; import { UserProfileService } from '../../utilities/UserProfileService'; +import { requireGuild } from '../../utilities/requireGuild'; const execute = async (cmd: ChatInputCommandInteraction) => { - let day = cmd.options.getNumber('day'); - const month = cmd.options.getNumber('month'); + let day = cmd.options.getNumber('day', true); + const month = cmd.options.getNumber('month', true); let alteredForLeap = false; @@ -42,18 +43,19 @@ const execute = async (cmd: ChatInputCommandInteraction) => { }); } - const userProfile = await UserProfileService.getUserProfile(cmd.guild.id, cmd.user.id); + const guildId = requireGuild(cmd).id; + const userProfile = await UserProfileService.getUserProfile(guildId, cmd.user.id); userProfile.birthdayDay = day; userProfile.birthdayMonth = month; await userProfile.save(); - cmd.reply({ + await cmd.reply({ content: `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!`, ephemeral: true, }); - return BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); + return BirthdayManager.populateCalendars(cmd.client, guildId); }; const commandData = new SlashCommandBuilder() diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index 0a6c9a66..b7aa84b9 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -9,12 +9,17 @@ import { EmbedColours } from '../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; import { SlashCommand } from '../../types/SlashCommand'; import { UserProfileService } from '../../utilities/UserProfileService'; +import { requireGuild } from '../../utilities/requireGuild'; const execute = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const member = cmd.guild.members.cache.get(user.id); + const user = cmd.options.getUser('member', true); + const guild = requireGuild(cmd); + const member = guild.members.cache.get(user.id); + if (!member) { + return cmd.reply({ content: 'Could not find that member in this server.', ephemeral: true }); + } - const userProfile = await UserProfileService.getUserProfile(cmd.guild.id, member.id); + const userProfile = await UserProfileService.getUserProfile(guild.id, member.id); const embed = new EmbedCompatLayer() .setThumbnail(member.displayAvatarURL()) @@ -24,7 +29,7 @@ const execute = async (cmd: ChatInputCommandInteraction) => { .addField('Member', member.toString()) .addField('Nickname', member.nickname ? member.nickname : 'Not set') .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) - .addField('Joined', ``) + .addField('Joined', ``) .addField('Screening', member.pending ? 'Not completed' : 'Passed') .addField('Activity Score', userProfile ? userProfile.activityScore.toString() : 'Not found') .addField('ID', member.user.id); diff --git a/src/utilities/BirthdayManager.ts b/src/utilities/BirthdayManager.ts index 4d0931ee..6539d2d4 100644 --- a/src/utilities/BirthdayManager.ts +++ b/src/utilities/BirthdayManager.ts @@ -12,7 +12,7 @@ export class BirthdayManager { public static async populateCalendars(client: Client, serverId?: string): Promise { Logger.writeLog('Running birthday calendar update job.'); - let parsedConfigs: ServerConfig[]; + let parsedConfigs: ServerConfig[] = []; if (serverId) { const serverConfig = await ConfigService.getConfig(serverId).catch((err) => { @@ -38,7 +38,7 @@ export class BirthdayManager { for (const config of parsedConfigs) { const profiles = await UserProfileService.getServerBirthdays(config.serverId); - const [channelId, messageId] = config.birthdayCalendarMessagePath.split('/'); + const [channelId, messageId] = config.birthdayCalendarMessagePath!.split('/'); let messageContent = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; const chanToEdit = await client.channels.fetch(channelId); @@ -60,12 +60,16 @@ export class BirthdayManager { const now = DateTime.local(); - if (!now.isInLeapYear && u.birthdayDay === 29 && u.birthdayMonth === 2) { - u.birthdayDay--; + // getServerBirthdays only returns profiles with a month and day set + const month = u.birthdayMonth!; + let day = u.birthdayDay!; + + if (!now.isInLeapYear && day === 29 && month === 2) { + day--; alteredForLeap = true; } - let nextDate = DateTime.local(now.year, u.birthdayMonth, u.birthdayDay); + let nextDate = DateTime.local(now.year, month, day); if (nextDate <= now) { nextDate = nextDate.plus({ @@ -120,7 +124,7 @@ export class BirthdayManager { config: ServerConfig ) { if (config && config.announcementsChannelId) { - const server: Guild = client.guilds.cache.get(config.serverId); + const server: Guild | undefined = client.guilds.cache.get(config.serverId); if (!server) { return Logger.writeError(`Server missing with id ${config.serverId}`); } @@ -161,7 +165,11 @@ export class BirthdayManager { for (const server in usersWithBirthdaysByServer) { const config = await ConfigService.getConfig(server); - this.notifyServerBirthdays(client, usersWithBirthdaysByServer[server], config); + try { + await this.notifyServerBirthdays(client, usersWithBirthdaysByServer[server], config); + } catch (err) { + Logger.writeError(`Error sending birthday notifications for server ${server}.`, err); + } } Logger.writeLog('Finished birthday notifications job.'); diff --git a/src/utilities/ChannelService.ts b/src/utilities/ChannelService.ts index bda634e7..e25fdfd9 100644 --- a/src/utilities/ChannelService.ts +++ b/src/utilities/ChannelService.ts @@ -7,7 +7,7 @@ export class ChannelService { serverConfig: ServerConfig, embed: EmbedBuilder, attachment?: Attachment - ): Promise { + ): Promise { if (!serverConfig.auditChannelId) { return; } diff --git a/src/utilities/CommandRegistrar.ts b/src/utilities/CommandRegistrar.ts index 38a65617..c84e2c0d 100644 --- a/src/utilities/CommandRegistrar.ts +++ b/src/utilities/CommandRegistrar.ts @@ -1,3 +1,4 @@ +import { env } from '../config/env'; import { REST, Routes } from 'discord.js'; import { NovaClient } from '../client/NovaClient'; import { Logger } from './Logger'; @@ -7,10 +8,10 @@ export class CommandRegistrar { Logger.writeLog('Registering Global Commands...'); const cmdData = client.slashCommands.map((v) => v.commandData.toJSON()); - const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); + const rest = new REST({ version: '10' }).setToken(env.token); rest - .put(Routes.applicationCommands(process.env.CLIENT_ID), { body: cmdData }) + .put(Routes.applicationCommands(env.clientId), { body: cmdData }) .then((data: any) => Logger.writeLog(`Successfully registered ${data.length} application commands.`) ) diff --git a/src/utilities/ConfigManager.ts b/src/utilities/ConfigManager.ts index 256d77aa..cedabe37 100644 --- a/src/utilities/ConfigManager.ts +++ b/src/utilities/ConfigManager.ts @@ -9,7 +9,7 @@ export class ConfigManager { ): Promise { const chan = interaction.options.getChannel('channel'); - let messageContent: string; + let messageContent: string | undefined; if (!chan && serverConfig[field]) { serverConfig[field] = null; } else if (!chan && !serverConfig[field]) { @@ -35,7 +35,7 @@ export class ConfigManager { if (serverConfig[field]) { return interaction.reply({ - content: `Channel updated to ${chan.toString()}.`, + content: `Channel updated to ${chan?.toString()}.`, ephemeral: true, }); } else { @@ -52,7 +52,7 @@ export class ConfigManager { field: string ) { if (serverConfig[field]) { - return interaction.guild.channels.cache.get(serverConfig[field]); + return interaction.guild?.channels.cache.get(serverConfig[field]); } return null; @@ -68,13 +68,13 @@ export class ConfigManager { return interaction.reply('Provided channel is not valid'); } serverConfig[field] = chan.id; - serverConfig.save(); + await serverConfig.save(); return chan; } public static async clearChannel(serverConfig: ServerConfig, field: string) { serverConfig[field] = null; - serverConfig.save(); + await serverConfig.save(); } } diff --git a/src/utilities/ConfigService.ts b/src/utilities/ConfigService.ts index be8f1976..14491da6 100644 --- a/src/utilities/ConfigService.ts +++ b/src/utilities/ConfigService.ts @@ -5,6 +5,10 @@ export class ConfigService { public static async getConfigByMessage( message: Message | ChatInputCommandInteraction ): Promise { + if (!message.guild) { + return undefined; + } + const [config] = await ServerConfig.findOrCreate({ where: { serverId: message.guild.id, diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index 0e5d65c5..2da81ca2 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -1,14 +1,70 @@ -import { DateTime } from 'luxon'; +type Context = Record; +type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; + +const timestamp = (): string => new Date().toISOString(); + +const formatError = (error: unknown): string => + error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error); + +const formatValue = (value: unknown): string => { + if (value instanceof Error) { + return value.message; + } + if (typeof value === 'string') { + return /[\s="]/.test(value) || value === '' ? JSON.stringify(value) : value; + } + if (typeof value === 'object' && value !== null) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +}; + +export const formatContext = (context?: Context): string => + context + ? Object.entries(context) + .map(([key, value]) => `${key}=${formatValue(value)}`) + .join(' ') + : ''; export class Logger { public static writeLog(log: string): void { - console.log(`[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}`); + console.log(`[${timestamp()}]: ${log}`); + } + + public static writeError(log: string, error?: unknown): void { + console.error(`[${timestamp()}]: ${log}`); + if (error !== undefined && error !== null && error !== '') { + console.error(`[${timestamp()}]: ${formatError(error)}`); + } + } + + public static debug(message: string, context?: Context, error?: unknown): void { + Logger.emit('DEBUG', message, context, error); + } + + public static info(message: string, context?: Context, error?: unknown): void { + Logger.emit('INFO', message, context, error); + } + + public static warn(message: string, context?: Context, error?: unknown): void { + Logger.emit('WARN', message, context, error); + } + + public static error(message: string, context?: Context, error?: unknown): void { + Logger.emit('ERROR', message, context, error); } - public static writeError(log: string, error?: string): void { - console.error(`[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}`); - if (error) { - console.error(`[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${error}`); + private static emit(level: Level, message: string, context?: Context, error?: unknown): void { + const ctx = formatContext(context); + const line = `[${timestamp()}] ${level}: ${message}${ctx ? ` ${ctx}` : ''}`; + const write = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log; + write(line); + if (error !== undefined && error !== null) { + write(`[${timestamp()}] ${level}: ${formatError(error)}`); } } } diff --git a/src/utilities/requireGuild.ts b/src/utilities/requireGuild.ts new file mode 100644 index 00000000..71357941 --- /dev/null +++ b/src/utilities/requireGuild.ts @@ -0,0 +1,8 @@ +import { ChatInputCommandInteraction, Guild } from 'discord.js'; + +export const requireGuild = (cmd: ChatInputCommandInteraction): Guild => { + if (!cmd.guild) { + throw new Error(`Command ${cmd.commandName} was used outside of a guild.`); + } + return cmd.guild; +}; diff --git a/tests/database.test.ts b/tests/database.test.ts new file mode 100644 index 00000000..7a6f5ce4 --- /dev/null +++ b/tests/database.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createSequelize } from '../src/client/database/createSequelize'; +import { ServerConfig } from '../src/client/models/ServerConfig'; +import { UserProfile } from '../src/client/models/UserProfile'; + +test('createSequelize(":memory:") syncs both models', async () => { + const db = createSequelize(':memory:'); + try { + await db.sync(); + + const [config] = await ServerConfig.findOrCreate({ where: { serverId: 's1' } }); + await config.reload(); + assert.equal(config.systemMessagesEnabled, false); + assert.equal(config.auditChannelId, null); + + const profile = await UserProfile.create({ serverId: 's1', userId: 'u1' }); + assert.equal(profile.activityScore, 0); + + assert.equal(await ServerConfig.count(), 1); + assert.equal(await UserProfile.count(), 1); + } finally { + await db.close(); + } +}); diff --git a/tests/env.test.ts b/tests/env.test.ts new file mode 100644 index 00000000..ca9f2d1f --- /dev/null +++ b/tests/env.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { loadEnv } from '../src/config/env'; + +const valid = { TOKEN: 'abc', CLIENT_ID: '123' }; + +test('applies defaults when only required variables are set', () => { + const env = loadEnv(valid); + assert.equal(env.token, 'abc'); + assert.equal(env.clientId, '123'); + assert.equal(env.jobSchedule, '0 9 * * *'); + assert.equal(env.timezone, 'Europe/London'); + assert.equal(env.dbPath, 'data/dingir.sqlite'); + assert.equal(env.purgeMinFailures, 3); + assert.equal(env.purgeGraceDays, 7); + assert.equal(env.devGuildId, undefined); +}); + +test('returns a frozen object', () => { + assert.ok(Object.isFrozen(loadEnv(valid))); +}); + +test('parses overrides', () => { + const env = loadEnv({ + ...valid, + JOB_SCHEDULE: '*/5 * * * * *', + BOT_TIMEZONE: 'America/New_York', + DB_PATH: ':memory:', + PURGE_MIN_FAILURES: '5', + PURGE_GRACE_DAYS: '0', + DEV_GUILD_ID: '999', + }); + assert.equal(env.jobSchedule, '*/5 * * * * *'); + assert.equal(env.timezone, 'America/New_York'); + assert.equal(env.dbPath, ':memory:'); + assert.equal(env.purgeMinFailures, 5); + assert.equal(env.purgeGraceDays, 0); + assert.equal(env.devGuildId, '999'); +}); + +test('treats blank values as unset', () => { + const env = loadEnv({ ...valid, JOB_SCHEDULE: ' ', DEV_GUILD_ID: '' }); + assert.equal(env.jobSchedule, '0 9 * * *'); + assert.equal(env.devGuildId, undefined); +}); + +test('reports every problem in a single error', () => { + assert.throws( + () => + loadEnv({ + JOB_SCHEDULE: 'every day', + BOT_TIMEZONE: 'Mars/Olympus', + PURGE_MIN_FAILURES: '0', + PURGE_GRACE_DAYS: '-1', + }), + (err: Error) => { + for (const expected of [ + 'TOKEN', + 'CLIENT_ID', + 'JOB_SCHEDULE', + 'BOT_TIMEZONE', + 'PURGE_MIN_FAILURES', + 'PURGE_GRACE_DAYS', + ]) { + assert.match(err.message, new RegExp(expected)); + } + return true; + } + ); +}); + +test('rejects malformed cron strings', () => { + for (const schedule of ['* * * *', '* * * * * * *', '0 9 * * ;']) { + assert.throws(() => loadEnv({ ...valid, JOB_SCHEDULE: schedule }), /JOB_SCHEDULE/); + } +}); + +test('rejects non-integer purge settings', () => { + assert.throws(() => loadEnv({ ...valid, PURGE_MIN_FAILURES: '2.5' }), /PURGE_MIN_FAILURES/); + assert.throws(() => loadEnv({ ...valid, PURGE_GRACE_DAYS: 'abc' }), /PURGE_GRACE_DAYS/); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 00000000..5e7503f2 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; +import { Logger, formatContext } from '../src/utilities/Logger'; + +type Method = 'log' | 'warn' | 'error'; +const original = { log: console.log, warn: console.warn, error: console.error }; +let lines: Record; + +beforeEach(() => { + lines = { log: [], warn: [], error: [] }; + for (const method of Object.keys(lines) as Method[]) { + console[method] = (msg: unknown) => { + lines[method].push(String(msg)); + }; + } +}); + +afterEach(() => { + Object.assign(console, original); +}); + +test('writeError prints the stack for Error instances', () => { + const err = new Error('boom'); + Logger.writeError('failed', err); + assert.equal(lines.error.length, 2); + assert.match(lines.error[0], /^\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]: failed$/); + assert.ok(lines.error[1].includes(err.stack as string)); +}); + +test('writeError stringifies non-Error values and skips missing ones', () => { + Logger.writeError('failed', 'plain text'); + Logger.writeError('failed', 42); + Logger.writeError('failed'); + assert.equal(lines.error.length, 5); + assert.ok(lines.error[1].endsWith(': plain text')); + assert.ok(lines.error[3].endsWith(': 42')); +}); + +test('formatContext renders key=value pairs', () => { + assert.equal( + formatContext({ guild: '123', name: 'a b', count: 3, ok: true, none: null }), + 'guild=123 name="a b" count=3 ok=true none=null' + ); + assert.equal(formatContext(), ''); +}); + +test('level helpers include level, message, context and error', () => { + Logger.info('hello', { guild: '1' }); + Logger.warn('careful'); + Logger.error('bad', { event: 'x' }, new Error('nope')); + Logger.debug('detail'); + + assert.match(lines.log[0], /INFO: hello guild=1$/); + assert.match(lines.warn[0], /WARN: careful$/); + assert.match(lines.error[0], /ERROR: bad event=x$/); + assert.match(lines.error[1], /ERROR: Error: nope/); + assert.match(lines.log[1], /DEBUG: detail$/); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 00000000..8f5f96ce --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.test.json", + "compilerOptions": { + "rootDir": ".." + }, + "include": ["../src", "."] +} diff --git a/tsconfig.json b/tsconfig.json index 289b022a..5d675952 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "target": "ES2024", "strict": false, + "strictNullChecks": true, "outDir": "dist", "sourceMap": true, "esModuleInterop": true, diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..06c8e029 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "tests"] +} From 908ffafb4b782197e3886b88a1c435d2849bbf6d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:38:14 +0100 Subject: [PATCH 03/77] Add command/event framework, loader and dispatcher; port commands and events Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 7 +- package.json | 1 - src/client/NovaClient.ts | 76 +++--- src/events/guildCreate.ts | 42 ++-- src/events/guildDelete.ts | 22 +- src/events/guildMemberAdd.ts | 34 +-- src/events/guildMemberRemove.ts | 52 ++-- src/events/guildMemberUpdate.ts | 122 +++++----- src/events/interactionCreate.ts | 110 +++++---- src/events/messageCreate.ts | 114 ++++----- src/events/messageDelete.ts | 64 ++--- src/events/messageUpdate.ts | 64 +++-- src/events/ready.ts | 42 ++-- src/framework/command.ts | 224 ++++++++++++++++++ src/framework/event.ts | 42 ++++ src/framework/loader.ts | 87 +++++++ src/framework/settings.ts | 126 ++++++++++ src/framework/shutdown.ts | 20 ++ src/slash-commands/Admin/noroles.ts | 67 ++---- src/slash-commands/Admin/post.ts | 120 ++++------ src/slash-commands/Admin/rolesince.ts | 109 ++++----- src/slash-commands/Admin/simulate.ts | 147 +++++------- .../Config/Subcommands/announcements.ts | 69 ------ .../Config/Subcommands/audit.ts | 69 ------ .../Config/Subcommands/birthdays.ts | 131 ++++------ .../Config/Subcommands/debug.ts | 90 +++---- .../Config/Subcommands/honeypot.ts | 94 -------- .../Config/Subcommands/newroles.ts | 127 ++++------ .../Config/Subcommands/systemMsgs.ts | 64 ----- .../Config/Subcommands/welcome.ts | 162 ++++++------- src/slash-commands/Config/config.ts | 102 ++++---- src/slash-commands/Info/about.ts | 58 ++--- src/slash-commands/Info/mybirthday.ts | 126 +++++----- src/slash-commands/Info/ping.ts | 34 +-- src/slash-commands/Info/profile.ts | 82 +++---- src/types/Command.ts | 17 -- src/types/Event.ts | 8 - src/types/SlashCommand.ts | 25 -- src/utilities/CommandRegistrar.ts | 24 +- src/utilities/ConfigManager.ts | 80 ------- src/utilities/requireGuild.ts | 8 - 41 files changed, 1448 insertions(+), 1614 deletions(-) create mode 100644 src/framework/command.ts create mode 100644 src/framework/event.ts create mode 100644 src/framework/loader.ts create mode 100644 src/framework/settings.ts create mode 100644 src/framework/shutdown.ts delete mode 100644 src/slash-commands/Config/Subcommands/announcements.ts delete mode 100644 src/slash-commands/Config/Subcommands/audit.ts delete mode 100644 src/slash-commands/Config/Subcommands/honeypot.ts delete mode 100644 src/slash-commands/Config/Subcommands/systemMsgs.ts delete mode 100644 src/types/Command.ts delete mode 100644 src/types/Event.ts delete mode 100644 src/types/SlashCommand.ts delete mode 100644 src/utilities/ConfigManager.ts delete mode 100644 src/utilities/requireGuild.ts diff --git a/package-lock.json b/package-lock.json index f004f70c..61d3a7b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@napi-rs/canvas": "^1.0.2", "discord.js": "^14.26.5", "dotenv": "^17.4.2", - "glob": "^13.0.6", "luxon": "^3.7.2", "node-schedule": "^2.1.1", "reflect-metadata": "^0.2.2", @@ -1914,6 +1913,7 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -1944,6 +1944,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -1953,6 +1954,7 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1965,6 +1967,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -2185,6 +2188,7 @@ "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -2543,6 +2547,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", diff --git a/package.json b/package.json index 0ced080e..d5739359 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "@napi-rs/canvas": "^1.0.2", "discord.js": "^14.26.5", "dotenv": "^17.4.2", - "glob": "^13.0.6", "luxon": "^3.7.2", "node-schedule": "^2.1.1", "reflect-metadata": "^0.2.2", diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts index 79d94fdb..fae28240 100644 --- a/src/client/NovaClient.ts +++ b/src/client/NovaClient.ts @@ -1,24 +1,24 @@ +import path from 'node:path'; import { env } from '../config/env'; -import { glob } from 'glob'; import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; -import { Event } from '../types/Event'; +import { Command } from '../framework/command'; +import { bindEvent } from '../framework/event'; +import { loadCommands, loadEvents } from '../framework/loader'; +import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown'; import { Logger } from '../utilities/Logger'; import { sequelize } from './database/sequelize'; -import { SlashCommand } from '../types/SlashCommand'; class NovaClient extends Client { - public events: Collection = new Collection(); - public slashCommands: Collection = new Collection(); + public slashCommands: Collection = new Collection(); + private shuttingDown = false; public constructor() { super({ - partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.GuildMember], + partials: [Partials.Message, Partials.Channel, Partials.GuildMember], intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent, ], }); @@ -26,36 +26,48 @@ class NovaClient extends Client { public async start(): Promise { await sequelize.sync({ alter: true }); + registerShutdownHook(() => sequelize.close()); - const eventFiles: string[] = await glob(`${__dirname}/../events/**/*{.js,.ts}`); - const slashCommandFiles: string[] = await glob(`${__dirname}/../slash-commands/*/*{.js,.ts}`); - - for (const eventFile of eventFiles) { - const importedEvent = await import(eventFile); - const event = (importedEvent.default ?? importedEvent) as Event; - this.events.set(event.name, event); - this.on(event.name, (...args: any[]) => { - event.run(this, ...args).catch((err: unknown) => { - Logger.writeError(`Unhandled error in ${event.name} event handler.`, err); - }); - }); - } + const [commands, events] = await Promise.all([ + loadCommands(path.join(__dirname, '..', 'slash-commands')), + loadEvents(path.join(__dirname, '..', 'events')), + ]); - for (const slashCommandFile of slashCommandFiles) { - const importedCommand = await import(slashCommandFile); - const cmd = (importedCommand.default ?? importedCommand) as SlashCommand; - this.slashCommands.set(cmd.commandData.name, cmd); + for (const command of commands) { + this.slashCommands.set(command.name, command); + } + for (const event of events) { + bindEvent(this, event); } - process.on('SIGTERM', () => { - Logger.writeLog('SIGTERM Received, destroying client & shutting down.'); - this.destroy() - .catch((err: unknown) => Logger.writeError('Error destroying client.', err)) - .finally(() => process.exit()); - }); + this.on('error', (error) => Logger.error('Discord client error', undefined, error)); + this.on('warn', (message) => Logger.warn(message)); + process.on('unhandledRejection', (reason) => + Logger.error('Unhandled promise rejection', undefined, reason) + ); + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, () => { + void this.shutdown(signal); + }); + } await this.login(env.token); - Logger.writeLog('Logged in'); + Logger.info('Logged in'); + } + + private async shutdown(signal: string): Promise { + if (this.shuttingDown) { + return; + } + this.shuttingDown = true; + Logger.info(`${signal} received, shutting down`); + try { + await this.destroy(); + } catch (error) { + Logger.error('Error destroying client', undefined, error); + } + await runShutdownHooks(); + process.exit(0); } } diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 36bd6d2e..924ce5f7 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,24 +1,24 @@ -import { Guild } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { ConfigService } from '../utilities/ConfigService'; import { Logger } from '../utilities/Logger'; -export const name = 'guildCreate'; -export const run: RunFunction = async (client: NovaClient, guild: Guild) => { - ConfigService.getConfig(guild.id) - .then((config) => { - if (config) { - return Logger.writeLog(`Bot added to new guild: ${guild.name} (${guild.id}).`); - } - return Logger.writeError( - `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).` - ); - }) - .catch((err) => { - return Logger.writeError( - `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).`, - err - ); - }); -}; +export default defineEvent({ + name: 'guildCreate', + run: async (client, guild) => { + ConfigService.getConfig(guild.id) + .then((config) => { + if (config) { + return Logger.writeLog(`Bot added to new guild: ${guild.name} (${guild.id}).`); + } + return Logger.writeError( + `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).` + ); + }) + .catch((err) => { + return Logger.writeError( + `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).`, + err + ); + }); + }, +}); diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 6396a6d4..9f49ea33 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,16 +1,16 @@ -import { Guild } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { ConfigService } from '../utilities/ConfigService'; import { Logger } from '../utilities/Logger'; import { UserProfileService } from '../utilities/UserProfileService'; -export const name = 'guildDelete'; -export const run: RunFunction = async (client: NovaClient, guild: Guild) => { - const configDeleted = await ConfigService.deleteConfig(guild.id); - const userProfilesDeleted = await UserProfileService.deleteUsersByServer(guild.id); +export default defineEvent({ + name: 'guildDelete', + run: async (client, guild) => { + const configDeleted = await ConfigService.deleteConfig(guild.id); + const userProfilesDeleted = await UserProfileService.deleteUsersByServer(guild.id); - Logger.writeLog(`Bot removed from guild: ${guild.name} (${guild.id}).`); - Logger.writeLog(`Config deleted: ${configDeleted}.`); - Logger.writeLog(`User profiles deleted: ${userProfilesDeleted}.`); -}; + Logger.writeLog(`Bot removed from guild: ${guild.name} (${guild.id}).`); + Logger.writeLog(`Config deleted: ${configDeleted}.`); + Logger.writeLog(`User profiles deleted: ${userProfilesDeleted}.`); + }, +}); diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 0db6f99b..74711f5b 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,24 +1,24 @@ -import { GuildMember } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { ChannelService } from '../utilities/ChannelService'; import { ConfigService } from '../utilities/ConfigService'; import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -export const name = 'guildMemberAdd'; -export const run: RunFunction = async (client: NovaClient, member: GuildMember) => { - const serverConfig = await ConfigService.getConfig(member.guild.id); +export default defineEvent({ + name: 'guildMemberAdd', + run: async (client, member) => { + const serverConfig = await ConfigService.getConfig(member.guild.id); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setDescription('New member joined') - .addField('ID', member.user.id) - .setTimestamp(); + const audit = new EmbedCompatLayer() + .setColor(EmbedColours.positive) + .setAuthor({ + name: member.displayName, + iconURL: member.displayAvatarURL(), + }) + .setDescription('New member joined') + .addField('ID', member.user.id) + .setTimestamp(); - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + await ChannelService.sendAuditMessage(client, serverConfig, audit); + }, +}); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 8aedbf25..64d3ee2e 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,37 +1,37 @@ -import { GuildMember } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { ChannelService } from '../utilities/ChannelService'; import { ConfigService } from '../utilities/ConfigService'; import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; import { UserProfileService } from '../utilities/UserProfileService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; -export const name = 'guildMemberRemove'; -export const run: RunFunction = async (client: NovaClient, member: GuildMember) => { - if (member.user.bot) { - return; - } +export default defineEvent({ + name: 'guildMemberRemove', + run: async (client, member) => { + if (member.user.bot) { + return; + } - if (HoneyPotEnforcementService.isActive(member.guild.id, member.user.id)) { - await UserProfileService.deleteUser(member.guild.id, member.user.id); - return; - } + if (HoneyPotEnforcementService.isActive(member.guild.id, member.user.id)) { + await UserProfileService.deleteUser(member.guild.id, member.user.id); + return; + } - const dataDeleted = await UserProfileService.deleteUser(member.guild.id, member.user.id); + const dataDeleted = await UserProfileService.deleteUser(member.guild.id, member.user.id); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setDescription('Member left') - .addField('ID', member.user.id) - .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data') - .setTimestamp(); + const audit = new EmbedCompatLayer() + .setColor(EmbedColours.negative) + .setAuthor({ + name: member.displayName, + iconURL: member.displayAvatarURL(), + }) + .setDescription('Member left') + .addField('ID', member.user.id) + .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data') + .setTimestamp(); - const serverConfig = await ConfigService.getConfig(member.guild.id); - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + const serverConfig = await ConfigService.getConfig(member.guild.id); + await ChannelService.sendAuditMessage(client, serverConfig, audit); + }, +}); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index bc95d93b..19846b67 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,6 +1,6 @@ import { GuildMember, AttachmentBuilder } from 'discord.js'; import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { ChannelService } from '../utilities/ChannelService'; import { ConfigService } from '../utilities/ConfigService'; @@ -135,71 +135,69 @@ const sendScreenAudit = async ( } }; -export const name = 'guildMemberUpdate'; -export const run: RunFunction = async ( - client: NovaClient, - oldMember: GuildMember, - newMember: GuildMember -) => { - if (newMember.partial) { - await newMember.fetch(); - } - - const serverConfig = await ConfigService.getConfig(newMember.guild.id); - const notPassedScreen = - oldMember.pending || (oldMember.pending === null && newMember.roles.cache.size === 1); - - if (notPassedScreen && !newMember.pending) { - await sendScreenAudit(serverConfig, newMember, client); - - if (serverConfig.guestRoleIds) { - await addGuestRoles(serverConfig, newMember, client).catch((err: unknown) => - Logger.writeError( - `Guest role audit failed in guildMemberUpdate for ${serverConfig.id}.`, - err - ) - ); +export default defineEvent({ + name: 'guildMemberUpdate', + run: async (client, oldMember, newMember) => { + if (newMember.partial) { + await newMember.fetch(); } - if ( - serverConfig.systemMessagesEnabled && - (serverConfig.welcomeMessage || serverConfig.welcomeMessageBackgroundUrl) - ) { - try { - let attachment: AttachmentBuilder | undefined; - let content: string | undefined; - if (serverConfig.welcomeMessageBackgroundUrl) { - attachment = await getWelcomeImage(serverConfig, newMember); - } + const serverConfig = await ConfigService.getConfig(newMember.guild.id); + const notPassedScreen = + oldMember.pending || (oldMember.pending === null && newMember.roles.cache.size === 1); - if (serverConfig.welcomeMessage) { - content = await getWelcomeMessage(serverConfig, newMember); - } + if (notPassedScreen && !newMember.pending) { + await sendScreenAudit(serverConfig, newMember, client); - const systemChannel = newMember.guild.systemChannel; - if (!systemChannel) { - throw new Error(`No system channel for guild ${newMember.guild.id}.`); - } - await systemChannel.send({ - content, - files: attachment ? [attachment] : undefined, - }); - } catch (e) { - Logger.writeError( - `Sending welcome message failed in guildMemberUpdate for server: ${serverConfig.serverId}.`, - e + if (serverConfig.guestRoleIds) { + await addGuestRoles(serverConfig, newMember, client).catch((err: unknown) => + Logger.writeError( + `Guest role audit failed in guildMemberUpdate for ${serverConfig.id}.`, + err + ) ); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Unable to send welcome message.') - .addField('ID', newMember.user.id) - .setTimestamp(); - return ChannelService.sendAuditMessage(client, serverConfig, audit); + } + + if ( + serverConfig.systemMessagesEnabled && + (serverConfig.welcomeMessage || serverConfig.welcomeMessageBackgroundUrl) + ) { + try { + let attachment: AttachmentBuilder | undefined; + let content: string | undefined; + if (serverConfig.welcomeMessageBackgroundUrl) { + attachment = await getWelcomeImage(serverConfig, newMember); + } + + if (serverConfig.welcomeMessage) { + content = await getWelcomeMessage(serverConfig, newMember); + } + + const systemChannel = newMember.guild.systemChannel; + if (!systemChannel) { + throw new Error(`No system channel for guild ${newMember.guild.id}.`); + } + await systemChannel.send({ + content, + files: attachment ? [attachment] : undefined, + }); + } catch (e) { + Logger.writeError( + `Sending welcome message failed in guildMemberUpdate for server: ${serverConfig.serverId}.`, + e + ); + const audit = new EmbedCompatLayer() + .setColor(EmbedColours.negative) + .setAuthor({ + name: newMember.displayName, + iconURL: newMember.displayAvatarURL(), + }) + .setDescription('Unable to send welcome message.') + .addField('ID', newMember.user.id) + .setTimestamp(); + return ChannelService.sendAuditMessage(client, serverConfig, audit); + } } } - } -}; + }, +}); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 6c521ce4..6c25b6c5 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,59 +1,69 @@ -import { ChatInputCommandInteraction, Interaction } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; +import { MessageFlags } from 'discord.js'; +import { CommandContext, createReply } from '../framework/command'; +import { defineEvent } from '../framework/event'; import { ConfigService } from '../utilities/ConfigService'; import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; -import { ServerConfig } from '../client/models/ServerConfig'; -export const name = 'interactionCreate'; +export default defineEvent({ + name: 'interactionCreate', + run: async (client, interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } -const runCommand = async ( - client: NovaClient, - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { - const slashCmd = client.slashCommands.get(cmd.commandName); + if (!interaction.inCachedGuild()) { + await interaction.reply({ + content: 'Dingir only works in servers.', + flags: MessageFlags.Ephemeral, + }); + return; + } - if (!slashCmd) { - return; - } + const command = client.slashCommands.get(interaction.commandName); + const resolved = command?.resolve(interaction); + if (!resolved) { + Logger.warn('Unknown command', { + command: interaction.commandName, + subcommand: interaction.options.getSubcommand(false), + guild: interaction.guildId, + user: interaction.user.id, + }); + await interaction.reply({ content: 'Unknown command.', flags: MessageFlags.Ephemeral }); + return; + } - await slashCmd.execute(cmd, config).catch(async (err: unknown) => { + const reply = createReply(interaction); try { - if (cmd.deferred && !cmd.replied) { - await cmd.editReply({ - content: 'Something went wrong, was this command run in the correct place?', - }); - } else if (!cmd.replied) { - await cmd.reply({ - content: 'Something went wrong, was this command run in the correct place?', - ephemeral: true, - }); + const config = await ConfigService.getConfig(interaction.guildId); + if (resolved.defer) { + await interaction.deferReply( + resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {} + ); + } + const ctx: CommandContext = { + interaction, + guild: interaction.guild, + member: interaction.member, + config, + reply, + }; + await resolved.run(ctx); + } catch (error) { + Logger.error( + 'Command failed', + { + command: interaction.commandName, + path: resolved.path, + guild: interaction.guildId, + user: interaction.user.id, + }, + error + ); + try { + await reply('Something went wrong running this command.'); + } catch (replyError) { + Logger.error('Could not send command error reply', { path: resolved.path }, replyError); } - } catch (replyErr) { - Logger.writeError('Failed to send command error reply.', replyErr); } - Logger.writeError(`Command ${cmd.commandName} failed.`, err); - }); -}; - -export const run: RunFunction = async (client: NovaClient, interaction: Interaction) => { - if (!interaction.isChatInputCommand()) { - return; - } - - if (!interaction.guild) { - return interaction.reply({ - content: 'Dingir only supports interactions in Discord Servers.', - ephemeral: true, - }); - } - - const serverConfig = await ConfigService.getConfigByMessage(interaction); - await UserProfileService.incrementActivityScore(interaction.guild.id, interaction.user.id); - - if (serverConfig) { - await runCommand(client, interaction, serverConfig); - } -}; + }, +}); diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index c778dc98..78c565bf 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,7 +1,7 @@ import { Message, PermissionFlagsBits } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; +import { NovaClient } from '../client/NovaClient'; +import { defineEvent } from '../framework/event'; import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; import { ChannelService } from '../utilities/ChannelService'; import { ConfigService } from '../utilities/ConfigService'; @@ -37,64 +37,66 @@ const sendAudit = async ( await ChannelService.sendAuditMessage(client, config, audit); }; -export const name = 'messageCreate'; -export const run: RunFunction = async (client: NovaClient, message: Message) => { - if (!message.inGuild() || message.author.bot) { - return; - } +export default defineEvent({ + name: 'messageCreate', + run: async (client, message) => { + if (!message.inGuild() || message.author.bot) { + return; + } - const config = await ConfigService.getConfig(message.guild.id); - if (config.honeyPotChannelId !== message.channelId) { - return; - } + const config = await ConfigService.getConfig(message.guild.id); + if (config.honeyPotChannelId !== message.channelId) { + return; + } - const member = - message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); - if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { - return; - } + const member = + message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); + if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { + return; + } - if (!HoneyPotEnforcementService.begin(message.guild.id, member.id)) { - return; - } + if (!HoneyPotEnforcementService.begin(message.guild.id, member.id)) { + return; + } - if (!member.bannable) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); - const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, error); - await sendAudit(client, message, 'Honey-pot ban failed', error).catch((auditError) => { - Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); - }); - return; - } + if (!member.bannable) { + HoneyPotEnforcementService.cancel(message.guild.id, member.id); + const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; + Logger.writeError(`Honey-pot ban failed for ${member.id}.`, error); + await sendAudit(client, message, 'Honey-pot ban failed', error).catch((auditError) => { + Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); + }); + return; + } - try { - await member.ban({ - deleteMessageSeconds: DELETE_MESSAGE_SECONDS, - reason: `Posted in honey-pot channel ${message.channelId}`, + try { + await member.ban({ + deleteMessageSeconds: DELETE_MESSAGE_SECONDS, + reason: `Posted in honey-pot channel ${message.channelId}`, + }); + } catch (error) { + HoneyPotEnforcementService.cancel(message.guild.id, member.id); + const errorMessage = formatError(error); + Logger.writeError(`Honey-pot ban failed for ${member.id}.`, errorMessage); + await sendAudit(client, message, 'Honey-pot ban failed', errorMessage).catch((auditError) => { + Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); + }); + return; + } + + await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { + Logger.writeError( + `Could not delete profile data for honey-pot ban ${member.id}.`, + formatError(error) + ); }); - } catch (error) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); - const errorMessage = formatError(error); - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, errorMessage); - await sendAudit(client, message, 'Honey-pot ban failed', errorMessage).catch((auditError) => { - Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); + await sendAudit( + client, + message, + 'Honey-pot triggered', + "Banned and deleted the member's messages from the past 7 days." + ).catch((error) => { + Logger.writeError('Could not send honey-pot success audit.', formatError(error)); }); - return; - } - - await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { - Logger.writeError( - `Could not delete profile data for honey-pot ban ${member.id}.`, - formatError(error) - ); - }); - await sendAudit( - client, - message, - 'Honey-pot triggered', - "Banned and deleted the member's messages from the past 7 days." - ).catch((error) => { - Logger.writeError('Could not send honey-pot success audit.', formatError(error)); - }); -}; + }, +}); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index e6644d1d..2f9382e2 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,45 +1,45 @@ -import { Message } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { ChannelService } from '../utilities/ChannelService'; import { ConfigService } from '../utilities/ConfigService'; import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; import { UserProfileService } from '../utilities/UserProfileService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; -export const name = 'messageDelete'; -export const run: RunFunction = async (client: NovaClient, message: Message) => { - if (!message.author || !message.guild) { - return; - } +export default defineEvent({ + name: 'messageDelete', + run: async (client, message) => { + if (!message.author || !message.guild) { + return; + } - if (HoneyPotEnforcementService.isActive(message.guild.id, message.author.id)) { - return; - } + if (HoneyPotEnforcementService.isActive(message.guild.id, message.author.id)) { + return; + } - const serverConfig = await ConfigService.getConfig(message.guild.id); + const serverConfig = await ConfigService.getConfig(message.guild.id); - await UserProfileService.decrementActivityScore(message.guild.id, message.author.id); + await UserProfileService.decrementActivityScore(message.guild.id, message.author.id); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: message.author.tag, - iconURL: message.author.displayAvatarURL(), - }) - .setDescription('A message was deleted') - .setTimestamp(); + const audit = new EmbedCompatLayer() + .setColor(EmbedColours.neutral) + .setAuthor({ + name: message.author.tag, + iconURL: message.author.displayAvatarURL(), + }) + .setDescription('A message was deleted') + .setTimestamp(); - if (message.content) { - audit.addField('Message', message.content); - } - if (message.embeds.length > 0) { - audit.addField('Embeds', message.embeds.length.toString()); - } - if (message.attachments.size > 0) { - audit.addField('Attachments', message.attachments.size.toString()); - } + if (message.content) { + audit.addField('Message', message.content); + } + if (message.embeds.length > 0) { + audit.addField('Embeds', message.embeds.length.toString()); + } + if (message.attachments.size > 0) { + audit.addField('Attachments', message.attachments.size.toString()); + } - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + await ChannelService.sendAuditMessage(client, serverConfig, audit); + }, +}); diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 239eab99..8e4de811 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -1,44 +1,40 @@ -import { Message } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { ChannelService } from '../utilities/ChannelService'; import { ConfigService } from '../utilities/ConfigService'; import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -export const name = 'messageUpdate'; -export const run: RunFunction = async ( - client: NovaClient, - oldMessage: Message, - newMessage: Message -) => { - if (!oldMessage.content) { - return; - } +export default defineEvent({ + name: 'messageUpdate', + run: async (client, oldMessage, newMessage) => { + if (!oldMessage.content) { + return; + } - if (newMessage.partial) { - newMessage = await newMessage.fetch(); - } + if (newMessage.partial) { + newMessage = await newMessage.fetch(); + } - if (newMessage.author.bot || !newMessage.guild) { - return; - } + if (newMessage.author.bot || !newMessage.guild) { + return; + } - if (oldMessage.content === newMessage.content) { - return; - } + if (oldMessage.content === newMessage.content) { + return; + } - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: newMessage.author.tag, - iconURL: newMessage.author.displayAvatarURL(), - }) - .setDescription('A message was edited') - .addField('Previous', oldMessage.content) - .addField('Current', newMessage.content) - .setTimestamp(); + const audit = new EmbedCompatLayer() + .setColor(EmbedColours.neutral) + .setAuthor({ + name: newMessage.author.tag, + iconURL: newMessage.author.displayAvatarURL(), + }) + .setDescription('A message was edited') + .addField('Previous', oldMessage.content) + .addField('Current', newMessage.content) + .setTimestamp(); - const serverConfig = await ConfigService.getConfig(newMessage.guild.id); - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + const serverConfig = await ConfigService.getConfig(newMessage.guild.id); + await ChannelService.sendAuditMessage(client, serverConfig, audit); + }, +}); diff --git a/src/events/ready.ts b/src/events/ready.ts index 441b4c11..8cdde8a4 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,30 +1,32 @@ import { env } from '../config/env'; import schedule from 'node-schedule'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; +import { defineEvent } from '../framework/event'; import { BirthdayManager } from '../utilities/BirthdayManager'; import { CommandRegistrar } from '../utilities/CommandRegistrar'; import { DataCheck } from '../utilities/DataCheck'; import { Logger } from '../utilities/Logger'; -export const name = 'ready'; -export const run: RunFunction = async (client: NovaClient) => { - client.user!.setPresence({ status: 'online' }); +export default defineEvent({ + name: 'clientReady', + once: true, + run: async (client) => { + client.user!.setPresence({ status: 'online' }); - Logger.writeLog('Online'); - await CommandRegistrar.registerGlobalCommands(client); + Logger.writeLog('Online'); + await CommandRegistrar.register([...client.slashCommands.values()]); - const logFailure = (job: string) => (err: unknown) => - Logger.writeError(`Scheduled job failed: ${job}.`, err); + const logFailure = (job: string) => (err: unknown) => + Logger.writeError(`Scheduled job failed: ${job}.`, err); - const birthdaySchedule = schedule.scheduleJob(env.jobSchedule, () => { - DataCheck.dataCleanup(client).catch(logFailure('data cleanup')); - BirthdayManager.notifyBirthdays(client).catch(logFailure('birthday notifications')); - BirthdayManager.populateCalendars(client).catch(logFailure('birthday calendars')); - }); - if (!birthdaySchedule) { - Logger.writeError(`Could not schedule job with "${env.jobSchedule}".`); - return; - } - Logger.writeLog(`Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}`); -}; + const birthdaySchedule = schedule.scheduleJob(env.jobSchedule, () => { + DataCheck.dataCleanup(client).catch(logFailure('data cleanup')); + BirthdayManager.notifyBirthdays(client).catch(logFailure('birthday notifications')); + BirthdayManager.populateCalendars(client).catch(logFailure('birthday calendars')); + }); + if (!birthdaySchedule) { + Logger.writeError(`Could not schedule job with "${env.jobSchedule}".`); + return; + } + Logger.writeLog(`Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}`); + }, +}); diff --git a/src/framework/command.ts b/src/framework/command.ts new file mode 100644 index 00000000..398b81b1 --- /dev/null +++ b/src/framework/command.ts @@ -0,0 +1,224 @@ +import { + APIEmbed, + Attachment, + AttachmentBuilder, + AttachmentPayload, + ChatInputCommandInteraction, + EmbedBuilder, + Guild, + GuildMember, + InteractionContextType, + InteractionEditReplyOptions, + InteractionReplyOptions, + MessageFlags, + MessageMentionOptions, + PermissionFlagsBits, + RESTPostAPIChatInputApplicationCommandsJSONBody, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; +import type { ServerConfig } from '../client/models/ServerConfig'; + +export type DeferMode = 'ephemeral' | 'public' | false; + +export interface ReplyOptions { + content?: string; + embeds?: APIEmbed[] | EmbedBuilder[]; + files?: AttachmentPayload[] | AttachmentBuilder[] | Attachment[]; + allowedMentions?: MessageMentionOptions; + ephemeral?: boolean; +} + +export interface CommandContext { + interaction: ChatInputCommandInteraction<'cached'>; + guild: Guild; + member: GuildMember; + config: ServerConfig; + reply(response: string | ReplyOptions): Promise; +} + +export type Handler = (ctx: CommandContext) => Promise; + +export interface SubcommandDefinition { + name: string; + description: string; + defer?: DeferMode; + options?: (b: SlashCommandSubcommandBuilder) => SlashCommandSubcommandBuilder; + run: Handler; +} + +export interface SubcommandGroupDefinition { + name: string; + description: string; + subcommands: SubcommandDefinition[]; +} + +export interface CommandDefinition { + name: string; + description: string; + adminOnly?: boolean; + defer?: DeferMode; + options?: (b: SlashCommandOptionsOnlyBuilder) => SlashCommandOptionsOnlyBuilder; + run?: Handler; + subcommands?: SubcommandDefinition[]; + groups?: SubcommandGroupDefinition[]; +} + +export interface ResolvedCommand { + run: Handler; + defer: DeferMode; + /** Human-readable path such as "config welcome set", used for logging. */ + path: string; +} + +export interface Command { + name: string; + toJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody; + resolve(interaction: ChatInputCommandInteraction): ResolvedCommand | undefined; +} + +export function buildReplyPayload( + response: string | ReplyOptions +): Omit & { flags?: MessageFlags.Ephemeral } { + const { ephemeral = true, ...rest } = + typeof response === 'string' ? { content: response } : response; + return ephemeral ? { ...rest, flags: MessageFlags.Ephemeral } : rest; +} + +/** Replies, edits the deferred reply, or follows up depending on the interaction's state. */ +export function createReply(interaction: ChatInputCommandInteraction): CommandContext['reply'] { + return async (response) => { + const payload = buildReplyPayload(response); + if (!interaction.deferred && !interaction.replied) { + await interaction.reply(payload as InteractionReplyOptions); + } else if (interaction.deferred && !interaction.replied) { + // The ephemeral flag is fixed when deferring and cannot be changed by an edit + const edit: Omit & { flags?: unknown } = { ...payload }; + delete edit.flags; + await interaction.editReply(edit as InteractionEditReplyOptions); + } else { + await interaction.followUp(payload as InteractionReplyOptions); + } + }; +} + +const assertUnique = (kind: string, where: string, names: string[]): void => { + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) { + throw new Error(`Duplicate ${kind} name "${name}" in ${where}`); + } + seen.add(name); + } +}; + +const subcommandBuilder = + (def: SubcommandDefinition) => + (b: SlashCommandSubcommandBuilder): SlashCommandSubcommandBuilder => { + const named = b.setName(def.name).setDescription(def.description); + return def.options ? def.options(named) : named; + }; + +export function defineCommand(def: CommandDefinition): Command { + const where = `command "${def.name}"`; + const hasSubs = (def.subcommands?.length ?? 0) > 0; + const hasGroups = (def.groups?.length ?? 0) > 0; + const isLeaf = def.run !== undefined; + + if (isLeaf && (hasSubs || hasGroups)) { + throw new Error(`${where} cannot define both run and subcommands/groups`); + } + if (!isLeaf && !hasSubs && !hasGroups) { + throw new Error(`${where} must define either run or subcommands/groups`); + } + if (!isLeaf && (def.options || def.defer !== undefined)) { + throw new Error(`${where} sets options/defer but is not a leaf command; set them on subcommands`); + } + + assertUnique('subcommand/group', where, [ + ...(def.subcommands ?? []).map((s) => s.name), + ...(def.groups ?? []).map((g) => g.name), + ]); + + const routes = new Map(); + const routeKey = (group: string | null, sub: string | null): string => + `${group ?? ''}/${sub ?? ''}`; + + let builder = new SlashCommandBuilder() + .setName(def.name) + .setDescription(def.description) + .setContexts([InteractionContextType.Guild]); + + if (def.adminOnly) { + builder = builder.setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + } + + if (def.run) { + if (def.options) { + builder = def.options(builder) as unknown as SlashCommandBuilder; + } + routes.set(routeKey(null, null), { run: def.run, defer: def.defer ?? false, path: def.name }); + } + + for (const sub of def.subcommands ?? []) { + builder.addSubcommand(subcommandBuilder(sub)); + routes.set(routeKey(null, sub.name), { + run: sub.run, + defer: sub.defer ?? false, + path: `${def.name} ${sub.name}`, + }); + } + + for (const group of def.groups ?? []) { + const groupWhere = `group "${group.name}" in ${where}`; + if (group.subcommands.length === 0) { + throw new Error(`${groupWhere} must define at least one subcommand`); + } + assertUnique( + 'subcommand', + groupWhere, + group.subcommands.map((s) => s.name) + ); + builder.addSubcommandGroup((g: SlashCommandSubcommandGroupBuilder) => { + g.setName(group.name).setDescription(group.description); + for (const sub of group.subcommands) { + g.addSubcommand(subcommandBuilder(sub)); + routes.set(routeKey(group.name, sub.name), { + run: sub.run, + defer: sub.defer ?? false, + path: `${def.name} ${group.name} ${sub.name}`, + }); + } + return g; + }); + } + + // Building the JSON now surfaces invalid names, descriptions and options at definition time + let json: RESTPostAPIChatInputApplicationCommandsJSONBody; + try { + json = builder.toJSON(); + } catch (error) { + throw new Error( + `Invalid ${where}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ); + } + + return { + name: def.name, + toJSON: () => json, + resolve: (interaction) => + routes.get( + routeKey( + interaction.options.getSubcommandGroup(false), + interaction.options.getSubcommand(false) + ) + ), + }; +} + +export function defineSubcommandGroup(def: SubcommandGroupDefinition): SubcommandGroupDefinition { + return def; +} diff --git a/src/framework/event.ts b/src/framework/event.ts new file mode 100644 index 00000000..d84c3b0b --- /dev/null +++ b/src/framework/event.ts @@ -0,0 +1,42 @@ +import { Client, ClientEvents } from 'discord.js'; +import type { NovaClient } from '../client/NovaClient'; +import { Logger } from '../utilities/Logger'; + +export interface EventDefinition { + name: K; + once?: boolean; + run(client: NovaClient, ...args: ClientEvents[K]): Promise; +} + +export function defineEvent( + def: EventDefinition +): EventDefinition { + return def; +} + +const guildIdOf = (arg: unknown): string | undefined => { + if (typeof arg !== 'object' || arg === null) { + return undefined; + } + const candidate = arg as { guildId?: unknown; guild?: { id?: unknown } | null }; + const id = candidate.guildId ?? candidate.guild?.id; + return typeof id === 'string' ? id : undefined; +}; + +/** Registers an event on the client, logging (never throwing) handler failures. */ +export function bindEvent( + client: NovaClient, + def: EventDefinition +): void { + const listener = (...args: ClientEvents[K]): void => { + def.run(client, ...args).catch((error: unknown) => { + Logger.error('Event handler failed', { event: def.name, guild: guildIdOf(args[0]) }, error); + }); + }; + const target: Client = client; + if (def.once) { + target.once(def.name, listener); + } else { + target.on(def.name, listener); + } +} diff --git a/src/framework/loader.ts b/src/framework/loader.ts new file mode 100644 index 00000000..1f44bc1b --- /dev/null +++ b/src/framework/loader.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { Command } from './command'; +import type { EventDefinition } from './event'; + +const isModuleFile = (name: string): boolean => + (name.endsWith('.js') || name.endsWith('.ts')) && !name.endsWith('.d.ts'); + +async function listModules(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && isModuleFile(entry.name)) + .map((entry) => path.join(dir, entry.name)) + .sort(); +} + +async function importDefault(file: string): Promise { + let mod: any; + try { + mod = await import(pathToFileURL(file).href); + } catch (error) { + throw new Error(`Failed to load ${file}: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); + } + // CommonJS output surfaces as { default: module.exports }, so unwrap the __esModule layer + let value = mod?.default ?? mod; + if (value?.__esModule && 'default' in value) { + value = value.default; + } + return value; +} + +const isCommand = (value: any): value is Command => + typeof value?.name === 'string' && + typeof value.toJSON === 'function' && + typeof value.resolve === 'function'; + +const isEvent = (value: any): value is EventDefinition => + typeof value?.name === 'string' && + typeof value.run === 'function' && + (value.once === undefined || typeof value.once === 'boolean'); + +/** Loads `//*.js`, skipping `Subcommands` directories and nested dirs. */ +export async function loadCommands(root: string): Promise { + const categories = (await fs.readdir(root, { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && entry.name !== 'Subcommands' + ); + + const commands: Command[] = []; + const seen = new Map(); + for (const category of categories.sort((a, b) => a.name.localeCompare(b.name))) { + for (const file of await listModules(path.join(root, category.name))) { + const value = await importDefault(file); + if (!isCommand(value)) { + throw new Error(`${file} must export default a Command created with defineCommand`); + } + const existing = seen.get(value.name); + if (existing) { + throw new Error(`Duplicate command "${value.name}" in ${file} (already defined in ${existing})`); + } + seen.set(value.name, file); + commands.push(value); + } + } + return commands; +} + +/** Loads every top-level module in `dir` as an event definition. */ +export async function loadEvents(dir: string): Promise[]> { + const events: EventDefinition[] = []; + const seen = new Map(); + for (const file of await listModules(dir)) { + const value = await importDefault(file); + if (!isEvent(value)) { + throw new Error(`${file} must export default an event created with defineEvent`); + } + const existing = seen.get(value.name); + if (existing) { + throw new Error(`Duplicate event "${value.name}" in ${file} (already defined in ${existing})`); + } + seen.set(value.name, file); + events.push(value); + } + return events; +} diff --git a/src/framework/settings.ts b/src/framework/settings.ts new file mode 100644 index 00000000..90246dbe --- /dev/null +++ b/src/framework/settings.ts @@ -0,0 +1,126 @@ +import { ApplicationCommandOptionAllowedChannelTypes, Guild } from 'discord.js'; +import { defineSubcommandGroup, SubcommandGroupDefinition } from './command'; + +export type ChannelField = 'auditChannelId' | 'announcementsChannelId' | 'honeyPotChannelId'; +export type BooleanField = 'debug' | 'systemMessagesEnabled'; + +export interface ChannelSettingOptions { + name: string; + description: string; + field: ChannelField; + label: string; + channelTypes: ApplicationCommandOptionAllowedChannelTypes[]; +} + +export interface BooleanSettingOptions { + name: string; + description: string; + field: BooleanField; + label: string; +} + +export const channelSetText = (label: string, channelId: string): string => + `${label} set to <#${channelId}>.`; + +export const channelGetText = ( + label: string, + channelId: string | null, + exists: boolean +): string => { + if (!channelId) { + return `${label}: not set`; + } + return exists + ? `${label}: <#${channelId}>` + : `${label}: <#${channelId}> (this channel no longer exists in this server)`; +}; + +export const channelClearText = (label: string): string => `${label} cleared.`; + +export const booleanText = (label: string, enabled: boolean): string => + `${label}: ${enabled ? 'enabled' : 'disabled'}`; + +const channelExists = async (guild: Guild, id: string): Promise => { + if (guild.channels.cache.has(id)) { + return true; + } + const fetched = await guild.channels.fetch(id).catch(() => null); + return fetched !== null; +}; + +export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefinition { + const { field, label } = opts; + return defineSubcommandGroup({ + name: opts.name, + description: opts.description, + subcommands: [ + { + name: 'set', + description: `Set the ${label.toLowerCase()}`, + options: (b) => + b.addChannelOption((o) => + o + .setName('channel') + .setDescription(`Channel to use for: ${label.toLowerCase()}`) + .addChannelTypes(...opts.channelTypes) + .setRequired(true) + ), + run: async (ctx) => { + const channel = ctx.interaction.options.getChannel('channel', true); + ctx.config[field] = channel.id; + await ctx.config.save(); + await ctx.reply(channelSetText(label, channel.id)); + }, + }, + { + name: 'get', + description: `Show the ${label.toLowerCase()}`, + run: async (ctx) => { + const id = ctx.config[field]; + const exists = id ? await channelExists(ctx.guild, id) : false; + await ctx.reply(channelGetText(label, id, exists)); + }, + }, + { + name: 'clear', + description: `Clear the ${label.toLowerCase()}`, + run: async (ctx) => { + ctx.config[field] = null; + await ctx.config.save(); + await ctx.reply(channelClearText(label)); + }, + }, + ], + }); +} + +export function booleanSetting(opts: BooleanSettingOptions): SubcommandGroupDefinition { + const { field, label } = opts; + return defineSubcommandGroup({ + name: opts.name, + description: opts.description, + subcommands: [ + { + name: 'set', + description: `Enable or disable: ${label.toLowerCase()}`, + options: (b) => + b.addBooleanOption((o) => + o.setName('enabled').setDescription(`Whether ${label.toLowerCase()} are on`).setRequired(true) + ), + run: async (ctx) => { + const enabled = ctx.interaction.options.getBoolean('enabled', true); + ctx.config[field] = enabled; + await ctx.config.save(); + await ctx.reply(booleanText(label, enabled)); + }, + }, + { + name: 'get', + description: `Show whether ${label.toLowerCase()} are enabled`, + run: async (ctx) => { + await ctx.reply(booleanText(label, ctx.config[field])); + }, + }, + ], + }); +} diff --git a/src/framework/shutdown.ts b/src/framework/shutdown.ts new file mode 100644 index 00000000..7fb85b6a --- /dev/null +++ b/src/framework/shutdown.ts @@ -0,0 +1,20 @@ +import { Logger } from '../utilities/Logger'; + +type ShutdownHook = () => Promise | void; + +const hooks: ShutdownHook[] = []; + +export function registerShutdownHook(hook: ShutdownHook): void { + hooks.push(hook); +} + +/** Runs hooks in reverse registration order; a failing hook never blocks the rest. */ +export async function runShutdownHooks(): Promise { + for (const hook of [...hooks].reverse()) { + try { + await hook(); + } catch (error) { + Logger.error('Shutdown hook failed', undefined, error); + } + } +} diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts index 5b1147e4..bd558252 100644 --- a/src/slash-commands/Admin/noroles.ts +++ b/src/slash-commands/Admin/noroles.ts @@ -1,48 +1,33 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - PermissionFlagsBits, - InteractionContextType, -} from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; -import { requireGuild } from '../../utilities/requireGuild'; +import { defineCommand } from '../../framework/command'; -const execute = async (cmd: ChatInputCommandInteraction) => { - const allMembers = await requireGuild(cmd).members.fetch(); +export default defineCommand({ + name: 'noroles', + description: 'Returns members of this guild with no roles assigned', + adminOnly: true, + run: async (ctx) => { + const allMembers = await ctx.guild.members.fetch(); - const members = allMembers.filter((member) => member.roles.cache.size === 1); + const members = allMembers.filter((member) => member.roles.cache.size === 1); - let response: string; + let response: string; - if (members.size < 1) { - response = 'There are no users with no roles.'; - } else { - response = '**Users with no roles**\n------\n'; - for (const mem of members.values()) { - if (mem.partial) { - await mem.fetch(); + if (members.size < 1) { + response = 'There are no users with no roles.'; + } else { + response = '**Users with no roles**\n------\n'; + for (const mem of members.values()) { + if (mem.partial) { + await mem.fetch(); + } + response += `${mem.toString()} joined \n`; } - response += `${mem.toString()} joined \n`; } - } - return cmd.reply({ - content: response, - allowedMentions: { - parse: [], - }, - ephemeral: true, - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('noroles') - .setDescription('Returns members of this guild with no roles assigned') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; + await ctx.reply({ + content: response, + allowedMentions: { + parse: [], + }, + }); + }, +}); diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts index 1170e72f..f9d51bff 100644 --- a/src/slash-commands/Admin/post.ts +++ b/src/slash-commands/Admin/post.ts @@ -1,100 +1,78 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - PermissionFlagsBits, - SlashCommandChannelOption, - SlashCommandStringOption, - SlashCommandAttachmentOption, - Attachment, - InteractionContextType, -} from 'discord.js'; +import { Attachment } from 'discord.js'; import { ServerConfig } from '../../client/models/ServerConfig'; +import { CommandContext, defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; import { ChannelService } from '../../utilities/ChannelService'; import { Logger } from '../../utilities/Logger'; -import { requireGuild } from '../../utilities/requireGuild'; const sendAudit = async ( - cmd: ChatInputCommandInteraction, + ctx: CommandContext, config: ServerConfig, file: Attachment | null, content: string | null ) => { const embed = new EmbedCompatLayer(); - const member = requireGuild(cmd).members.cache.get(cmd.user.id); + const member = ctx.guild.members.cache.get(ctx.interaction.user.id); embed .setColor(EmbedColours.neutral) .setAuthor({ - name: member?.displayName ?? cmd.user.username, - iconURL: (member ?? cmd.user).displayAvatarURL(), + name: member?.displayName ?? ctx.interaction.user.username, + iconURL: (member ?? ctx.interaction.user).displayAvatarURL(), }) .addField('Content', content ? content : 'No') .addField('Attachment', file ? 'Yes' : 'No') .setDescription('Post created via Dingir') .setTimestamp(); - await ChannelService.sendAuditMessage(cmd.client, config, embed, file ?? undefined); + await ChannelService.sendAuditMessage(ctx.interaction.client, config, embed, file ?? undefined); }; -const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - await cmd.deferReply({ ephemeral: true }); - const channel = cmd.options.getChannel('channel', true); - const content = cmd.options.getString('content'); - const attachment = cmd.options.getAttachment('attachment'); +export default defineCommand({ + name: 'post', + description: 'Posts a simple message and/or attachment to the given channel', + adminOnly: true, + defer: 'ephemeral', + options: (b) => + b + .addChannelOption((opt) => + opt.setName('channel').setDescription('Channel to post in').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('content').setDescription('Optional simple message to send') + ) + .addAttachmentOption((opt) => + opt.setName('attachment').setDescription('Optional attachment to send') + ), + run: async (ctx) => { + const cmd = ctx.interaction; + const channel = cmd.options.getChannel('channel', true); + const content = cmd.options.getString('content'); + const attachment = cmd.options.getAttachment('attachment'); - if (!content && !attachment) { - return cmd.editReply({ - content: 'You must provide at least text or an attachment', - }); - } + if (!content && !attachment) { + await ctx.reply('You must provide at least text or an attachment'); + return; + } - const guildChannel = requireGuild(cmd).channels.cache.get(channel.id); - if (!guildChannel || !guildChannel.isTextBased()) { - return cmd.editReply({ - content: 'The provided channel is not valid', - }); - } + const guildChannel = ctx.guild.channels.cache.get(channel.id); + if (!guildChannel || !guildChannel.isTextBased()) { + await ctx.reply('The provided channel is not valid'); + return; + } - await guildChannel - .send({ - content: content ?? undefined, - files: attachment ? [attachment] : undefined, - }) - .catch(() => { - return cmd.editReply({ - content: 'An error was encountered sending this message', - }); - }); - - await sendAudit(cmd, config, attachment, content).catch((err: unknown) => - Logger.writeError('Could not send post audit.', err) - ); + await guildChannel + .send({ + content: content ?? undefined, + files: attachment ? [attachment] : undefined, + }) + .catch(() => ctx.reply('An error was encountered sending this message')); - return cmd.editReply({ - content: 'Message successfully sent', - }); -}; + await sendAudit(ctx, ctx.config, attachment, content).catch((err: unknown) => + Logger.writeError('Could not send post audit.', err) + ); -const commandData = new SlashCommandBuilder() - .setName('post') - .setDescription('Posts a simple message and/or attachment to the given channel') - .addChannelOption((opt: SlashCommandChannelOption) => - opt.setName('channel').setDescription('Channel to post in').setRequired(true) - ) - .addStringOption((opt: SlashCommandStringOption) => - opt.setName('content').setDescription('Optional simple message to send') - ) - .addAttachmentOption((opt: SlashCommandAttachmentOption) => - opt.setName('attachment').setDescription('Optional attachment to send') - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; + await ctx.reply('Message successfully sent'); + }, +}); diff --git a/src/slash-commands/Admin/rolesince.ts b/src/slash-commands/Admin/rolesince.ts index 12fd283d..c016f411 100644 --- a/src/slash-commands/Admin/rolesince.ts +++ b/src/slash-commands/Admin/rolesince.ts @@ -1,68 +1,49 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - PermissionFlagsBits, - SlashCommandRoleOption, - SlashCommandNumberOption, - InteractionContextType, -} from 'discord.js'; import { DateTime } from 'luxon'; -import { SlashCommand } from '../../types/SlashCommand'; -import { requireGuild } from '../../utilities/requireGuild'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const role = cmd.options.getRole('role', true); - const days = cmd.options.getNumber('days') ?? 0; - - const allMembers = await requireGuild(cmd).members.fetch(); - - const members = allMembers.filter((member) => { - const joined = DateTime.fromMillis(member.joinedTimestamp ?? Date.now()).startOf('day'); - const daysInServer = DateTime.local().startOf('day').diff(joined, 'days').days; - return member.roles.cache.find((r) => r.id === role.id) && daysInServer >= days; - }); - - let response: string; - - if (members.size < 1) { - response = `There are no users in ${role.toString()} that have been in the server for at least ${ - days ?? 0 - } days.`; - } else { - response = `**Users in ${role.toString()} that have been in the server for at least ${ - days ?? 0 - } days.**\n------\n`; - for (const mem of members.values()) { - if (mem.partial) { - await mem.fetch(); +import { defineCommand } from '../../framework/command'; + +export default defineCommand({ + name: 'rolesince', + description: 'Returns members in the given role for the specified number of days', + adminOnly: true, + options: (b) => + b + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to search again').setRequired(true) + ) + .addNumberOption((opt) => + opt.setName('days').setDescription('Minimum number of days in the role') + ), + run: async (ctx) => { + const role = ctx.interaction.options.getRole('role', true); + const days = ctx.interaction.options.getNumber('days') ?? 0; + + const allMembers = await ctx.guild.members.fetch(); + + const members = allMembers.filter((member) => { + const joined = DateTime.fromMillis(member.joinedTimestamp ?? Date.now()).startOf('day'); + const daysInServer = DateTime.local().startOf('day').diff(joined, 'days').days; + return member.roles.cache.find((r) => r.id === role.id) && daysInServer >= days; + }); + + let response: string; + + if (members.size < 1) { + response = `There are no users in ${role.toString()} that have been in the server for at least ${days} days.`; + } else { + response = `**Users in ${role.toString()} that have been in the server for at least ${days} days.**\n------\n`; + for (const mem of members.values()) { + if (mem.partial) { + await mem.fetch(); + } + response += `${mem.toString()} joined \n`; } - response += `${mem.toString()} joined \n`; } - } - - return cmd.reply({ - content: response, - allowedMentions: { - parse: [], - }, - ephemeral: true, - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('rolesince') - .setDescription('Returns members in the given role for the specified number of days') - .addRoleOption((opt: SlashCommandRoleOption) => - opt.setName('role').setDescription('Role to search again').setRequired(true) - ) - .addNumberOption((opt: SlashCommandNumberOption) => - opt.setName('days').setDescription('Minimum number of days in the role') - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; + await ctx.reply({ + content: response, + allowedMentions: { + parse: [], + }, + }); + }, +}); diff --git a/src/slash-commands/Admin/simulate.ts b/src/slash-commands/Admin/simulate.ts index b9d93383..2a7ed5f8 100644 --- a/src/slash-commands/Admin/simulate.ts +++ b/src/slash-commands/Admin/simulate.ts @@ -1,95 +1,58 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - PermissionFlagsBits, - SlashCommandSubcommandBuilder, - SlashCommandUserOption, - InteractionContextType, -} from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; -import { requireGuild } from '../../utilities/requireGuild'; +import { GuildMember } from 'discord.js'; +import { CommandContext, defineCommand } from '../../framework/command'; -const findMember = (cmd: ChatInputCommandInteraction) => { - const members = requireGuild(cmd).members.cache; - const user = cmd.options.getUser('member'); - return (user ? members.get(user.id) : undefined) ?? members.get(cmd.user.id); +const findMember = (ctx: CommandContext) => { + const members = ctx.guild.members.cache; + const user = ctx.interaction.options.getUser('member'); + return (user ? members.get(user.id) : undefined) ?? members.get(ctx.interaction.user.id); }; -const testJoin = async (cmd: ChatInputCommandInteraction) => { - const guildMember = findMember(cmd); - if (!guildMember) { - return cmd.reply({ content: 'Could not find that member.', ephemeral: true }); - } - cmd.client.emit('guildMemberAdd', guildMember); - - await cmd.reply({ - content: `Emitted guildMemberAdd for ${guildMember.toString()}`, - ephemeral: true, - }); -}; - -const testScreen = async (cmd: ChatInputCommandInteraction) => { - const guildMember = findMember(cmd); - if (!guildMember) { - return cmd.reply({ content: 'Could not find that member.', ephemeral: true }); - } - - const oldMemberMock = Object.assign({}, guildMember, { - pending: true, - }); - const newMemberMock = guildMember; - - cmd.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); - - await cmd.reply({ - content: `Emitted guildMemberUpdate for ${guildMember.toString()}`, - ephemeral: true, - }); -}; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const subCommand = cmd.options.getSubcommand(); - - if (!subCommand) { - return cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); - } - - if (subCommand == 'join') { - return testJoin(cmd); - } else if (subCommand == 'screen') { - return testScreen(cmd); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const commandData = new SlashCommandBuilder() - .setName('simulate') - .setDescription('Simulate events in this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('join') - .setDescription('Simulate someone joining this server') - .addUserOption((opt: SlashCommandUserOption) => - opt.setName('member').setDescription('Member to simulate joining as') - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('screen') - .setDescription('Simulate someone passing screening in this server') - .addUserOption((opt: SlashCommandUserOption) => - opt.setName('member').setDescription('Member to simulate passing screening as') - ) - ); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; +export default defineCommand({ + name: 'simulate', + description: 'Simulate events in this server', + adminOnly: true, + subcommands: [ + { + name: 'join', + description: 'Simulate someone joining this server', + options: (b) => + b.addUserOption((opt) => + opt.setName('member').setDescription('Member to simulate joining as') + ), + run: async (ctx) => { + const guildMember = findMember(ctx); + if (!guildMember) { + await ctx.reply('Could not find that member.'); + return; + } + ctx.interaction.client.emit('guildMemberAdd', guildMember); + + await ctx.reply(`Emitted guildMemberAdd for ${guildMember.toString()}`); + }, + }, + { + name: 'screen', + description: 'Simulate someone passing screening in this server', + options: (b) => + b.addUserOption((opt) => + opt.setName('member').setDescription('Member to simulate passing screening as') + ), + run: async (ctx) => { + const guildMember = findMember(ctx); + if (!guildMember) { + await ctx.reply('Could not find that member.'); + return; + } + + const oldMemberMock = Object.assign({}, guildMember, { + pending: true, + }) as unknown as GuildMember; + const newMemberMock = guildMember; + + ctx.interaction.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); + + await ctx.reply(`Emitted guildMemberUpdate for ${guildMember.toString()}`); + }, + }, + ], +}); diff --git a/src/slash-commands/Config/Subcommands/announcements.ts b/src/slash-commands/Config/Subcommands/announcements.ts deleted file mode 100644 index 35f0e5ab..00000000 --- a/src/slash-commands/Config/Subcommands/announcements.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandBuilder, - SlashCommandChannelOption, - ChatInputCommandInteraction, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ConfigManager } from '../../../utilities/ConfigManager'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - await ConfigManager.updateChannelNew(cmd, config, 'announcementsChannelId'); - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = await ConfigManager.getChannel(cmd, config, 'announcementsChannelId'); - return cmd.reply({ - content: `The announcements channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, - ephemeral: true, - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - await ConfigManager.clearChannel(config, 'announcementsChannelId'); - return get(cmd, config); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('announcements') - .setDescription('Configure the announcements channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription("Set's the channel to post announcements in") - .addChannelOption((opt: SlashCommandChannelOption) => - opt - .setName('channel') - .setDescription('Channel to set as the announcements channel') - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('get').setDescription("Get's the channel announcements are posted in") - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('clear').setDescription('Disables the announcements channel feature') - ); - -export const AnnouncementsCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; diff --git a/src/slash-commands/Config/Subcommands/audit.ts b/src/slash-commands/Config/Subcommands/audit.ts deleted file mode 100644 index 719a0a04..00000000 --- a/src/slash-commands/Config/Subcommands/audit.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandBuilder, - SlashCommandChannelOption, - ChatInputCommandInteraction, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ConfigManager } from '../../../utilities/ConfigManager'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - await ConfigManager.updateChannelNew(cmd, config, 'auditChannelId'); - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = await ConfigManager.getChannel(cmd, config, 'auditChannelId'); - return cmd.reply({ - content: `The audit channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, - ephemeral: true, - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - await ConfigManager.clearChannel(config, 'auditChannelId'); - return get(cmd, config); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('audit') - .setDescription('Configure the audit channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription("Set's the channel to post audits in") - .addChannelOption((opt: SlashCommandChannelOption) => - opt - .setName('channel') - .setDescription('Channel to set as the audits channel') - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('get').setDescription("Get's the channel audits are posted in") - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('clear').setDescription('Disables the audits channel feature') - ); - -export const AuditCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index d9b13d7b..c6b1e70d 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -1,81 +1,54 @@ -import { - SlashCommandSubcommandBuilder, - SlashCommandChannelOption, - ChatInputCommandInteraction, - SlashCommandSubcommandGroupBuilder, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { defineSubcommandGroup } from '../../../framework/command'; import { BirthdayManager } from '../../../utilities/BirthdayManager'; -import { requireGuild } from '../../../utilities/requireGuild'; -const create = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const { id } = cmd.options.getChannel('channel', true); - const channel = requireGuild(cmd).channels.cache.get(id); - - if (!channel || !channel.isTextBased()) { - return cmd.reply('Provided channel is not a text channel'); - } - - const birthdaysCalendar = await channel.send({ - content: 'Placeholder calendar message - populating...', - }); - config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; - await config.save(); - await BirthdayManager.populateCalendars(cmd.client, requireGuild(cmd).id); - - return cmd.reply({ content: 'Birthday calendar has been created.', ephemeral: true }); -}; - -const sync = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - try { - await BirthdayManager.populateCalendars(cmd.client, config.serverId); - } catch { - return cmd.reply({ - content: 'An error ocurred running the calendar sync for this server.', - ephemeral: true, - }); - } - - return cmd.reply({ - content: `Calendar successfully synchronised for ${requireGuild(cmd).name}.`, - ephemeral: true, - }); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'create') { - return create(cmd, config); - } else if (subCommand == 'sync') { - return sync(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('birthdays') - .setDescription('Configure the birthday calendar for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('create') - .setDescription('Creates or recreates a birthday calendar for this server') - .addChannelOption((opt: SlashCommandChannelOption) => - opt - .setName('channel') - .setDescription('Channel to create the birthday calendar in') - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('sync').setDescription('Syncs the birthday calendar for this server') - ); - -export const BirthdaysConfigCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; +export const BirthdaysGroup = defineSubcommandGroup({ + name: 'birthdays', + description: 'Configure the birthday calendar for this server', + subcommands: [ + { + name: 'create', + description: 'Creates or recreates a birthday calendar for this server', + options: (sub) => + sub.addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to create the birthday calendar in') + .setRequired(true) + ), + run: async (ctx) => { + const { id } = ctx.interaction.options.getChannel('channel', true); + const channel = ctx.guild.channels.cache.get(id); + + if (!channel || !channel.isTextBased()) { + await ctx.reply({ content: 'Provided channel is not a text channel', ephemeral: false }); + return; + } + + const birthdaysCalendar = await channel.send({ + content: 'Placeholder calendar message - populating...', + }); + ctx.config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; + await ctx.config.save(); + await BirthdayManager.populateCalendars(ctx.interaction.client, ctx.guild.id); + + await ctx.reply('Birthday calendar has been created.'); + }, + }, + { + name: 'sync', + description: 'Syncs the birthday calendar for this server', + run: async (ctx) => { + try { + await BirthdayManager.populateCalendars(ctx.interaction.client, ctx.config.serverId); + } catch { + await ctx.reply('An error ocurred running the calendar sync for this server.'); + return; + } + + await ctx.reply(`Calendar successfully synchronised for ${ctx.guild.name}.`); + }, + }, + ], +}); + +export default BirthdaysGroup; diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/slash-commands/Config/Subcommands/debug.ts index c7a3e420..e27a8856 100644 --- a/src/slash-commands/Config/Subcommands/debug.ts +++ b/src/slash-commands/Config/Subcommands/debug.ts @@ -1,62 +1,34 @@ -import { - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandBuilder, - ChatInputCommandInteraction, - SlashCommandBooleanOption, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { defineSubcommandGroup } from '../../../framework/command'; -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled', true); - config.debug = value; - await config.save(); +export const DebugGroup = defineSubcommandGroup({ + name: 'debug', + description: 'Toggle the debugging mode for this server', + subcommands: [ + { + name: 'set', + description: "Set's whether debug mode is enabled for this server", + options: (sub) => + sub.addBooleanOption((opt) => + opt + .setName('enabled') + .setDescription('Whether to enable debug mode or not') + .setRequired(true) + ), + run: async (ctx) => { + ctx.config.debug = ctx.interaction.options.getBoolean('enabled', true); + await ctx.config.save(); - return get(cmd, config); -}; + await ctx.reply(`Debug mode is: ${ctx.config.debug ? 'enabled' : 'disabled'}`); + }, + }, + { + name: 'get', + description: "Get's whether debug mode is enabled or not", + run: async (ctx) => { + await ctx.reply(`Debug mode is: ${ctx.config.debug ? 'enabled' : 'disabled'}`); + }, + }, + ], +}); -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config.debug; - - return cmd.reply({ - content: `Debug mode is: ${value ? 'enabled' : 'disabled'}`, - ephemeral: true, - }); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('debug') - .setDescription('Toggle the debugging mode for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription("Set's whether debug mode is enabled for this server") - .addBooleanOption((opt: SlashCommandBooleanOption) => - opt - .setName('enabled') - .setDescription('Whether to enable debug mode or not') - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('get').setDescription("Get's whether debug mode is enabled or not") - ); - -export const DebugCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; +export default DebugGroup; diff --git a/src/slash-commands/Config/Subcommands/honeypot.ts b/src/slash-commands/Config/Subcommands/honeypot.ts deleted file mode 100644 index 3df8439b..00000000 --- a/src/slash-commands/Config/Subcommands/honeypot.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - ChannelType, - ChatInputCommandInteraction, - SlashCommandChannelOption, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { requireGuild } from '../../../utilities/requireGuild'; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = config.honeyPotChannelId - ? requireGuild(cmd).channels.cache.get(config.honeyPotChannelId) - : null; - - return cmd.reply({ - content: `The honey-pot channel for this server is: ${ - channel ? channel.toString() : 'Not Set' - }`, - ephemeral: true, - }); -}; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = cmd.options.getChannel('channel', true); - if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { - return cmd.reply({ - content: 'Honey-pot channels must be Text or Announcement Channels.', - ephemeral: true, - }); - } - - config.honeyPotChannelId = channel.id; - await config.save(); - - return cmd.reply({ - content: `Honey-pot channel updated to ${channel.toString()}.`, - ephemeral: true, - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config.honeyPotChannelId = null; - await config.save(); - - return cmd.reply({ - content: 'Honey-pot channel disabled.', - ephemeral: true, - }); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - switch (cmd.options.getSubcommand()) { - case 'set': - return set(cmd, config); - case 'get': - return get(cmd, config); - case 'clear': - return clear(cmd, config); - default: - return cmd.reply({ - content: 'A valid option was not supplied for this command.', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('honeypot') - .setDescription('Configure the honey-pot channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription('Set the channel that automatically bans people who post in it') - .addChannelOption((opt: SlashCommandChannelOption) => - opt - .setName('channel') - .setDescription('Channel to use as the honey-pot') - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('get').setDescription('Get the configured honey-pot channel') - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('clear').setDescription('Disable the honey-pot channel') - ); - -export const HoneyPotCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index 7532aab3..e81313e1 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -1,35 +1,17 @@ -import { - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandBuilder, - ChatInputCommandInteraction, - SlashCommandRoleOption, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; +import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; import { EmbedColours } from '../../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../../types/EmbedCompatLayer'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; import { ChannelService } from '../../../utilities/ChannelService'; import { Logger } from '../../../utilities/Logger'; -import { requireGuild } from '../../../utilities/requireGuild'; -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const role1 = cmd.options.getRole('role-one', true); - const role2 = cmd.options.getRole('role-two'); - - const newGuestRoles: string = [role1.id, role2?.id].filter(Boolean).join(','); - config.guestRoleIds = newGuestRoles; - await config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { +const get = async (ctx: CommandContext) => { + const { config, guild, interaction: cmd } = ctx; if (!config.guestRoleIds) { - return cmd.reply('No guest roles configured for this server'); + await ctx.reply({ content: 'No guest roles configured for this server', ephemeral: false }); + return; } const roleIds: string[] = config.guestRoleIds.split(','); - const guild = requireGuild(cmd); const roles = guild.roles.cache.filter((r) => roleIds.includes(r.id)); const member = guild.members.cache.get(cmd.user.id); @@ -56,62 +38,55 @@ const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { await ChannelService.sendAuditMessage(cmd.client, config, audit).catch((err: unknown) => Logger.writeError('Could not send new roles audit.', err) ); - await cmd.reply({ - content: `New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`, - ephemeral: true, - }); + await ctx.reply(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`); }; -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config.guestRoleIds = null; - await config.save(); +export const NewRolesGroup = defineSubcommandGroup({ + name: 'newroles', + description: 'Control the roles assigned to newly screened members', + subcommands: [ + { + name: 'get', + description: 'Gets the roles assigned to newly screened members', + run: get, + }, + { + name: 'set', + description: 'Sets the roles of newly screened members', + options: (sub) => + sub + .addRoleOption((option) => + option + .setName('role-one') + .setDescription('First role to give to newly screened members') + .setRequired(true) + ) + .addRoleOption((option) => + option + .setName('role-two') + .setDescription('Second optional role to give to newly screened members') + ), + run: async (ctx) => { + const role1 = ctx.interaction.options.getRole('role-one', true); + const role2 = ctx.interaction.options.getRole('role-two'); - return get(cmd, config); -}; + ctx.config.guestRoleIds = [role1.id, role2?.id].filter(Boolean).join(','); + await ctx.config.save(); -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; + await get(ctx); + }, + }, + { + name: 'clear', + description: 'Clears the roles assigned to newly screened members', + run: async (ctx) => { + ctx.config.guestRoleIds = null; + await ctx.config.save(); -const data = new SlashCommandSubcommandGroupBuilder() - .setName('newroles') - .setDescription('Control the roles assigned to newly screened members') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('get').setDescription('Gets the roles assigned to newly screened members') - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription('Sets the roles of newly screened members') - .addRoleOption((option: SlashCommandRoleOption) => - option - .setName('role-one') - .setDescription('First role to give to newly screened members') - .setRequired(true) - ) - .addRoleOption((option: SlashCommandRoleOption) => - option - .setName('role-two') - .setDescription('Second optional role to give to newly screened members') - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('clear').setDescription('Clears the roles assigned to newly screened members') - ); + await get(ctx); + }, + }, + ], +}); -export const NewRolesCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; +export default NewRolesGroup; diff --git a/src/slash-commands/Config/Subcommands/systemMsgs.ts b/src/slash-commands/Config/Subcommands/systemMsgs.ts deleted file mode 100644 index 5b9d0539..00000000 --- a/src/slash-commands/Config/Subcommands/systemMsgs.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandBuilder, - ChatInputCommandInteraction, - SlashCommandBooleanOption, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled', true); - config.systemMessagesEnabled = value; - await config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config.systemMessagesEnabled; - - return cmd.reply({ - content: `Bot System messages are: ${value ? 'enabled' : 'disabled'}`, - ephemeral: true, - }); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('sysmsgs') - .setDescription('Toggle Bot created System Messages for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription( - "Set's whether system messages are posted in the default channel for this server" - ) - .addBooleanOption((opt: SlashCommandBooleanOption) => - opt - .setName('enabled') - .setDescription('Whether to post bot created system messages or not') - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub.setName('get').setDescription("Get's whether bot created system messages are posted or not") - ); - -export const SystemMessagesCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts index df4275ba..90e36ce1 100644 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ b/src/slash-commands/Config/Subcommands/welcome.ts @@ -1,108 +1,86 @@ -import { - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandBuilder, - ChatInputCommandInteraction, - SlashCommandStringOption, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; +import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; const imageChoice = { name: 'Welcome Image', value: 'welcomeMessageBackgroundUrl', -}; +} as const; const msgChoice = { name: 'Welcome Message', value: 'welcomeMessage', -}; +} as const; -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - console.log(cmd.options); - config[cmd.options.getString('content', true)] = cmd.options.getString('value', true); - await config.save(); +type WelcomeField = typeof imageChoice.value | typeof msgChoice.value; - return get(cmd, config); -}; +const fieldOf = (ctx: CommandContext) => + ctx.interaction.options.getString('content', true) as WelcomeField; -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config[cmd.options.getString('content', true)]; +const get = async (ctx: CommandContext) => { + const value = ctx.config[fieldOf(ctx)]; - return cmd.reply({ - content: `Current value is: ${value ? value : 'Not Set'}`, - ephemeral: true, - }); + await ctx.reply(`Current value is: ${value ? value : 'Not Set'}`); }; -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config[cmd.options.getString('content', true)] = null; - await config.save(); +export const WelcomeGroup = defineSubcommandGroup({ + name: 'welcome', + description: 'Control welcome information for this server', + subcommands: [ + { + name: 'set', + description: "Set's the welcome information for this server", + options: (sub) => + sub + .addStringOption((opt) => + opt + .setName('content') + .setDescription('Set the welcome image, or text?') + .setRequired(true) + .addChoices(imageChoice, msgChoice) + ) + .addStringOption((opt) => + opt + .setName('value') + .setDescription('URL to the welcome image, or the welcome text') + .setRequired(true) + ), + run: async (ctx) => { + ctx.config[fieldOf(ctx)] = ctx.interaction.options.getString('value', true); + await ctx.config.save(); - return get(cmd, config); -}; + await get(ctx); + }, + }, + { + name: 'get', + description: "Get's the welcome information for this server", + options: (sub) => + sub.addStringOption((opt) => + opt + .setName('content') + .setDescription('Get the welcome image, or text?') + .setRequired(true) + .addChoices(imageChoice, msgChoice) + ), + run: get, + }, + { + name: 'clear', + description: "Clear's the welcome information for this server", + options: (sub) => + sub.addStringOption((opt) => + opt + .setName('content') + .setDescription('Clear the welcome image, or text?') + .setRequired(true) + .addChoices(imageChoice, msgChoice) + ), + run: async (ctx) => { + ctx.config[fieldOf(ctx)] = null; + await ctx.config.save(); -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('welcome') - .setDescription('Control welcome information for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription("Set's the welcome information for this server") - .addStringOption((opt: SlashCommandStringOption) => - opt - .setName('content') - .setDescription('Set the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice) - ) - .addStringOption((opt: SlashCommandStringOption) => - opt - .setName('value') - .setDescription('URL to the welcome image, or the welcome text') - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('get') - .setDescription("Get's the welcome information for this server") - .addStringOption((opt: SlashCommandStringOption) => - opt - .setName('content') - .setDescription('Get the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('clear') - .setDescription("Clear's the welcome information for this server") - .addStringOption((opt: SlashCommandStringOption) => - opt - .setName('content') - .setDescription('Clear the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice) - ) - ); + await get(ctx); + }, + }, + ], +}); -export const WelcomeCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; +export default WelcomeGroup; diff --git a/src/slash-commands/Config/config.ts b/src/slash-commands/Config/config.ts index 167cd42e..59f55532 100644 --- a/src/slash-commands/Config/config.ts +++ b/src/slash-commands/Config/config.ts @@ -1,58 +1,48 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - PermissionFlagsBits, - InteractionContextType, -} from 'discord.js'; -import { ServerConfig } from '../../client/models/ServerConfig'; -import { SlashCommand, SlashSubCommand, SlashSubGroupCommand } from '../../types/SlashCommand'; -import { AnnouncementsCommand } from './Subcommands/announcements'; -import { AuditCommand } from './Subcommands/audit'; -import { BirthdaysConfigCommand } from './Subcommands/birthdays'; -import { DebugCommand } from './Subcommands/debug'; -import { HoneyPotCommand } from './Subcommands/honeypot'; -import { NewRolesCommand } from './Subcommands/newroles'; -import { SystemMessagesCommand } from './Subcommands/systemMsgs'; -import { WelcomeCommand } from './Subcommands/welcome'; +import { ChannelType } from 'discord.js'; +import { defineCommand } from '../../framework/command'; +import { booleanSetting, channelSetting } from '../../framework/settings'; +import BirthdaysGroup from './Subcommands/birthdays'; +import DebugGroup from './Subcommands/debug'; +import NewRolesGroup from './Subcommands/newroles'; +import WelcomeGroup from './Subcommands/welcome'; -const cmdMap: { [key: string]: SlashSubGroupCommand | SlashSubCommand } = { - announcements: AnnouncementsCommand, - debug: DebugCommand, - sysmsgs: SystemMessagesCommand, - audit: AuditCommand, - welcome: WelcomeCommand, - newroles: NewRolesCommand, - birthdays: BirthdaysConfigCommand, - honeypot: HoneyPotCommand, -}; +const textChannels = [ChannelType.GuildText, ChannelType.GuildAnnouncement] as const; -const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommandGroup() ?? cmd.options.getSubcommand(); - const subCmd = cmdMap[subCommand]; - - if (!subCommand) { - return cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); - } - - await subCmd.execute(cmd, config); -}; - -const commandData = new SlashCommandBuilder() - .setName('config') - .setDescription('Manage configuration data for this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]) - .addSubcommandGroup(AnnouncementsCommand.commandData) - .addSubcommandGroup(DebugCommand.commandData) - .addSubcommandGroup(SystemMessagesCommand.commandData) - .addSubcommandGroup(AuditCommand.commandData) - .addSubcommandGroup(WelcomeCommand.commandData) - .addSubcommandGroup(NewRolesCommand.commandData) - .addSubcommandGroup(BirthdaysConfigCommand.commandData) - .addSubcommandGroup(HoneyPotCommand.commandData); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; +export default defineCommand({ + name: 'config', + description: 'Manage configuration data for this server', + adminOnly: true, + groups: [ + channelSetting({ + name: 'announcements', + description: 'Configure the announcements channel for this server', + field: 'announcementsChannelId', + label: 'Announcements channel', + channelTypes: [...textChannels], + }), + DebugGroup, + booleanSetting({ + name: 'sysmsgs', + description: "Toggle bot-created welcome messages in the server's system channel", + field: 'systemMessagesEnabled', + label: 'Bot system messages', + }), + channelSetting({ + name: 'audit', + description: 'Configure the audit channel for this server', + field: 'auditChannelId', + label: 'Audit channel', + channelTypes: [...textChannels], + }), + WelcomeGroup, + NewRolesGroup, + BirthdaysGroup, + channelSetting({ + name: 'honeypot', + description: 'Configure the honey-pot channel for this server', + field: 'honeyPotChannelId', + label: 'Honey-pot channel', + channelTypes: [...textChannels], + }), + ], +}); diff --git a/src/slash-commands/Info/about.ts b/src/slash-commands/Info/about.ts index 02c1916a..5be236f4 100644 --- a/src/slash-commands/Info/about.ts +++ b/src/slash-commands/Info/about.ts @@ -1,42 +1,28 @@ -import { - ChatInputCommandInteraction, - InteractionContextType, - SlashCommandBuilder, -} from 'discord.js'; +import { defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; import * as packageJson from '../../../package.json'; -const execute = async (cmd: ChatInputCommandInteraction) => { - const version = packageJson.version; - const projectUrl = packageJson.repository; +export default defineCommand({ + name: 'about', + description: 'Returns info about the bot and server', + run: async (ctx) => { + const cmd = ctx.interaction; + const version = packageJson.version; + const projectUrl = packageJson.repository; - const embed = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: cmd.client.user.tag, - iconURL: cmd.client.user.displayAvatarURL(), - }) - .setDescription('Dingir Discord Bot') - .addField('Version', version.length > 0 ? version : 'Unknown') - .addField('Project URL', projectUrl.url) - .addField('Servers', cmd.client.guilds.cache.size.toString()) - .setTimestamp(); + const embed = new EmbedCompatLayer() + .setColor(EmbedColours.positive) + .setAuthor({ + name: cmd.client.user.tag, + iconURL: cmd.client.user.displayAvatarURL(), + }) + .setDescription('Dingir Discord Bot') + .addField('Version', version.length > 0 ? version : 'Unknown') + .addField('Project URL', projectUrl.url) + .addField('Servers', cmd.client.guilds.cache.size.toString()) + .setTimestamp(); - return cmd.reply({ - embeds: [embed], - ephemeral: true, - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('about') - .setDescription('Returns info about the bot and server') - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; + await ctx.reply({ embeds: [embed] }); + }, +}); diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index c4aa88de..1ca978e9 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -1,86 +1,74 @@ -import { - ChatInputCommandInteraction, - InteractionContextType, - SlashCommandBuilder, - SlashCommandNumberOption, -} from 'discord.js'; import { DateTime } from 'luxon'; -import { SlashCommand } from '../../types/SlashCommand'; +import { defineCommand } from '../../framework/command'; import { BirthdayManager } from '../../utilities/BirthdayManager'; import { UserProfileService } from '../../utilities/UserProfileService'; -import { requireGuild } from '../../utilities/requireGuild'; -const execute = async (cmd: ChatInputCommandInteraction) => { - let day = cmd.options.getNumber('day', true); - const month = cmd.options.getNumber('month', true); +export default defineCommand({ + name: 'mybirthday', + description: 'Set your birthday in this server', + options: (b) => + b + .addNumberOption((opt) => + opt + .setName('day') + .setDescription('Day of the month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(31) + ) + .addNumberOption((opt) => + opt + .setName('month') + .setDescription('Month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(12) + ), + run: async (ctx) => { + const cmd = ctx.interaction; + let day = cmd.options.getNumber('day', true); + const month = cmd.options.getNumber('month', true); - let alteredForLeap = false; + let alteredForLeap = false; - const now = DateTime.local(); + const now = DateTime.local(); - if (!now.isInLeapYear && day === 29 && month === 2) { - day--; - alteredForLeap = true; - } + if (!now.isInLeapYear && day === 29 && month === 2) { + day--; + alteredForLeap = true; + } - let nextDate = DateTime.local(now.year, month, day); + let nextDate = DateTime.local(now.year, month, day); - if (nextDate <= now) { - nextDate = nextDate.plus({ - year: 1, - }); - if (nextDate.isInLeapYear && alteredForLeap) { + if (nextDate <= now) { nextDate = nextDate.plus({ - day: 1, + year: 1, }); + if (nextDate.isInLeapYear && alteredForLeap) { + nextDate = nextDate.plus({ + day: 1, + }); + } } - } - - if (!nextDate.isValid) { - return cmd.reply({ - content: 'It looks like that date was invalid, make sure a valid day and month were given', - ephemeral: true, - }); - } - const guildId = requireGuild(cmd).id; - const userProfile = await UserProfileService.getUserProfile(guildId, cmd.user.id); - - userProfile.birthdayDay = day; - userProfile.birthdayMonth = month; + if (!nextDate.isValid) { + await ctx.reply( + 'It looks like that date was invalid, make sure a valid day and month were given' + ); + return; + } - await userProfile.save(); + const guildId = ctx.guild.id; + const userProfile = await UserProfileService.getUserProfile(guildId, cmd.user.id); - await cmd.reply({ - content: `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!`, - ephemeral: true, - }); - return BirthdayManager.populateCalendars(cmd.client, guildId); -}; + userProfile.birthdayDay = day; + userProfile.birthdayMonth = month; -const commandData = new SlashCommandBuilder() - .setName('mybirthday') - .setDescription('Set your birthday in this server') - .addNumberOption((opt: SlashCommandNumberOption) => - opt - .setName('day') - .setDescription('Day of the month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(31) - ) - .addNumberOption((opt: SlashCommandNumberOption) => - opt - .setName('month') - .setDescription('Month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(12) - ) - .setContexts([InteractionContextType.Guild]); + await userProfile.save(); -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; + await ctx.reply( + `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!` + ); + await BirthdayManager.populateCalendars(cmd.client, guildId); + }, +}); diff --git a/src/slash-commands/Info/ping.ts b/src/slash-commands/Info/ping.ts index 31454f00..80250284 100644 --- a/src/slash-commands/Info/ping.ts +++ b/src/slash-commands/Info/ping.ts @@ -1,24 +1,12 @@ -import { - ChatInputCommandInteraction, - InteractionContextType, - SlashCommandBuilder, -} from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; +import { defineCommand } from '../../framework/command'; -const execute = async (cmd: ChatInputCommandInteraction) => { - return cmd.reply({ - content: 'Pong!', - ephemeral: true, - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('ping') - .setDescription('Pings Dingir') - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; +export default defineCommand({ + name: 'ping', + description: 'Pings Dingir', + defer: 'ephemeral', + run: async (ctx) => { + const roundTrip = Date.now() - ctx.interaction.createdTimestamp; + const websocket = Math.round(ctx.interaction.client.ws.ping); + await ctx.reply(`Pong! WebSocket: ${websocket}ms, round-trip: ${roundTrip}ms`); + }, +}); diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index b7aa84b9..9efaa1ed 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -1,56 +1,40 @@ -import { - ChatInputCommandInteraction, - InteractionContextType, - PermissionFlagsBits, - SlashCommandBuilder, - SlashCommandUserOption, -} from 'discord.js'; +import { defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; import { UserProfileService } from '../../utilities/UserProfileService'; -import { requireGuild } from '../../utilities/requireGuild'; -const execute = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member', true); - const guild = requireGuild(cmd); - const member = guild.members.cache.get(user.id); - if (!member) { - return cmd.reply({ content: 'Could not find that member in this server.', ephemeral: true }); - } +export default defineCommand({ + name: 'profile', + description: 'Fetches profiles for server members', + adminOnly: true, + options: (b) => + b.addUserOption((opt) => + opt.setName('member').setDescription('Member to fetch profile for').setRequired(true) + ), + run: async (ctx) => { + const user = ctx.interaction.options.getUser('member', true); + const guild = ctx.guild; + const member = guild.members.cache.get(user.id); + if (!member) { + await ctx.reply('Could not find that member in this server.'); + return; + } - const userProfile = await UserProfileService.getUserProfile(guild.id, member.id); + const userProfile = await UserProfileService.getUserProfile(guild.id, member.id); - const embed = new EmbedCompatLayer() - .setThumbnail(member.displayAvatarURL()) - .setColor(EmbedColours.info) - .setTitle('User Profile') - .setTimestamp() - .addField('Member', member.toString()) - .addField('Nickname', member.nickname ? member.nickname : 'Not set') - .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) - .addField('Joined', ``) - .addField('Screening', member.pending ? 'Not completed' : 'Passed') - .addField('Activity Score', userProfile ? userProfile.activityScore.toString() : 'Not found') - .addField('ID', member.user.id); + const embed = new EmbedCompatLayer() + .setThumbnail(member.displayAvatarURL()) + .setColor(EmbedColours.info) + .setTitle('User Profile') + .setTimestamp() + .addField('Member', member.toString()) + .addField('Nickname', member.nickname ? member.nickname : 'Not set') + .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) + .addField('Joined', ``) + .addField('Screening', member.pending ? 'Not completed' : 'Passed') + .addField('Activity Score', userProfile ? userProfile.activityScore.toString() : 'Not found') + .addField('ID', member.user.id); - return cmd.reply({ - embeds: [embed], - ephemeral: true, - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('profile') - .setDescription('Fetches profiles for server members') - .addUserOption((opt: SlashCommandUserOption) => - opt.setName('member').setDescription('Member to fetch profile for').setRequired(true) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute, -}; -export = slashCommand; + await ctx.reply({ embeds: [embed] }); + }, +}); diff --git a/src/types/Command.ts b/src/types/Command.ts deleted file mode 100644 index 2cddd938..00000000 --- a/src/types/Command.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ChannelType, Message } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { NovaClient } from '../client/NovaClient'; - -export interface Command { - name: string; - run(client: NovaClient, message: Message, config: ServerConfig, args: any[]): Promise; - title: string; - description: string; - usage: string; - example: string; - admin: boolean; - deleteCmd: boolean; - limited: boolean; - limitation?: string; - channels: ChannelType[]; -} diff --git a/src/types/Event.ts b/src/types/Event.ts deleted file mode 100644 index 0a4f7f78..00000000 --- a/src/types/Event.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NovaClient } from '../client/NovaClient'; -export interface RunFunction { - (client: NovaClient, ...params: any[]): Promise; -} -export interface Event { - name: string; - run: RunFunction; -} diff --git a/src/types/SlashCommand.ts b/src/types/SlashCommand.ts deleted file mode 100644 index c26838e7..00000000 --- a/src/types/SlashCommand.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandsOnlyBuilder, -} from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export interface SlashCommand { - commandData: - SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder | SlashCommandOptionsOnlyBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise; -} - -export interface SlashSubGroupCommand { - commandData: SlashCommandSubcommandGroupBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise; -} - -export interface SlashSubCommand { - commandData: SlashCommandSubcommandBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise; -} diff --git a/src/utilities/CommandRegistrar.ts b/src/utilities/CommandRegistrar.ts index c84e2c0d..6548704d 100644 --- a/src/utilities/CommandRegistrar.ts +++ b/src/utilities/CommandRegistrar.ts @@ -1,20 +1,24 @@ import { env } from '../config/env'; import { REST, Routes } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; +import { Command } from '../framework/command'; import { Logger } from './Logger'; export class CommandRegistrar { - public static async registerGlobalCommands(client: NovaClient) { - Logger.writeLog('Registering Global Commands...'); - const cmdData = client.slashCommands.map((v) => v.commandData.toJSON()); + public static async register(commands: Command[]): Promise { + const guildId = env.devGuildId; + const scope = guildId ? `dev guild ${guildId}` : 'globally'; + Logger.info(`Registering ${commands.length} application commands ${scope}`); const rest = new REST({ version: '10' }).setToken(env.token); + const route = guildId + ? Routes.applicationGuildCommands(env.clientId, guildId) + : Routes.applicationCommands(env.clientId); - rest - .put(Routes.applicationCommands(env.clientId), { body: cmdData }) - .then((data: any) => - Logger.writeLog(`Successfully registered ${data.length} application commands.`) - ) - .catch(console.error); + try { + const data = (await rest.put(route, { body: commands.map((c) => c.toJSON()) })) as unknown[]; + Logger.info(`Registered ${data.length} application commands ${scope}`); + } catch (error) { + Logger.error('Failed to register application commands', { scope }, error); + } } } diff --git a/src/utilities/ConfigManager.ts b/src/utilities/ConfigManager.ts deleted file mode 100644 index cedabe37..00000000 --- a/src/utilities/ConfigManager.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ChannelType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export class ConfigManager { - public static async updateChannel( - serverConfig: ServerConfig, - interaction: ChatInputCommandInteraction, - field: string - ): Promise { - const chan = interaction.options.getChannel('channel'); - - let messageContent: string | undefined; - if (!chan && serverConfig[field]) { - serverConfig[field] = null; - } else if (!chan && !serverConfig[field]) { - messageContent = 'Please tag the channel.'; - } else if (!chan) { - messageContent = 'Channel not found, or I do not have permission to access it.'; - } else if (chan.type !== ChannelType.GuildText && chan.type !== ChannelType.GuildNews) { - messageContent = 'Channel must be a Text Channel.'; - } - - if (messageContent) { - return interaction.reply({ - content: messageContent, - ephemeral: true, - }); - } - - if (chan) { - serverConfig[field] = chan.id; - } - - await serverConfig.save(); - - if (serverConfig[field]) { - return interaction.reply({ - content: `Channel updated to ${chan?.toString()}.`, - ephemeral: true, - }); - } else { - return interaction.reply({ - content: 'Channel disabled.', - ephemeral: true, - }); - } - } - - public static async getChannel( - interaction: ChatInputCommandInteraction, - serverConfig: ServerConfig, - field: string - ) { - if (serverConfig[field]) { - return interaction.guild?.channels.cache.get(serverConfig[field]); - } - - return null; - } - - public static async updateChannelNew( - interaction: ChatInputCommandInteraction, - serverConfig: ServerConfig, - field: string - ) { - const chan = interaction.options.getChannel('channel'); - if (!chan) { - return interaction.reply('Provided channel is not valid'); - } - serverConfig[field] = chan.id; - await serverConfig.save(); - - return chan; - } - - public static async clearChannel(serverConfig: ServerConfig, field: string) { - serverConfig[field] = null; - await serverConfig.save(); - } -} diff --git a/src/utilities/requireGuild.ts b/src/utilities/requireGuild.ts deleted file mode 100644 index 71357941..00000000 --- a/src/utilities/requireGuild.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ChatInputCommandInteraction, Guild } from 'discord.js'; - -export const requireGuild = (cmd: ChatInputCommandInteraction): Guild => { - if (!cmd.guild) { - throw new Error(`Command ${cmd.commandName} was used outside of a guild.`); - } - return cmd.guild; -}; From 3b7b95d74487a430382a91924c0a7cad8df0c280 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:39:26 +0100 Subject: [PATCH 04/77] Add framework, dispatcher and loader tests Co-Authored-By: Claude Sonnet 5.5 --- src/framework/command.ts | 93 +++++++++--------- tests/command.test.ts | 187 +++++++++++++++++++++++++++++++++++++ tests/dispatcher.test.ts | 174 ++++++++++++++++++++++++++++++++++ tests/fakes/interaction.ts | 109 +++++++++++++++++++++ tests/loader.test.ts | 96 +++++++++++++++++++ tests/settings.test.ts | 157 +++++++++++++++++++++++++++++++ 6 files changed, 770 insertions(+), 46 deletions(-) create mode 100644 tests/command.test.ts create mode 100644 tests/dispatcher.test.ts create mode 100644 tests/fakes/interaction.ts create mode 100644 tests/loader.test.ts create mode 100644 tests/settings.test.ts diff --git a/src/framework/command.ts b/src/framework/command.ts index 398b81b1..b5bf9875 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -142,35 +142,6 @@ export function defineCommand(def: CommandDefinition): Command { ...(def.groups ?? []).map((g) => g.name), ]); - const routes = new Map(); - const routeKey = (group: string | null, sub: string | null): string => - `${group ?? ''}/${sub ?? ''}`; - - let builder = new SlashCommandBuilder() - .setName(def.name) - .setDescription(def.description) - .setContexts([InteractionContextType.Guild]); - - if (def.adminOnly) { - builder = builder.setDefaultMemberPermissions(PermissionFlagsBits.Administrator); - } - - if (def.run) { - if (def.options) { - builder = def.options(builder) as unknown as SlashCommandBuilder; - } - routes.set(routeKey(null, null), { run: def.run, defer: def.defer ?? false, path: def.name }); - } - - for (const sub of def.subcommands ?? []) { - builder.addSubcommand(subcommandBuilder(sub)); - routes.set(routeKey(null, sub.name), { - run: sub.run, - defer: sub.defer ?? false, - path: `${def.name} ${sub.name}`, - }); - } - for (const group of def.groups ?? []) { const groupWhere = `group "${group.name}" in ${where}`; if (group.subcommands.length === 0) { @@ -181,29 +152,59 @@ export function defineCommand(def: CommandDefinition): Command { groupWhere, group.subcommands.map((s) => s.name) ); - builder.addSubcommandGroup((g: SlashCommandSubcommandGroupBuilder) => { - g.setName(group.name).setDescription(group.description); - for (const sub of group.subcommands) { - g.addSubcommand(subcommandBuilder(sub)); - routes.set(routeKey(group.name, sub.name), { - run: sub.run, - defer: sub.defer ?? false, - path: `${def.name} ${group.name} ${sub.name}`, - }); - } - return g; - }); } - // Building the JSON now surfaces invalid names, descriptions and options at definition time + const routeKey = (group: string | null, sub: string | null): string => + `${group ?? ''}/${sub ?? ''}`; + const routes = new Map(); let json: RESTPostAPIChatInputApplicationCommandsJSONBody; try { + let builder = new SlashCommandBuilder() + .setName(def.name) + .setDescription(def.description) + .setContexts([InteractionContextType.Guild]); + + if (def.adminOnly) { + builder = builder.setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + } + + if (def.run) { + if (def.options) { + builder = def.options(builder) as unknown as SlashCommandBuilder; + } + routes.set(routeKey(null, null), { run: def.run, defer: def.defer ?? false, path: def.name }); + } + + for (const sub of def.subcommands ?? []) { + builder.addSubcommand(subcommandBuilder(sub)); + routes.set(routeKey(null, sub.name), { + run: sub.run, + defer: sub.defer ?? false, + path: `${def.name} ${sub.name}`, + }); + } + + for (const group of def.groups ?? []) { + builder.addSubcommandGroup((g: SlashCommandSubcommandGroupBuilder) => { + g.setName(group.name).setDescription(group.description); + for (const sub of group.subcommands) { + g.addSubcommand(subcommandBuilder(sub)); + routes.set(routeKey(group.name, sub.name), { + run: sub.run, + defer: sub.defer ?? false, + path: `${def.name} ${group.name} ${sub.name}`, + }); + } + return g; + }); + } + + // Building the JSON now surfaces invalid names, descriptions and options at definition time json = builder.toJSON(); } catch (error) { - throw new Error( - `Invalid ${where}: ${error instanceof Error ? error.message : String(error)}`, - { cause: error } - ); + throw new Error(`Invalid ${where}: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); } return { diff --git a/tests/command.test.ts b/tests/command.test.ts new file mode 100644 index 00000000..f7cb9acd --- /dev/null +++ b/tests/command.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { MessageFlags } from 'discord.js'; +import { + CommandContext, + defineCommand, + defineSubcommandGroup, + createReply, +} from '../src/framework/command'; +import { fakeInteraction } from './fakes/interaction'; + +const noop = async () => {}; + +test('defineCommand builds guild-only JSON and admin permissions', () => { + const json = defineCommand({ name: 'ping', description: 'Pings', run: noop }).toJSON(); + assert.equal(json.name, 'ping'); + assert.deepEqual(json.contexts, [0]); + assert.equal(json.default_member_permissions, undefined); + + const admin = defineCommand({ name: 'secret', description: 'Admin', adminOnly: true, run: noop }); + assert.equal(admin.toJSON().default_member_permissions, '8'); +}); + +test('defineCommand applies leaf options', () => { + const json = defineCommand({ + name: 'say', + description: 'Says', + options: (b) => b.addStringOption((o) => o.setName('text').setDescription('Text').setRequired(true)), + run: noop, + }).toJSON(); + assert.equal(json.options?.[0].name, 'text'); +}); + +test('defineCommand rejects invalid shapes with descriptive errors', () => { + assert.throws( + () => defineCommand({ name: 'a', description: 'd' }), + /command "a" must define either run or subcommands/ + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + run: noop, + subcommands: [{ name: 's', description: 'd', run: noop }], + }), + /cannot define both run and subcommands/ + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + defer: 'ephemeral', + subcommands: [{ name: 's', description: 'd', run: noop }], + }), + /not a leaf command/ + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + subcommands: [ + { name: 's', description: 'd', run: noop }, + { name: 's', description: 'd', run: noop }, + ], + }), + /Duplicate subcommand\/group name "s" in command "a"/ + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + subcommands: [{ name: 'x', description: 'd', run: noop }], + groups: [ + { + name: 'x', + description: 'd', + subcommands: [{ name: 's', description: 'd', run: noop }], + }, + ], + }), + /Duplicate subcommand\/group name "x"/ + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + groups: [ + { + name: 'g', + description: 'd', + subcommands: [ + { name: 's', description: 'd', run: noop }, + { name: 's', description: 'd', run: noop }, + ], + }, + ], + }), + /Duplicate subcommand name "s" in group "g"/ + ); + assert.throws( + () => defineCommand({ name: 'a', description: 'd', groups: [{ name: 'g', description: 'd', subcommands: [] }] }), + /group "g" in command "a" must define at least one subcommand/ + ); + assert.throws( + () => defineCommand({ name: 'Bad Name', description: 'd', run: noop }), + /Invalid command "Bad Name"/ + ); +}); + +test('resolve routes leaf, subcommand and grouped subcommand handlers', () => { + const ran: string[] = []; + const handler = (id: string) => async () => { + ran.push(id); + }; + + const leaf = defineCommand({ name: 'leaf', description: 'd', defer: 'public', run: handler('leaf') }); + const resolvedLeaf = leaf.resolve(fakeInteraction({ commandName: 'leaf' }).interaction)!; + assert.equal(resolvedLeaf.defer, 'public'); + assert.equal(resolvedLeaf.path, 'leaf'); + + const group = defineSubcommandGroup({ + name: 'grp', + description: 'd', + subcommands: [{ name: 'go', description: 'd', defer: 'ephemeral', run: handler('grp go') }], + }); + const tree = defineCommand({ + name: 'tree', + description: 'd', + subcommands: [{ name: 'plain', description: 'd', run: handler('tree plain') }], + groups: [group], + }); + + const plain = tree.resolve(fakeInteraction({ subcommand: 'plain' }).interaction)!; + assert.equal(plain.defer, false); + assert.equal(plain.path, 'tree plain'); + const grouped = tree.resolve(fakeInteraction({ group: 'grp', subcommand: 'go' }).interaction)!; + assert.equal(grouped.defer, 'ephemeral'); + assert.equal(grouped.path, 'tree grp go'); + + assert.equal(tree.resolve(fakeInteraction({ subcommand: 'missing' }).interaction), undefined); + assert.equal(tree.resolve(fakeInteraction({}).interaction), undefined); + assert.equal(tree.resolve(fakeInteraction({ group: 'grp', subcommand: 'plain' }).interaction), undefined); + assert.equal(leaf.resolve(fakeInteraction({ subcommand: 'x' }).interaction), undefined); + + return Promise.all([resolvedLeaf.run, plain.run, grouped.run].map((run) => run({} as CommandContext))).then(() => { + assert.deepEqual(ran, ['leaf', 'tree plain', 'grp go']); + }); +}); + +test('reply uses reply() when nothing has been sent, ephemeral by default', async () => { + const { interaction, calls } = fakeInteraction(); + await createReply(interaction)('hello'); + assert.deepEqual(calls, [ + { method: 'reply', payload: { content: 'hello', flags: MessageFlags.Ephemeral } }, + ]); +}); + +test('reply omits the ephemeral flag when ephemeral is false', async () => { + const { interaction, calls } = fakeInteraction(); + await createReply(interaction)({ content: 'public', ephemeral: false }); + assert.deepEqual(calls[0].payload, { content: 'public' }); +}); + +test('reply uses editReply() after deferring, then followUp() afterwards', async () => { + const { interaction, calls } = fakeInteraction({ deferred: true }); + const reply = createReply(interaction); + await reply('first'); + await reply('second'); + assert.deepEqual( + calls.map((c) => c.method), + ['editReply', 'followUp'] + ); + assert.equal(calls[0].payload.flags, undefined); + assert.equal(calls[1].payload.flags, MessageFlags.Ephemeral); +}); + +test('reply uses followUp() when already replied', async () => { + const { interaction, calls } = fakeInteraction({ replied: true }); + await createReply(interaction)({ content: 'more', allowedMentions: { parse: [] } }); + assert.equal(calls[0].method, 'followUp'); + assert.deepEqual(calls[0].payload.allowedMentions, { parse: [] }); +}); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts new file mode 100644 index 00000000..5954e4e7 --- /dev/null +++ b/tests/dispatcher.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, before, beforeEach, test } from 'node:test'; +import { Collection, MessageFlags } from 'discord.js'; +import type { Sequelize } from 'sequelize-typescript'; +import { createSequelize } from '../src/client/database/createSequelize'; +import type { NovaClient } from '../src/client/NovaClient'; +import { Command, defineCommand } from '../src/framework/command'; +import interactionCreate from '../src/events/interactionCreate'; +import { fakeInteraction } from './fakes/interaction'; + +let db: Sequelize; +const original = { log: console.log, warn: console.warn, error: console.error }; +let errors: string[]; + +before(async () => { + db = createSequelize(':memory:'); + await db.sync(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(() => { + errors = []; + console.log = () => {}; + console.warn = () => {}; + console.error = (msg: unknown) => { + errors.push(String(msg)); + }; +}); + +afterEach(() => { + Object.assign(console, original); +}); + +const clientWith = (...commands: Command[]) => + ({ + slashCommands: new Collection(commands.map((c) => [c.name, c])), + }) as unknown as NovaClient; + +const dispatch = (client: NovaClient, interaction: unknown) => + interactionCreate.run(client, interaction as never); + +test('ignores interactions that are not chat input commands', async () => { + const { interaction, calls } = fakeInteraction({ chatInput: false }); + await dispatch(clientWith(), interaction); + assert.equal(calls.length, 0); +}); + +test('replies ephemerally when used outside a cached guild', async () => { + const ran: string[] = []; + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + ran.push('run'); + }, + }); + const { interaction, calls } = fakeInteraction({ inGuild: false }); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual(calls, [ + { + method: 'reply', + payload: { content: 'Dingir only works in servers.', flags: MessageFlags.Ephemeral }, + }, + ]); + assert.deepEqual(ran, []); +}); + +test('replies "Unknown command." for unregistered commands and unresolvable subcommands', async () => { + const cmd = defineCommand({ + name: 'tree', + description: 'd', + subcommands: [{ name: 'a', description: 'd', run: async () => {} }], + }); + + const unknown = fakeInteraction({ commandName: 'nope' }); + await dispatch(clientWith(cmd), unknown.interaction); + assert.deepEqual(unknown.calls, [ + { method: 'reply', payload: { content: 'Unknown command.', flags: MessageFlags.Ephemeral } }, + ]); + + const badSub = fakeInteraction({ commandName: 'tree', subcommand: 'zzz' }); + await dispatch(clientWith(cmd), badSub.interaction); + assert.equal(badSub.calls.length, 1); + assert.equal(badSub.calls[0].payload.content, 'Unknown command.'); +}); + +test('runs the handler with context and applies the defer mode', async () => { + let seen: any; + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async (ctx) => { + seen = ctx; + await ctx.reply('done'); + }, + }); + const { interaction, calls } = fakeInteraction({ guildId: 'guild-9' }); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual( + calls.map((c) => c.method), + ['deferReply', 'editReply'] + ); + assert.deepEqual(calls[0].payload, { flags: MessageFlags.Ephemeral }); + assert.equal(seen.config.serverId, 'guild-9'); + assert.equal(seen.interaction, interaction); +}); + +test('public defer does not set the ephemeral flag', async () => { + const cmd = defineCommand({ name: 'test', description: 'd', defer: 'public', run: async () => {} }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + assert.deepEqual(calls, [{ method: 'deferReply', payload: {} }]); +}); + +test('a throwing handler produces exactly one ephemeral error reply', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + throw new Error('boom'); + }, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual(calls, [ + { + method: 'reply', + payload: { content: 'Something went wrong running this command.', flags: MessageFlags.Ephemeral }, + }, + ]); + assert.ok(errors.some((line) => line.includes('Command failed') && line.includes('command=test'))); +}); + +test('a handler that throws after replying results in a single follow-up error message', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async (ctx) => { + await ctx.reply('partial'); + throw new Error('late failure'); + }, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual( + calls.map((c) => c.method), + ['deferReply', 'editReply', 'followUp'] + ); +}); + +test('a failing error reply is logged and does not throw', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + throw new Error('boom'); + }, + }); + const { interaction } = fakeInteraction(); + (interaction as any).reply = async () => { + throw new Error('reply failed'); + }; + await dispatch(clientWith(cmd), interaction); + assert.ok(errors.some((line) => line.includes('Could not send command error reply'))); +}); diff --git a/tests/fakes/interaction.ts b/tests/fakes/interaction.ts new file mode 100644 index 00000000..ea8152ec --- /dev/null +++ b/tests/fakes/interaction.ts @@ -0,0 +1,109 @@ +import { Collection } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; + +export interface Call { + method: 'reply' | 'deferReply' | 'editReply' | 'followUp'; + payload: any; +} + +export interface FakeInteractionOptions { + commandName?: string; + group?: string | null; + subcommand?: string | null; + options?: Record; + chatInput?: boolean; + inGuild?: boolean; + deferred?: boolean; + replied?: boolean; + guildId?: string; + userId?: string; + guild?: Record; +} + +export interface FakeInteraction { + interaction: ChatInputCommandInteraction<'cached'>; + calls: Call[]; +} + +export function fakeGuild(channelIds: string[] = []) { + const cache = new Collection(channelIds.map((id) => [id, { id }])); + return { + id: 'guild-1', + name: 'Test Guild', + channels: { + cache, + fetch: async (id: string) => { + const channel = cache.get(id); + if (!channel) { + throw new Error('Unknown Channel'); + } + return channel; + }, + }, + }; +} + +export function fakeInteraction(opts: FakeInteractionOptions = {}): FakeInteraction { + const calls: Call[] = []; + const values = opts.options ?? {}; + const guildId = opts.guildId ?? 'guild-1'; + + const required = (name: string, req?: boolean): T | null => { + const value = (values[name] ?? null) as T | null; + if (req && value === null) { + throw new Error(`Required option "${name}" missing`); + } + return value; + }; + + const state = { + commandName: opts.commandName ?? 'test', + deferred: opts.deferred ?? false, + replied: opts.replied ?? false, + guildId, + guild: opts.guild ?? fakeGuild(), + member: { id: opts.userId ?? 'user-1' }, + user: { id: opts.userId ?? 'user-1' }, + createdTimestamp: Date.now(), + isChatInputCommand: () => opts.chatInput ?? true, + inCachedGuild: () => opts.inGuild ?? true, + options: { + getSubcommand: (req = true) => { + if (opts.subcommand == null && req) { + throw new Error('No subcommand'); + } + return opts.subcommand ?? null; + }, + getSubcommandGroup: (req = true) => { + if (opts.group == null && req) { + throw new Error('No subcommand group'); + } + return opts.group ?? null; + }, + getChannel: required, + getBoolean: required, + getString: required, + getUser: required, + getRole: required, + getNumber: required, + getAttachment: required, + }, + reply: async (payload: unknown) => { + calls.push({ method: 'reply', payload }); + state.replied = true; + }, + deferReply: async (payload: unknown) => { + calls.push({ method: 'deferReply', payload }); + state.deferred = true; + }, + editReply: async (payload: unknown) => { + calls.push({ method: 'editReply', payload }); + state.replied = true; + }, + followUp: async (payload: unknown) => { + calls.push({ method: 'followUp', payload }); + }, + }; + + return { interaction: state as unknown as ChatInputCommandInteraction<'cached'>, calls }; +} diff --git a/tests/loader.test.ts b/tests/loader.test.ts new file mode 100644 index 00000000..894ba592 --- /dev/null +++ b/tests/loader.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, test } from 'node:test'; +import { loadCommands, loadEvents } from '../src/framework/loader'; + +let root: string; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'dingir-loader-')); +}); + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); +}); + +const write = async (relative: string, contents: string) => { + const file = path.join(root, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, contents); + return file; +}; + +const commandModule = (name: string) => + `exports.__esModule = true;\nexports.default = { name: ${JSON.stringify(name)}, toJSON() { return {}; }, resolve() {} };\n`; +const eventModule = (name: string) => + `exports.__esModule = true;\nexports.default = { name: ${JSON.stringify(name)}, run: async () => {} };\n`; + +test('loadCommands reads the top level of each category and skips Subcommands, .d.ts and maps', async () => { + await write('Info/ping.js', commandModule('ping')); + await write('Info/about.js', commandModule('about')); + await write('Info/ping.js.map', '{}'); + await write('Info/types.d.ts', 'export {};'); + await write('Info/Subcommands/ignored.js', 'throw new Error("must not load");'); + await write('Config/Subcommands/nested.js', 'throw new Error("must not load");'); + await write('Config/config.js', commandModule('config')); + await write('Admin/deep/ignored.js', 'throw new Error("must not load");'); + + const commands = await loadCommands(root); + assert.deepEqual(commands.map((c) => c.name).sort(), ['about', 'config', 'ping']); +}); + +test('loadCommands rejects duplicate command names, naming both files', async () => { + const first = await write('A/one.js', commandModule('same')); + const second = await write('B/two.js', commandModule('same')); + await assert.rejects(loadCommands(root), (err: Error) => { + assert.ok(err.message.includes('Duplicate command "same"')); + assert.ok(err.message.includes(second)); + assert.ok(err.message.includes(first)); + return true; + }); +}); + +test('loadCommands rejects modules without a valid default export', async () => { + const file = await write('A/bad.js', 'exports.default = { name: "bad" };'); + await assert.rejects(loadCommands(root), (err: Error) => { + assert.ok(err.message.includes(file)); + assert.match(err.message, /export default a Command/); + return true; + }); +}); + +test('loadCommands rejects legacy named exports', async () => { + const file = await write('A/legacy.js', 'exports.commandData = {}; exports.execute = () => {};'); + await assert.rejects(loadCommands(root), (err: Error) => err.message.includes(file)); +}); + +test('loadCommands reports files that fail to import with their path', async () => { + const file = await write('A/broken.js', 'throw new Error("syntax boom");'); + await assert.rejects(loadCommands(root), (err: Error) => { + assert.ok(err.message.includes(file)); + assert.ok(err.message.includes('syntax boom')); + return true; + }); +}); + +test('loadEvents loads top-level event modules', async () => { + await write('ready.js', eventModule('clientReady')); + await write('messageCreate.js', eventModule('messageCreate')); + await write('helpers/ignored.js', 'throw new Error("must not load");'); + await write('messageCreate.d.ts', ''); + + const events = await loadEvents(root); + assert.deepEqual(events.map((e) => e.name).sort(), ['clientReady', 'messageCreate']); +}); + +test('loadEvents rejects duplicates and bad shapes', async () => { + await write('a.js', eventModule('messageCreate')); + await write('b.js', eventModule('messageCreate')); + await assert.rejects(loadEvents(root), /Duplicate event "messageCreate"/); + + await fs.rm(path.join(root, 'b.js')); + const bad = await write('c.js', 'exports.default = { name: "x", run: 5 };'); + await assert.rejects(loadEvents(root), (err: Error) => err.message.includes(bad)); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 00000000..2a4568f2 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import { ChannelType } from 'discord.js'; +import type { Sequelize } from 'sequelize-typescript'; +import { createSequelize } from '../src/client/database/createSequelize'; +import { ServerConfig } from '../src/client/models/ServerConfig'; +import { CommandContext, defineCommand, Handler } from '../src/framework/command'; +import { + booleanSetting, + booleanText, + channelClearText, + channelGetText, + channelSetText, + channelSetting, +} from '../src/framework/settings'; +import { fakeGuild, fakeInteraction } from './fakes/interaction'; + +let db: Sequelize; +let config: ServerConfig; +let events: string[]; + +before(async () => { + db = createSequelize(':memory:'); + await db.sync(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(async () => { + await ServerConfig.destroy({ where: {} }); + [config] = await ServerConfig.findOrCreate({ where: { serverId: 'guild-1' } }); + events = []; + const originalSave = config.save.bind(config); + config.save = (async (...args: Parameters) => { + const result = await originalSave(...args); + events.push('save'); + return result; + }) as typeof config.save; +}); + +const channelGroup = channelSetting({ + name: 'audit', + description: 'Audit channel', + field: 'auditChannelId', + label: 'Audit channel', + channelTypes: [ChannelType.GuildText, ChannelType.GuildAnnouncement], +}); +const boolGroup = booleanSetting({ + name: 'sysmsgs', + description: 'System messages', + field: 'systemMessagesEnabled', + label: 'Bot system messages', +}); + +const makeCtx = (options: Record, channelIds: string[] = []) => { + const { interaction } = fakeInteraction({ options }); + const guild = fakeGuild(channelIds); + const replies: string[] = []; + const ctx = { + interaction, + guild, + member: {}, + config, + reply: async (response: string | { content?: string }) => { + events.push('reply'); + replies.push(typeof response === 'string' ? response : (response.content ?? '')); + }, + } as unknown as CommandContext; + return { ctx, replies }; +}; + +const run = (group: typeof channelGroup, name: string): Handler => + group.subcommands.find((s) => s.name === name)!.run; + +test('pure helpers format responses', () => { + assert.equal(channelSetText('Audit channel', '1'), 'Audit channel set to <#1>.'); + assert.equal(channelGetText('Audit channel', '1', true), 'Audit channel: <#1>'); + assert.equal(channelGetText('Audit channel', null, false), 'Audit channel: not set'); + assert.match(channelGetText('Audit channel', '1', false), /no longer exists/); + assert.equal(channelClearText('Audit channel'), 'Audit channel cleared.'); + assert.equal(booleanText('Bot system messages', true), 'Bot system messages: enabled'); + assert.equal(booleanText('Bot system messages', false), 'Bot system messages: disabled'); +}); + +test('channelSetting set saves before replying and persists', async () => { + const { ctx, replies } = makeCtx({ channel: { id: '123' } }); + await run(channelGroup, 'set')(ctx); + + assert.deepEqual(events, ['save', 'reply']); + assert.deepEqual(replies, ['Audit channel set to <#123>.']); + await config.reload(); + assert.equal(config.auditChannelId, '123'); +}); + +test('channelSetting get reports set, missing and not-set channels', async () => { + const notSet = makeCtx({}, []); + await run(channelGroup, 'get')(notSet.ctx); + assert.deepEqual(notSet.replies, ['Audit channel: not set']); + + config.auditChannelId = '123'; + const present = makeCtx({}, ['123']); + await run(channelGroup, 'get')(present.ctx); + assert.deepEqual(present.replies, ['Audit channel: <#123>']); + + const missing = makeCtx({}, []); + await run(channelGroup, 'get')(missing.ctx); + assert.match(missing.replies[0], /no longer exists/); +}); + +test('channelSetting clear nulls the field, saves, then replies', async () => { + config.auditChannelId = '123'; + await config.save(); + events.length = 0; + + const { ctx, replies } = makeCtx({}); + await run(channelGroup, 'clear')(ctx); + + assert.deepEqual(events, ['save', 'reply']); + assert.deepEqual(replies, ['Audit channel cleared.']); + await config.reload(); + assert.equal(config.auditChannelId, null); +}); + +test('channelSetting restricts the channel option types', () => { + const json = defineCommand({ name: 'config', description: 'd', groups: [channelGroup] }).toJSON(); + const group: any = json.options?.[0]; + const set = group.options.find((o: any) => o.name === 'set'); + assert.deepEqual(set.options[0].channel_types, [ChannelType.GuildText, ChannelType.GuildAnnouncement]); + assert.equal(set.options[0].required, true); + assert.deepEqual( + group.options.map((o: any) => o.name), + ['set', 'get', 'clear'] + ); +}); + +test('booleanSetting set saves before replying; get reads the stored value', async () => { + const set = makeCtx({ enabled: true }); + await run(boolGroup, 'set')(set.ctx); + assert.deepEqual(events, ['save', 'reply']); + assert.deepEqual(set.replies, ['Bot system messages: enabled']); + await config.reload(); + assert.equal(config.systemMessagesEnabled, true); + + const get = makeCtx({}); + await run(boolGroup, 'get')(get.ctx); + assert.deepEqual(get.replies, ['Bot system messages: enabled']); +}); + +test('booleanSetting requires the enabled option', () => { + const json = defineCommand({ name: 'config', description: 'd', groups: [boolGroup] }).toJSON(); + const group: any = json.options?.[0]; + const set = group.options.find((o: any) => o.name === 'set'); + assert.equal(set.options[0].name, 'enabled'); + assert.equal(set.options[0].required, true); +}); From 27189f0d4de72b920ba8ac5bc066473ca7143549 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:35:59 +0100 Subject: [PATCH 05/77] Add audit, member, birthday, scheduler and retention services Co-Authored-By: Claude Sonnet 5.5 --- src/services/AuditEmbed.ts | 98 +++++++++++++++++++++ src/services/AuditService.ts | 29 +++++++ src/services/BirthdayDates.ts | 90 +++++++++++++++++++ src/services/MemberResolver.ts | 38 +++++++++ src/services/RetentionPolicy.ts | 39 +++++++++ src/services/Scheduler.ts | 77 +++++++++++++++++ src/types/EmbedCompatLayer.ts | 15 +--- src/utilities/ChannelService.ts | 18 +--- tests/services/AuditEmbed.test.ts | 107 +++++++++++++++++++++++ tests/services/AuditService.test.ts | 56 ++++++++++++ tests/services/BirthdayDates.test.ts | 114 +++++++++++++++++++++++++ tests/services/MemberResolver.test.ts | 54 ++++++++++++ tests/services/RetentionPolicy.test.ts | 62 ++++++++++++++ tests/services/Scheduler.test.ts | 102 ++++++++++++++++++++++ 14 files changed, 872 insertions(+), 27 deletions(-) create mode 100644 src/services/AuditEmbed.ts create mode 100644 src/services/AuditService.ts create mode 100644 src/services/BirthdayDates.ts create mode 100644 src/services/MemberResolver.ts create mode 100644 src/services/RetentionPolicy.ts create mode 100644 src/services/Scheduler.ts create mode 100644 tests/services/AuditEmbed.test.ts create mode 100644 tests/services/AuditService.test.ts create mode 100644 tests/services/BirthdayDates.test.ts create mode 100644 tests/services/MemberResolver.test.ts create mode 100644 tests/services/RetentionPolicy.test.ts create mode 100644 tests/services/Scheduler.test.ts diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts new file mode 100644 index 00000000..fd4baf0d --- /dev/null +++ b/src/services/AuditEmbed.ts @@ -0,0 +1,98 @@ +import { ColorResolvable, EmbedBuilder, GuildMember, User } from 'discord.js'; + +const LIMITS = { + title: 256, + description: 4096, + fieldName: 256, + fieldValue: 1024, + fields: 25, + total: 6000, +}; +const ELLIPSIS = '…'; +const EMPTY_VALUE = '*(empty)*'; +const NOTE_NAME = 'Truncated'; +const NOTE_VALUE = 'Some content was omitted to fit Discord limits.'; + +const truncate = (text: string, max: number): string => + text.length <= max ? text : `${text.slice(0, Math.max(0, max - ELLIPSIS.length))}${ELLIPSIS}`; + +export class AuditEmbed extends EmbedBuilder { + private cut = false; + private storedFields = 0; + + public static forMember( + member: GuildMember | User, + colour: ColorResolvable, + description: string + ): AuditEmbed { + const user = member instanceof User ? member : member.user; + const name = member.displayName || user.tag; + const embed = new AuditEmbed(); + embed + .setColor(colour) + .setAuthor({ name: truncate(name, LIMITS.fieldName), iconURL: member.displayAvatarURL() }) + .setDescription(description) + .setTimestamp(); + return embed; + } + + public setTitle(title: string | null): this { + return super.setTitle(title === null ? null : this.limit(title, LIMITS.title)); + } + + public setDescription(description: string | null): this { + return super.setDescription( + description === null ? null : this.limit(description, LIMITS.description) + ); + } + + public addField(name: string, value: string, inline = false): this { + if (this.storedFields >= LIMITS.fields) { + this.cut = true; + return this; + } + const safeName = this.limit(name.trim() === '' ? '​' : name, LIMITS.fieldName); + const safeValue = value.trim() === '' ? EMPTY_VALUE : this.limit(value, LIMITS.fieldValue); + this.addFields([{ name: safeName, value: safeValue, inline }]); + this.storedFields += 1; + return this; + } + + public toJSON(): ReturnType { + const data = super.toJSON(); + const fields = [...(data.fields ?? [])]; + const textLength = (): number => + (data.title?.length ?? 0) + + (data.description?.length ?? 0) + + (data.author?.name.length ?? 0) + + (data.footer?.text.length ?? 0); + const fieldsLength = (list: typeof fields): number => + list.reduce((sum, f) => sum + f.name.length + f.value.length, 0); + const noteLength = NOTE_NAME.length + NOTE_VALUE.length; + + let truncated = this.cut; + let kept = fields; + if (textLength() + fieldsLength(fields) > LIMITS.total) { + truncated = true; + } + if (truncated) { + kept = fields.slice(0, LIMITS.fields - 1); + while (kept.length > 0 && textLength() + fieldsLength(kept) + noteLength > LIMITS.total) { + kept.pop(); + } + const excess = textLength() + noteLength - LIMITS.total; + if (excess > 0 && data.description) { + data.description = truncate(data.description, data.description.length - excess); + } + kept.push({ name: NOTE_NAME, value: NOTE_VALUE, inline: false }); + } + return { ...data, fields: kept.length > 0 ? kept : undefined }; + } + + private limit(text: string, max: number): string { + if (text.length > max) { + this.cut = true; + } + return truncate(text, max); + } +} diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts new file mode 100644 index 00000000..de64360d --- /dev/null +++ b/src/services/AuditService.ts @@ -0,0 +1,29 @@ +import { Attachment, Client, EmbedBuilder } from 'discord.js'; +import { ServerConfig } from '../client/models/ServerConfig'; +import { Logger } from '../utilities/Logger'; + +export const sendAudit = async ( + client: Client, + config: ServerConfig, + embed: EmbedBuilder, + files?: Attachment[] +): Promise => { + const channelId = config.auditChannelId; + if (!channelId) { + return false; + } + const context = { guildId: config.serverId, channelId }; + + try { + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isSendable()) { + Logger.warn('Audit channel missing or not sendable', context); + return false; + } + await channel.send({ embeds: [embed], files: files?.length ? files : undefined }); + return true; + } catch (error) { + Logger.warn('Failed to send audit message', context, error); + return false; + } +}; diff --git a/src/services/BirthdayDates.ts b/src/services/BirthdayDates.ts new file mode 100644 index 00000000..3930472e --- /dev/null +++ b/src/services/BirthdayDates.ts @@ -0,0 +1,90 @@ +import { DateTime } from 'luxon'; + +export interface BirthdayProfile { + userId: string; + month: number; + day: number; +} + +export interface UpcomingGroup { + date: DateTime; + userIds: string[]; +} + +export const isValidBirthday = (month: number, day: number): boolean => + Number.isInteger(month) && + Number.isInteger(day) && + DateTime.fromObject({ year: 2024, month, day }, { zone: 'utc' }).isValid; + +export const celebrationDate = ( + month: number, + day: number, + year: number +): { year: number; month: number; day: number } => { + if (month === 2 && day === 29 && !DateTime.utc(year).isInLeapYear) { + return { year, month: 2, day: 28 }; + } + return { year, month, day }; +}; + +const startOfToday = (now: DateTime, zone: string): DateTime => now.setZone(zone).startOf('day'); + +export const nextOccurrence = ( + month: number, + day: number, + now: DateTime, + zone: string +): DateTime => { + const start = startOfToday(now, zone); + for (let year = start.year; year <= start.year + 8; year += 1) { + const candidate = DateTime.fromObject(celebrationDate(month, day, year), { zone }); + if (candidate >= start) { + return candidate; + } + } + throw new Error(`No occurrence found for ${month}/${day}`); +}; + +export const isBirthdayToday = ( + month: number, + day: number, + now: DateTime, + zone: string +): boolean => { + const start = startOfToday(now, zone); + const celebration = celebrationDate(month, day, start.year); + return start.month === celebration.month && start.day === celebration.day; +}; + +/** Groups share a date and are never split: the group that crosses `limit` is included in full. */ +export const upcoming = ( + profiles: readonly BirthdayProfile[], + now: DateTime, + zone: string, + limit = 10 +): UpcomingGroup[] => { + const byDate = new Map(); + for (const profile of profiles) { + if (!isValidBirthday(profile.month, profile.day)) { + continue; + } + const date = nextOccurrence(profile.month, profile.day, now, zone); + const key = date.toMillis(); + const group = byDate.get(key) ?? { date, userIds: [] }; + group.userIds.push(profile.userId); + byDate.set(key, group); + } + + const groups = [...byDate.values()].sort((a, b) => a.date.toMillis() - b.date.toMillis()); + const result: UpcomingGroup[] = []; + let total = 0; + for (const group of groups) { + if (total >= limit) { + break; + } + group.userIds.sort(); + result.push(group); + total += group.userIds.length; + } + return result; +}; diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts new file mode 100644 index 00000000..b412e81a --- /dev/null +++ b/src/services/MemberResolver.ts @@ -0,0 +1,38 @@ +import { Guild, GuildMember, GuildTextBasedChannel, User } from 'discord.js'; + +const UNKNOWN_MEMBER = 10007; +const UNKNOWN_USER = 10013; + +const errorCode = (error: unknown): unknown => + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + +export const resolveMember = async ( + guild: Guild, + userOrId: User | string +): Promise => { + const id = typeof userOrId === 'string' ? userOrId : userOrId.id; + const cached = guild.members.cache.get(id); + if (cached) { + return cached; + } + try { + return await guild.members.fetch(id); + } catch (error) { + const code = errorCode(error); + if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER) { + return null; + } + throw error; + } +}; + +export const resolveTextChannel = async ( + guild: Guild, + id: string +): Promise => { + const channel = guild.channels.cache.get(id) ?? (await guild.channels.fetch(id).catch(() => null)); + if (!channel || !channel.isTextBased() || channel.isDMBased() || !channel.isSendable()) { + return null; + } + return channel; +}; diff --git a/src/services/RetentionPolicy.ts b/src/services/RetentionPolicy.ts new file mode 100644 index 00000000..7195242d --- /dev/null +++ b/src/services/RetentionPolicy.ts @@ -0,0 +1,39 @@ +export type AccessOutcome = 'ok' | 'gone' | 'transient'; + +export interface AccessState { + accessFailureCount: number; + firstAccessFailureAt: Date | null; +} + +export interface RetentionPolicyConfig { + minFailures: number; + graceDays: number; +} + +const GONE_CODES = new Set([50001, 10004]); +const DAY_MS = 24 * 60 * 60 * 1000; + +export const classifyGuildFetchError = (err: unknown): 'gone' | 'transient' => { + const code = + typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined; + return typeof code === 'number' && GONE_CODES.has(code) ? 'gone' : 'transient'; +}; + +export const nextAccessState = ( + state: AccessState, + outcome: AccessOutcome, + now: Date, + policy: RetentionPolicyConfig +): AccessState & { purge: boolean } => { + if (outcome === 'ok') { + return { accessFailureCount: 0, firstAccessFailureAt: null, purge: false }; + } + if (outcome === 'transient') { + return { ...state, purge: false }; + } + const accessFailureCount = state.accessFailureCount + 1; + const firstAccessFailureAt = state.firstAccessFailureAt ?? now; + const elapsed = now.getTime() - firstAccessFailureAt.getTime(); + const purge = accessFailureCount >= policy.minFailures && elapsed >= policy.graceDays * DAY_MS; + return { accessFailureCount, firstAccessFailureAt, purge }; +}; diff --git a/src/services/Scheduler.ts b/src/services/Scheduler.ts new file mode 100644 index 00000000..7b5c6293 --- /dev/null +++ b/src/services/Scheduler.ts @@ -0,0 +1,77 @@ +import { scheduleJob } from 'node-schedule'; +import { Logger } from '../utilities/Logger'; + +export interface SchedulerTask { + name: string; + run: () => Promise; +} + +export interface ScheduledJobLike { + cancel(): unknown; + nextInvocation(): { toDate(): Date } | Date | null | undefined; +} + +export type ScheduleFn = ( + spec: { rule: string; tz: string }, + callback: () => void +) => ScheduledJobLike | null; + +const defaultScheduleFn: ScheduleFn = (spec, callback) => scheduleJob(spec, callback); + +export class Scheduler { + private job: ScheduledJobLike | null = null; + private running = false; + + public constructor( + private readonly cron: string, + private readonly timezone: string, + private readonly tasks: SchedulerTask[], + private readonly scheduleFn: ScheduleFn = defaultScheduleFn + ) {} + + public start(): void { + if (this.job) { + Logger.warn('Scheduler already started', { cron: this.cron }); + return; + } + const job = this.scheduleFn({ rule: this.cron, tz: this.timezone }, () => { + void this.runNow(); + }); + if (!job) { + throw new Error(`Invalid cron schedule "${this.cron}" (timezone ${this.timezone})`); + } + this.job = job; + } + + public stop(): void { + this.job?.cancel(); + this.job = null; + } + + public async runNow(): Promise { + if (this.running) { + Logger.warn('Scheduled run skipped; previous run still in progress'); + return; + } + this.running = true; + try { + for (const task of this.tasks) { + try { + await task.run(); + } catch (error) { + Logger.error('Scheduled task failed', { task: task.name }, error); + } + } + } finally { + this.running = false; + } + } + + public nextInvocation(): Date | null { + const next = this.job?.nextInvocation(); + if (!next) { + return null; + } + return next instanceof Date ? next : next.toDate(); + } +} diff --git a/src/types/EmbedCompatLayer.ts b/src/types/EmbedCompatLayer.ts index d9892da0..7a7426ba 100644 --- a/src/types/EmbedCompatLayer.ts +++ b/src/types/EmbedCompatLayer.ts @@ -1,14 +1,3 @@ -import { EmbedBuilder } from 'discord.js'; +import { AuditEmbed } from '../services/AuditEmbed'; -export class EmbedCompatLayer extends EmbedBuilder { - public addField(name: string, value: string): EmbedCompatLayer { - this.addFields([ - { - name: name, - value: value, - }, - ]); - - return this; - } -} +export class EmbedCompatLayer extends AuditEmbed {} diff --git a/src/utilities/ChannelService.ts b/src/utilities/ChannelService.ts index e25fdfd9..c6a7c0ae 100644 --- a/src/utilities/ChannelService.ts +++ b/src/utilities/ChannelService.ts @@ -1,25 +1,15 @@ import { Attachment, Client, EmbedBuilder } from 'discord.js'; import { ServerConfig } from '../client/models/ServerConfig'; import { NovaClient } from '../client/NovaClient'; +import { sendAudit } from '../services/AuditService'; + export class ChannelService { public static async sendAuditMessage( client: NovaClient | Client, serverConfig: ServerConfig, embed: EmbedBuilder, attachment?: Attachment - ): Promise { - if (!serverConfig.auditChannelId) { - return; - } - - const auditChannel = client.channels.cache.get(serverConfig.auditChannelId); - if (!auditChannel || !auditChannel.isSendable()) { - return; - } - - await auditChannel.send({ - embeds: [embed], - files: attachment ? [attachment] : undefined, - }); + ): Promise { + return sendAudit(client, serverConfig, embed, attachment ? [attachment] : undefined); } } diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts new file mode 100644 index 00000000..c137a679 --- /dev/null +++ b/tests/services/AuditEmbed.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { GuildMember } from 'discord.js'; +import { AuditEmbed } from '../../src/services/AuditEmbed'; +import { EmbedColours } from '../../src/resources/EmbedColours'; + +const total = (json: ReturnType): number => + (json.title?.length ?? 0) + + (json.description?.length ?? 0) + + (json.author?.name.length ?? 0) + + (json.footer?.text.length ?? 0) + + (json.fields ?? []).reduce((s, f) => s + f.name.length + f.value.length, 0); + +test('short fields pass through untouched', () => { + const json = new AuditEmbed().addField('Name', 'Value', true).toJSON(); + assert.deepEqual(json.fields, [{ name: 'Name', value: 'Value', inline: true }]); +}); + +test('1025-char value is truncated to 1024 with ellipsis and a note', () => { + const json = new AuditEmbed().addField('Big', 'x'.repeat(1025)).toJSON(); + const [field, note] = json.fields ?? []; + assert.equal(field.value.length, 1024); + assert.ok(field.value.endsWith('…')); + assert.equal(note.name, 'Truncated'); +}); + +test('empty and whitespace values render as *(empty)*', () => { + const json = new AuditEmbed().addField('A', '').addField('B', ' ').toJSON(); + assert.deepEqual( + json.fields?.map((f) => f.value), + ['*(empty)*', '*(empty)*'] + ); + assert.equal(json.fields?.length, 2); +}); + +test('long names truncate to 256', () => { + const json = new AuditEmbed().addField('n'.repeat(300), 'v').toJSON(); + assert.equal(json.fields?.[0].name.length, 256); +}); + +test('30 fields are capped at 25 including the truncation note', () => { + const embed = new AuditEmbed(); + for (let i = 0; i < 30; i += 1) { + embed.addField(`f${i}`, 'v'); + } + const json = embed.toJSON(); + assert.equal(json.fields?.length, 25); + assert.equal(json.fields?.[24].name, 'Truncated'); + assert.equal(json.fields?.[23].name, 'f23'); +}); + +test('exactly 25 fields need no note', () => { + const embed = new AuditEmbed(); + for (let i = 0; i < 25; i += 1) { + embed.addField(`f${i}`, 'v'); + } + const json = embed.toJSON(); + assert.equal(json.fields?.length, 25); + assert.notEqual(json.fields?.[24].name, 'Truncated'); +}); + +test('description over 4096 is truncated', () => { + const json = new AuditEmbed().setDescription('d'.repeat(5000)).toJSON(); + assert.equal(json.description?.length, 4096); + assert.equal(json.fields?.at(-1)?.name, 'Truncated'); +}); + +test('7000 total chars are trimmed to 6000 or less, dropping trailing fields', () => { + const embed = new AuditEmbed().setDescription('d'.repeat(1000)); + for (let i = 0; i < 7; i += 1) { + embed.addField(`f${i}`, 'v'.repeat(1000)); + } + const json = embed.toJSON(); + assert.ok(total(json) <= 6000, `total was ${total(json)}`); + assert.equal(json.fields?.at(-1)?.name, 'Truncated'); + assert.equal(json.fields?.[0].name, 'f0'); + assert.ok((json.fields?.length ?? 0) < 8); +}); + +test('oversized description plus fields still fits', () => { + const embed = new AuditEmbed().setDescription('d'.repeat(4096)); + embed.addField('a', 'v'.repeat(1024)).addField('b', 'v'.repeat(1024)).addField('c', 'v'.repeat(1024)); + assert.ok(total(embed.toJSON()) <= 6000); +}); + +test('toJSON does not mutate the builder', () => { + const embed = new AuditEmbed(); + for (let i = 0; i < 8; i += 1) { + embed.addField(`f${i}`, 'v'.repeat(1000)); + } + embed.toJSON(); + assert.equal(embed.data.fields?.length, 8); +}); + +test('forMember sets author, colour, description and timestamp', () => { + const member = { + displayName: 'Alice', + user: { tag: 'alice#0' }, + displayAvatarURL: () => 'https://cdn.example/a.png', + } as unknown as GuildMember; + const json = AuditEmbed.forMember(member, EmbedColours.info, 'hello').toJSON(); + assert.equal(json.author?.name, 'Alice'); + assert.equal(json.author?.icon_url, 'https://cdn.example/a.png'); + assert.equal(json.description, 'hello'); + assert.ok(json.timestamp); + assert.equal(typeof json.color, 'number'); +}); diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts new file mode 100644 index 00000000..f21779ef --- /dev/null +++ b/tests/services/AuditService.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Client, EmbedBuilder } from 'discord.js'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { sendAudit } from '../../src/services/AuditService'; + +console.warn = (): void => undefined; + +const config = (auditChannelId: string | null): ServerConfig => + ({ serverId: 'g1', auditChannelId }) as unknown as ServerConfig; +const clientWith = (fetch: () => Promise): Client => + ({ channels: { fetch } }) as unknown as Client; +const embed = new EmbedBuilder().setDescription('x'); + +test('returns false without fetching when no audit channel configured', async () => { + let fetched = false; + const client = clientWith(async () => { + fetched = true; + return null; + }); + assert.equal(await sendAudit(client, config(null), embed), false); + assert.equal(fetched, false); +}); + +test('returns false when fetch rejects', async () => { + const client = clientWith(async () => { + throw new Error('Missing Access'); + }); + assert.equal(await sendAudit(client, config('c1'), embed), false); +}); + +test('returns false when channel is missing or not sendable', async () => { + assert.equal(await sendAudit(clientWith(async () => null), config('c1'), embed), false); + const unsendable = clientWith(async () => ({ isSendable: () => false })); + assert.equal(await sendAudit(unsendable, config('c1'), embed), false); +}); + +test('returns false when send rejects', async () => { + const client = clientWith(async () => ({ + isSendable: () => true, + send: async () => { + throw new Error('Missing Permissions'); + }, + })); + assert.equal(await sendAudit(client, config('c1'), embed), false); +}); + +test('sends embed and files, returns true', async () => { + const sent: unknown[] = []; + const client = clientWith(async () => ({ + isSendable: () => true, + send: async (payload: unknown) => void sent.push(payload), + })); + assert.equal(await sendAudit(client, config('c1'), embed), true); + assert.deepEqual(sent, [{ embeds: [embed], files: undefined }]); +}); diff --git a/tests/services/BirthdayDates.test.ts b/tests/services/BirthdayDates.test.ts new file mode 100644 index 00000000..94090ac3 --- /dev/null +++ b/tests/services/BirthdayDates.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { DateTime } from 'luxon'; +import { + celebrationDate, + isBirthdayToday, + isValidBirthday, + nextOccurrence, + upcoming, +} from '../../src/services/BirthdayDates'; + +const LONDON = 'Europe/London'; +const at = (iso: string, zone = 'utc'): DateTime => DateTime.fromISO(iso, { zone }); +const ymd = (dt: DateTime): string => dt.toISODate() ?? ''; + +test('isValidBirthday', () => { + assert.equal(isValidBirthday(2, 29), true); + assert.equal(isValidBirthday(4, 31), false); + assert.equal(isValidBirthday(13, 1), false); + assert.equal(isValidBirthday(1, 0), false); + assert.equal(isValidBirthday(12, 31), true); +}); + +test('celebrationDate moves Feb 29 to Feb 28 in non-leap years', () => { + assert.deepEqual(celebrationDate(2, 29, 2026), { year: 2026, month: 2, day: 28 }); + assert.deepEqual(celebrationDate(2, 29, 2027), { year: 2027, month: 2, day: 28 }); + assert.deepEqual(celebrationDate(2, 29, 2028), { year: 2028, month: 2, day: 29 }); + assert.deepEqual(celebrationDate(3, 1, 2026), { year: 2026, month: 3, day: 1 }); +}); + +test('nextOccurrence: today counts as upcoming', () => { + const next = nextOccurrence(6, 15, at('2026-06-15T13:00:00'), 'utc'); + assert.equal(ymd(next), '2026-06-15'); + assert.equal(next.hour, 0); +}); + +test('nextOccurrence: passed date rolls to next year, Dec 31 to Jan 1', () => { + assert.equal(ymd(nextOccurrence(6, 14, at('2026-06-15T00:00:00'), 'utc')), '2027-06-14'); + assert.equal(ymd(nextOccurrence(1, 1, at('2026-12-31T23:00:00'), 'utc')), '2027-01-01'); + assert.equal(ymd(nextOccurrence(12, 31, at('2026-12-31T23:00:00'), 'utc')), '2026-12-31'); +}); + +test('nextOccurrence: Feb 29 across leap and non-leap years', () => { + assert.equal(ymd(nextOccurrence(2, 29, at('2026-01-10T12:00:00'), 'utc')), '2026-02-28'); + assert.equal(ymd(nextOccurrence(2, 29, at('2026-03-01T12:00:00'), 'utc')), '2027-02-28'); + assert.equal(ymd(nextOccurrence(2, 29, at('2027-03-01T12:00:00'), 'utc')), '2028-02-29'); + assert.equal(ymd(nextOccurrence(2, 29, at('2028-02-29T09:00:00'), 'utc')), '2028-02-29'); + assert.equal(ymd(nextOccurrence(2, 29, at('2028-03-01T09:00:00'), 'utc')), '2029-02-28'); +}); + +test('isBirthdayToday handles Feb 29 on Feb 28/29', () => { + assert.equal(isBirthdayToday(2, 29, at('2026-02-28T10:00:00'), 'utc'), true); + assert.equal(isBirthdayToday(2, 29, at('2027-02-28T10:00:00'), 'utc'), true); + assert.equal(isBirthdayToday(2, 29, at('2028-02-28T10:00:00'), 'utc'), false); + assert.equal(isBirthdayToday(2, 29, at('2028-02-29T10:00:00'), 'utc'), true); + assert.equal(isBirthdayToday(2, 29, at('2026-03-01T00:00:00'), 'utc'), false); +}); + +test('timezone: UTC date differs from Europe/London date (BST)', () => { + const now = at('2026-06-14T23:30:00Z'); + assert.equal(isBirthdayToday(6, 14, now, 'utc'), true); + assert.equal(isBirthdayToday(6, 14, now, LONDON), false); + assert.equal(isBirthdayToday(6, 15, now, LONDON), true); + assert.equal(ymd(nextOccurrence(6, 15, now, LONDON)), '2026-06-15'); + assert.equal(nextOccurrence(6, 15, now, LONDON).zoneName, LONDON); +}); + +test('timezone: Pacific/Auckland is ahead of UTC', () => { + const now = at('2026-06-14T20:00:00Z'); + assert.equal(isBirthdayToday(6, 15, now, 'Pacific/Auckland'), true); + assert.equal(isBirthdayToday(6, 15, now, 'utc'), false); + assert.equal(ymd(nextOccurrence(6, 14, now, 'Pacific/Auckland')), '2027-06-14'); +}); + +test('upcoming groups by date, sorts, and does not mutate input', () => { + const profiles = [ + { userId: 'c', month: 7, day: 1 }, + { userId: 'b', month: 6, day: 20 }, + { userId: 'a', month: 6, day: 20 }, + { userId: 'd', month: 1, day: 5 }, + { userId: 'bad', month: 4, day: 31 }, + ]; + const copy = structuredClone(profiles); + const result = upcoming(profiles, at('2026-06-15T12:00:00'), 'utc'); + assert.deepEqual( + result.map((g) => [ymd(g.date), g.userIds]), + [ + ['2026-06-20', ['a', 'b']], + ['2026-07-01', ['c']], + ['2027-01-05', ['d']], + ] + ); + assert.deepEqual(profiles, copy); +}); + +test('upcoming limit: stops after the group that reaches the limit, never splitting a date', () => { + const profiles = [ + { userId: 'a', month: 6, day: 16 }, + { userId: 'b', month: 6, day: 17 }, + { userId: 'c', month: 6, day: 17 }, + { userId: 'd', month: 6, day: 18 }, + ]; + const now = at('2026-06-15T12:00:00'); + assert.deepEqual( + upcoming(profiles, now, 'utc', 2).map((g) => g.userIds), + [['a'], ['b', 'c']] + ); + assert.deepEqual( + upcoming(profiles, now, 'utc', 3).map((g) => g.userIds), + [['a'], ['b', 'c']] + ); + assert.equal(upcoming(profiles, now, 'utc', 4).length, 3); + assert.deepEqual(upcoming([], now, 'utc'), []); +}); diff --git a/tests/services/MemberResolver.test.ts b/tests/services/MemberResolver.test.ts new file mode 100644 index 00000000..787f6664 --- /dev/null +++ b/tests/services/MemberResolver.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Guild, GuildMember } from 'discord.js'; +import { resolveMember, resolveTextChannel } from '../../src/services/MemberResolver'; + +const guildWith = (cached: Record, fetch: (id: string) => Promise): Guild => + ({ + members: { cache: new Map(Object.entries(cached)), fetch }, + channels: { cache: new Map(Object.entries(cached)), fetch }, + }) as unknown as Guild; + +const apiError = (code: number): Error => Object.assign(new Error(`code ${code}`), { code }); + +test('cache hit does not fetch', async () => { + const member = { id: 'u1' } as unknown as GuildMember; + const guild = guildWith({ u1: member }, async () => { + throw new Error('should not fetch'); + }); + assert.equal(await resolveMember(guild, 'u1'), member); + assert.equal(await resolveMember(guild, { id: 'u1' } as never), member); +}); + +test('cache miss fetches', async () => { + const member = { id: 'u2' }; + const guild = guildWith({}, async () => member); + assert.equal(await resolveMember(guild, 'u2'), member as unknown as GuildMember); +}); + +test('Unknown Member and Unknown User resolve to null', async () => { + for (const code of [10007, 10013]) { + const guild = guildWith({}, async () => { + throw apiError(code); + }); + assert.equal(await resolveMember(guild, 'u3'), null); + } +}); + +test('other errors are rethrown', async () => { + const guild = guildWith({}, async () => { + throw apiError(50013); + }); + await assert.rejects(resolveMember(guild, 'u3'), { code: 50013 }); +}); + +test('resolveTextChannel returns sendable guild text channels only', async () => { + const good = { isTextBased: () => true, isDMBased: () => false, isSendable: () => true }; + const voice = { isTextBased: () => false, isDMBased: () => false, isSendable: () => false }; + const guild = guildWith({ good, voice }, async () => { + throw apiError(10003); + }); + assert.equal(await resolveTextChannel(guild, 'good'), good as never); + assert.equal(await resolveTextChannel(guild, 'voice'), null); + assert.equal(await resolveTextChannel(guild, 'missing'), null); +}); diff --git a/tests/services/RetentionPolicy.test.ts b/tests/services/RetentionPolicy.test.ts new file mode 100644 index 00000000..c486dc13 --- /dev/null +++ b/tests/services/RetentionPolicy.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + AccessState, + classifyGuildFetchError, + nextAccessState, +} from '../../src/services/RetentionPolicy'; + +const policy = { minFailures: 3, graceDays: 7 }; +const day = (n: number): Date => new Date(Date.UTC(2026, 0, 1 + n)); +const fresh: AccessState = { accessFailureCount: 0, firstAccessFailureAt: null }; + +test('classifyGuildFetchError checks code structurally', () => { + assert.equal(classifyGuildFetchError({ code: 50001 }), 'gone'); + assert.equal(classifyGuildFetchError({ code: 10004 }), 'gone'); + assert.equal(classifyGuildFetchError({ code: 500 }), 'transient'); + assert.equal(classifyGuildFetchError(new Error('ECONNRESET')), 'transient'); + assert.equal(classifyGuildFetchError(null), 'transient'); + assert.equal(classifyGuildFetchError('50001'), 'transient'); +}); + +test('single failure sets first-failure and does not purge', () => { + const next = nextAccessState(fresh, 'gone', day(0), policy); + assert.deepEqual(next, { accessFailureCount: 1, firstAccessFailureAt: day(0), purge: false }); +}); + +test('enough failures but inside grace period does not purge', () => { + let state = fresh; + for (let i = 0; i < 5; i += 1) { + const next = nextAccessState(state, 'gone', day(i), policy); + assert.equal(next.purge, false); + state = next; + } + assert.equal(state.accessFailureCount, 5); +}); + +test('min failures and grace days both met purges', () => { + const state: AccessState = { accessFailureCount: 2, firstAccessFailureAt: day(0) }; + const next = nextAccessState(state, 'gone', day(7), policy); + assert.equal(next.accessFailureCount, 3); + assert.equal(next.purge, true); + assert.deepEqual(next.firstAccessFailureAt, day(0)); +}); + +test('grace days elapsed but too few failures does not purge', () => { + const state: AccessState = { accessFailureCount: 1, firstAccessFailureAt: day(0) }; + assert.equal(nextAccessState(state, 'gone', day(30), policy).purge, false); +}); + +test('ok resets state', () => { + const state: AccessState = { accessFailureCount: 9, firstAccessFailureAt: day(0) }; + assert.deepEqual(nextAccessState(state, 'ok', day(40), policy), { + accessFailureCount: 0, + firstAccessFailureAt: null, + purge: false, + }); +}); + +test('transient leaves state unchanged and never purges', () => { + const state: AccessState = { accessFailureCount: 9, firstAccessFailureAt: day(0) }; + assert.deepEqual(nextAccessState(state, 'transient', day(40), policy), { ...state, purge: false }); +}); diff --git a/tests/services/Scheduler.test.ts b/tests/services/Scheduler.test.ts new file mode 100644 index 00000000..b1199501 --- /dev/null +++ b/tests/services/Scheduler.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ScheduleFn, Scheduler } from '../../src/services/Scheduler'; + +const silence = (): void => { + console.warn = (): void => undefined; + console.error = (): void => undefined; +}; +silence(); + +const fakeSchedule = (): { fn: ScheduleFn; calls: unknown[]; cancelled: () => number; fire: () => void } => { + const calls: unknown[] = []; + let cancels = 0; + let cb: () => void = () => undefined; + const fn: ScheduleFn = (spec, callback) => { + calls.push(spec); + cb = callback; + return { cancel: () => (cancels += 1), nextInvocation: () => new Date(0) }; + }; + return { fn, calls, cancelled: () => cancels, fire: () => cb() }; +}; + +test('tasks run sequentially in order with error isolation', async () => { + const order: string[] = []; + const scheduler = new Scheduler('0 0 * * *', 'utc', [ + { name: 'a', run: async () => void order.push('a') }, + { + name: 'boom', + run: async () => { + order.push('boom'); + throw new Error('fail'); + }, + }, + { name: 'c', run: async () => void order.push('c') }, + ]); + await scheduler.runNow(); + assert.deepEqual(order, ['a', 'boom', 'c']); +}); + +test('overlapping runNow is skipped, then allowed again', async () => { + let runs = 0; + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const scheduler = new Scheduler('0 0 * * *', 'utc', [ + { + name: 'slow', + run: async () => { + runs += 1; + await gate; + }, + }, + ]); + const first = scheduler.runNow(); + await scheduler.runNow(); + assert.equal(runs, 1); + release(); + await first; + await scheduler.runNow(); + assert.equal(runs, 2); +}); + +test('start schedules with cron and tz; second start is a no-op', () => { + const fake = fakeSchedule(); + const scheduler = new Scheduler('0 9 * * *', 'Europe/London', [], fake.fn); + scheduler.start(); + scheduler.start(); + assert.deepEqual(fake.calls, [{ rule: '0 9 * * *', tz: 'Europe/London' }]); + assert.deepEqual(scheduler.nextInvocation(), new Date(0)); +}); + +test('fired job triggers tasks', async () => { + const fake = fakeSchedule(); + let ran = 0; + const scheduler = new Scheduler('* * * * *', 'utc', [{ name: 't', run: async () => void (ran += 1) }], fake.fn); + scheduler.start(); + fake.fire(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(ran, 1); +}); + +test('start throws when the schedule is invalid', () => { + const scheduler = new Scheduler('nope', 'utc', [], () => null); + assert.throws(() => scheduler.start(), /Invalid cron/); +}); + +test('stop cancels the job and clears nextInvocation', () => { + const fake = fakeSchedule(); + const scheduler = new Scheduler('* * * * *', 'utc', [], fake.fn); + scheduler.start(); + scheduler.stop(); + assert.equal(fake.cancelled(), 1); + assert.equal(scheduler.nextInvocation(), null); + scheduler.start(); + assert.equal(fake.calls.length, 2); +}); + +test('real node-schedule rejects an invalid cron string', () => { + const scheduler = new Scheduler('not a cron', 'utc', []); + assert.throws(() => scheduler.start()); +}); From 14054045487b844d8a4e70835f4bec4d3818cfbd Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:22 +0100 Subject: [PATCH 06/77] Fix post-merge audit return type and ignore .claude in lint Co-Authored-By: Claude Opus 5.5 --- eslint.config.mjs | 2 +- src/events/guildMemberUpdate.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index a998912e..7b35c874 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import tseslint from 'typescript-eslint'; export default defineConfig([ - globalIgnores(['dist/**', 'node_modules/**', 'package.json', '.github/**']), + globalIgnores(['dist/**', 'node_modules/**', '.claude/**', 'package.json', '.github/**']), js.configs.recommended, tseslint.configs.recommended, { diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 19846b67..d5a04d4e 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -195,7 +195,7 @@ export default defineEvent({ .setDescription('Unable to send welcome message.') .addField('ID', newMember.user.id) .setTimestamp(); - return ChannelService.sendAuditMessage(client, serverConfig, audit); + await ChannelService.sendAuditMessage(client, serverConfig, audit); } } } From ea486f785e32eb6a0a4d30c27076d4c87ec593aa Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:42:45 +0100 Subject: [PATCH 07/77] Add umzug migrations: baseline, UserProfile unique index, access tracking columns Replaces sequelize.sync({ alter: true }) at startup with versioned migrations; test helper runs the migrator on :memory: databases. Co-Authored-By: Claude Sonnet 5.5 --- Dockerfile | 1 + package-lock.json | 375 +++++++++++++++++- package.json | 3 +- src/client/NovaClient.ts | 4 +- .../database/migrations/001-baseline.ts | 63 +++ .../migrations/002-userprofile-unique.ts | 95 +++++ .../003-serverconfig-access-tracking.ts | 25 ++ src/client/database/migrations/index.ts | 16 + src/client/database/migrations/types.ts | 3 + src/client/database/migrator.ts | 18 + src/client/models/ServerConfig.ts | 23 +- src/client/models/UserProfile.ts | 4 +- tests/database/migrations.test.ts | 239 +++++++++++ tests/database/purgeGuild.test.ts | 56 +++ .../schema.test.ts} | 14 +- tests/dispatcher.test.ts | 5 +- tests/helpers/db.ts | 9 + tests/settings.test.ts | 5 +- 18 files changed, 907 insertions(+), 51 deletions(-) create mode 100644 src/client/database/migrations/001-baseline.ts create mode 100644 src/client/database/migrations/002-userprofile-unique.ts create mode 100644 src/client/database/migrations/003-serverconfig-access-tracking.ts create mode 100644 src/client/database/migrations/index.ts create mode 100644 src/client/database/migrations/types.ts create mode 100644 src/client/database/migrator.ts create mode 100644 tests/database/migrations.test.ts create mode 100644 tests/database/purgeGuild.test.ts rename tests/{database.test.ts => database/schema.test.ts} (59%) create mode 100644 tests/helpers/db.ts diff --git a/Dockerfile b/Dockerfile index 8c5d791f..4f07dda3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,5 @@ WORKDIR /usr/src/app COPY package*.json ./ RUN npm ci --omit=dev COPY --from=build /usr/src/app/dist dist +VOLUME /usr/src/app/data ENTRYPOINT ["npm", "start"] diff --git a/package-lock.json b/package-lock.json index 61d3a7b3..b3132549 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,14 +18,13 @@ "sequelize-typescript": "^2.1.6", "sqlite3": "^6.0.1", "tslib": "^2.8.1", - "underscore": "^1.13.8" + "umzug": "^3.8.3" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "@types/node-schedule": "^2.1.8", - "@types/underscore": "^1.13.0", "@types/validator": "^13.15.10", "copyfiles": "^2.4.1", "eslint": "^10.6.0", @@ -687,6 +686,91 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@rushstack/node-core-library": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", + "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", + "license": "MIT", + "dependencies": { + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "z-schema": "~5.0.2" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@rushstack/terminal": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", + "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "4.0.2", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", + "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.10.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -720,6 +804,12 @@ "npm": ">=7.0.0" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -782,13 +872,6 @@ "@types/node": "*" } }, - "node_modules/@types/underscore": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", - "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -1159,6 +1242,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1303,6 +1395,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1504,6 +1606,18 @@ "dev": true, "license": "MIT" }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1530,6 +1644,15 @@ "node": ">=6" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1803,7 +1926,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1887,12 +2009,35 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1983,8 +2128,28 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "optional": true + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/ieee754": { "version": "1.2.1", @@ -2016,6 +2181,15 @@ "node": ">= 4" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2058,6 +2232,21 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2105,6 +2294,12 @@ "dev": true, "license": "ISC" }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2126,6 +2321,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2172,6 +2376,20 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -2543,6 +2761,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -2583,7 +2807,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -2592,6 +2815,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pony-cause": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", + "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2726,6 +2958,27 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/retry-as-promised": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", @@ -2981,6 +3234,12 @@ "node": "*" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/sqlite3": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", @@ -3029,7 +3288,6 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6.19" @@ -3072,6 +3330,33 @@ "node": ">=0.10.0" } }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -3207,7 +3492,6 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3298,6 +3582,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3336,11 +3632,21 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" + "node_modules/umzug": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.8.3.tgz", + "integrity": "sha512-U9SRJI6LJvV0XwrqGMVPBkE26WHJklHZjtscJ2sEjUp7f+h4NH/25YGjPBernWLroVJvMnTkCAGC0bT0dd63qA==", + "license": "MIT", + "dependencies": { + "@rushstack/ts-command-line": "4.19.1", + "emittery": "^0.13.0", + "pony-cause": "^2.1.4", + "tinyglobby": "^0.2.16", + "type-fest": "^4.0.0" + }, + "engines": { + "node": ">=12" + } }, "node_modules/undici": { "version": "6.27.0", @@ -3357,6 +3663,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -3555,6 +3870,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } } } } diff --git a/package.json b/package.json index d5739359..16f0e99e 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,13 @@ "sequelize-typescript": "^2.1.6", "sqlite3": "^6.0.1", "tslib": "^2.8.1", - "underscore": "^1.13.8" + "umzug": "^3.8.3" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "@types/node-schedule": "^2.1.8", - "@types/underscore": "^1.13.0", "@types/validator": "^13.15.10", "copyfiles": "^2.4.1", "eslint": "^10.6.0", diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts index fae28240..1d4e06fe 100644 --- a/src/client/NovaClient.ts +++ b/src/client/NovaClient.ts @@ -6,6 +6,7 @@ import { bindEvent } from '../framework/event'; import { loadCommands, loadEvents } from '../framework/loader'; import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown'; import { Logger } from '../utilities/Logger'; +import { migrate } from './database/migrator'; import { sequelize } from './database/sequelize'; class NovaClient extends Client { @@ -25,7 +26,8 @@ class NovaClient extends Client { } public async start(): Promise { - await sequelize.sync({ alter: true }); + const applied = await migrate(sequelize); + Logger.info('Database migrations complete', { applied: applied.length ? applied : 'none' }); registerShutdownHook(() => sequelize.close()); const [commands, events] = await Promise.all([ diff --git a/src/client/database/migrations/001-baseline.ts b/src/client/database/migrations/001-baseline.ts new file mode 100644 index 00000000..70388650 --- /dev/null +++ b/src/client/database/migrations/001-baseline.ts @@ -0,0 +1,63 @@ +import { DataTypes, ModelAttributeColumnOptions, QueryInterface } from 'sequelize'; +import type { MigrationParams } from './types'; + +type Columns = Record; + +const serverConfigColumns: Columns = { + serverId: { type: DataTypes.STRING, primaryKey: true }, + prefix: { type: DataTypes.STRING, defaultValue: '^' }, + rulesMessagePath: { type: DataTypes.STRING }, + rulesMessage: { type: DataTypes.STRING }, + guestRoleIds: { type: DataTypes.STRING }, + adminRoleId: { type: DataTypes.STRING }, + welcomeMessage: { type: DataTypes.STRING }, + debug: { type: DataTypes.BOOLEAN, defaultValue: false }, + auditChannelId: { type: DataTypes.STRING }, + welcomeMessageBackgroundUrl: { type: DataTypes.STRING }, + systemMessagesEnabled: { type: DataTypes.BOOLEAN, defaultValue: false }, + announcementsChannelId: { type: DataTypes.STRING }, + birthdayCalendarMessagePath: { type: DataTypes.STRING }, + honeyPotChannelId: { type: DataTypes.STRING }, + createdAt: { type: DataTypes.DATE, allowNull: false }, + updatedAt: { type: DataTypes.DATE, allowNull: false }, +}; + +const userProfileColumns: Columns = { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + serverId: { type: DataTypes.STRING }, + userId: { type: DataTypes.STRING }, + birthdayYear: { type: DataTypes.INTEGER }, + birthdayMonth: { type: DataTypes.INTEGER }, + birthdayDay: { type: DataTypes.INTEGER }, + activityScore: { type: DataTypes.INTEGER, defaultValue: 0 }, + createdAt: { type: DataTypes.DATE, allowNull: false }, + updatedAt: { type: DataTypes.DATE, allowNull: false }, +}; + +async function ensureTable( + queryInterface: QueryInterface, + table: string, + columns: Columns +): Promise { + if (!(await queryInterface.tableExists(table))) { + await queryInterface.createTable(table, columns); + return; + } + + // Tables created by the old sync() may predate some columns. + const existing = await queryInterface.describeTable(table); + for (const [name, definition] of Object.entries(columns)) { + if (!(name in existing) && !definition.primaryKey && definition.allowNull !== false) { + await queryInterface.addColumn(table, name, definition); + } + } +} + +export async function up({ context }: MigrationParams): Promise { + await ensureTable(context, 'ServerConfigs', serverConfigColumns); + await ensureTable(context, 'UserProfiles', userProfileColumns); +} + +export async function down(): Promise { + // Intentionally a no-op: the baseline tables hold production data and are never dropped. +} diff --git a/src/client/database/migrations/002-userprofile-unique.ts b/src/client/database/migrations/002-userprofile-unique.ts new file mode 100644 index 00000000..25bfb280 --- /dev/null +++ b/src/client/database/migrations/002-userprofile-unique.ts @@ -0,0 +1,95 @@ +import { QueryTypes } from 'sequelize'; +import { Logger } from '../../../utilities/Logger'; +import type { MigrationParams } from './types'; + +const INDEX_NAME = 'user_profiles_server_user_unique'; + +interface ProfileRow { + id: number; + serverId: string; + userId: string; + birthdayYear: number | null; + birthdayMonth: number | null; + birthdayDay: number | null; + activityScore: number | null; + updatedAt: string | Date | null; +} + +const toTime = (value: string | Date | null): number => { + if (value instanceof Date) { + return value.getTime(); + } + const parsed = Date.parse(String(value ?? '').replace(' ', 'T').replace(/ ([+-])/, '$1')); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const hasBirthday = (row: ProfileRow): boolean => + row.birthdayMonth !== null && row.birthdayDay !== null; + +const birthdayKey = (row: ProfileRow): string => + `${row.birthdayYear ?? ''}-${row.birthdayMonth}-${row.birthdayDay}`; + +const newestFirst = (a: ProfileRow, b: ProfileRow): number => + toTime(b.updatedAt) - toTime(a.updatedAt) || b.id - a.id; + +export async function up({ context }: MigrationParams): Promise { + const sequelize = context.sequelize; + + await sequelize.transaction(async (transaction) => { + const rows = await sequelize.query( + 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL', + { type: QueryTypes.SELECT, transaction } + ); + + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.serverId, row.userId]); + const group = groups.get(key); + if (group) { + group.push(row); + } else { + groups.set(key, [row]); + } + } + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + + const withBirthday = group.filter(hasBirthday).sort(newestFirst); + const keep = withBirthday.length > 0 ? withBirthday[0] : [...group].sort(newestFirst)[0]; + + if (new Set(withBirthday.map(birthdayKey)).size > 1) { + Logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { + serverId: keep.serverId, + userId: keep.userId, + keptId: keep.id, + birthdays: withBirthday.map((row) => ({ id: row.id, birthday: birthdayKey(row) })), + }); + } + + const maxScore = Math.max(...group.map((row) => row.activityScore ?? 0)); + const removeIds = group.filter((row) => row.id !== keep.id).map((row) => row.id); + + await sequelize.query('UPDATE `UserProfiles` SET activityScore = :maxScore WHERE id = :id', { + replacements: { maxScore, id: keep.id }, + transaction, + }); + await sequelize.query('DELETE FROM `UserProfiles` WHERE id IN (:removeIds)', { + replacements: { removeIds }, + transaction, + }); + } + + await context.addIndex('UserProfiles', ['serverId', 'userId'], { + name: INDEX_NAME, + unique: true, + transaction, + }); + }); +} + +export async function down({ context }: MigrationParams): Promise { + await context.removeIndex('UserProfiles', INDEX_NAME); +} diff --git a/src/client/database/migrations/003-serverconfig-access-tracking.ts b/src/client/database/migrations/003-serverconfig-access-tracking.ts new file mode 100644 index 00000000..1c4a8f10 --- /dev/null +++ b/src/client/database/migrations/003-serverconfig-access-tracking.ts @@ -0,0 +1,25 @@ +import { DataTypes } from 'sequelize'; +import type { MigrationParams } from './types'; + +export async function up({ context }: MigrationParams): Promise { + const columns = await context.describeTable('ServerConfigs'); + + if (!('accessFailureCount' in columns)) { + await context.addColumn('ServerConfigs', 'accessFailureCount', { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }); + } + if (!('firstAccessFailureAt' in columns)) { + await context.addColumn('ServerConfigs', 'firstAccessFailureAt', { + type: DataTypes.DATE, + allowNull: true, + }); + } +} + +export async function down({ context }: MigrationParams): Promise { + await context.removeColumn('ServerConfigs', 'firstAccessFailureAt'); + await context.removeColumn('ServerConfigs', 'accessFailureCount'); +} diff --git a/src/client/database/migrations/index.ts b/src/client/database/migrations/index.ts new file mode 100644 index 00000000..167c8f97 --- /dev/null +++ b/src/client/database/migrations/index.ts @@ -0,0 +1,16 @@ +import * as baseline from './001-baseline'; +import * as userProfileUnique from './002-userprofile-unique'; +import * as serverConfigAccessTracking from './003-serverconfig-access-tracking'; +import type { MigrationParams } from './types'; + +export interface MigrationModule { + name: string; + up: (params: MigrationParams) => Promise; + down: (params: MigrationParams) => Promise; +} + +export const migrations: MigrationModule[] = [ + { name: '001-baseline', ...baseline }, + { name: '002-userprofile-unique', ...userProfileUnique }, + { name: '003-serverconfig-access-tracking', ...serverConfigAccessTracking }, +]; diff --git a/src/client/database/migrations/types.ts b/src/client/database/migrations/types.ts new file mode 100644 index 00000000..2145debd --- /dev/null +++ b/src/client/database/migrations/types.ts @@ -0,0 +1,3 @@ +import type { QueryInterface } from 'sequelize'; + +export type MigrationParams = { context: QueryInterface }; diff --git a/src/client/database/migrator.ts b/src/client/database/migrator.ts new file mode 100644 index 00000000..8f2161db --- /dev/null +++ b/src/client/database/migrator.ts @@ -0,0 +1,18 @@ +import { QueryInterface } from 'sequelize'; +import type { Sequelize } from 'sequelize-typescript'; +import { SequelizeStorage, Umzug } from 'umzug'; +import { migrations } from './migrations'; + +export function createMigrator(sequelize: Sequelize): Umzug { + return new Umzug({ + migrations, + context: sequelize.getQueryInterface(), + storage: new SequelizeStorage({ sequelize, tableName: 'SequelizeMeta' }), + logger: undefined, + }); +} + +export async function migrate(sequelize: Sequelize): Promise { + const applied = await createMigrator(sequelize).up(); + return applied.map((migration) => migration.name); +} diff --git a/src/client/models/ServerConfig.ts b/src/client/models/ServerConfig.ts index 8f17974d..708cde7b 100644 --- a/src/client/models/ServerConfig.ts +++ b/src/client/models/ServerConfig.ts @@ -1,4 +1,4 @@ -import { Table, Column, DataType, Model, PrimaryKey, Default } from 'sequelize-typescript'; +import { Table, Column, DataType, Model, PrimaryKey, Default, AllowNull } from 'sequelize-typescript'; @Table export class ServerConfig extends Model { @@ -6,22 +6,9 @@ export class ServerConfig extends Model { @Column declare serverId: string; - @Default('^') - @Column - declare prefix: string; - - @Column - declare rulesMessagePath: string; - - @Column - declare rulesMessage: string; - @Column(DataType.STRING) declare guestRoleIds: string | null; - @Column - declare adminRoleId: string; - @Column(DataType.STRING) declare welcomeMessage: string | null; @@ -47,4 +34,12 @@ export class ServerConfig extends Model { @Column(DataType.STRING) declare honeyPotChannelId: string | null; + + @Default(0) + @AllowNull(false) + @Column(DataType.INTEGER) + declare accessFailureCount: number; + + @Column(DataType.DATE) + declare firstAccessFailureAt: Date | null; } diff --git a/src/client/models/UserProfile.ts b/src/client/models/UserProfile.ts index ca54f777..0398055b 100644 --- a/src/client/models/UserProfile.ts +++ b/src/client/models/UserProfile.ts @@ -1,6 +1,8 @@ import { Table, Column, DataType, Model, Default } from 'sequelize-typescript'; -@Table +@Table({ + indexes: [{ name: 'user_profiles_server_user_unique', unique: true, fields: ['serverId', 'userId'] }], +}) export class UserProfile extends Model { @Column declare serverId: string; diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts new file mode 100644 index 00000000..c9a4fb34 --- /dev/null +++ b/tests/database/migrations.test.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { QueryTypes } from 'sequelize'; +import type { Sequelize } from 'sequelize-typescript'; +import { createSequelize } from '../../src/client/database/createSequelize'; +import { createMigrator, migrate } from '../../src/client/database/migrator'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import { Logger } from '../../src/utilities/Logger'; + +const INDEX = 'user_profiles_server_user_unique'; +const ALL = ['001-baseline', '002-userprofile-unique', '003-serverconfig-access-tracking']; + +const LEGACY_SERVER_CONFIGS = + "CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `prefix` VARCHAR(255) DEFAULT '^', `rulesMessagePath` VARCHAR(255), `rulesMessage` VARCHAR(255), `guestRoleIds` VARCHAR(255), `adminRoleId` VARCHAR(255), `welcomeMessage` VARCHAR(255), `debug` TINYINT(1) DEFAULT 0, `auditChannelId` VARCHAR(255), `welcomeMessageBackgroundUrl` VARCHAR(255), `systemMessagesEnabled` TINYINT(1) DEFAULT 0, `announcementsChannelId` VARCHAR(255), `birthdayCalendarMessagePath` VARCHAR(255), `honeyPotChannelId` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)"; +const LEGACY_USER_PROFILES = + 'CREATE TABLE `UserProfiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serverId` VARCHAR(255), `userId` VARCHAR(255), `birthdayYear` INTEGER, `birthdayMonth` INTEGER, `birthdayDay` INTEGER, `activityScore` INTEGER DEFAULT 0, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)'; + +const withDb = async (fn: (db: Sequelize) => Promise): Promise => { + const db = createSequelize(':memory:'); + try { + await fn(db); + } finally { + await db.close(); + } +}; + +const columnsOf = async (db: Sequelize, table: string): Promise => + Object.keys(await db.getQueryInterface().describeTable(table)); + +const profileRow = ( + serverId: string, + userId: string, + month: number | null, + day: number | null, + score: number, + updatedAt: string +) => ({ serverId, userId, month, day, score, updatedAt }); + +const insertProfiles = async ( + db: Sequelize, + rows: ReturnType[] +): Promise => { + for (const r of rows) { + await db.query( + 'INSERT INTO `UserProfiles` (serverId, userId, birthdayMonth, birthdayDay, activityScore, createdAt, updatedAt) VALUES (:serverId, :userId, :month, :day, :score, :updatedAt, :updatedAt)', + { replacements: r } + ); + } +}; + +const runUpTo = async (db: Sequelize, to: string): Promise => { + await createMigrator(db).up({ to }); +}; + +test('migrations on an empty database create the expected schema', async () => { + await withDb(async (db) => { + assert.deepEqual(await migrate(db), ALL); + + const serverColumns = await columnsOf(db, 'ServerConfigs'); + for (const column of [ + 'serverId', + 'prefix', + 'adminRoleId', + 'guestRoleIds', + 'honeyPotChannelId', + 'accessFailureCount', + 'firstAccessFailureAt', + 'createdAt', + 'updatedAt', + ]) { + assert.ok(serverColumns.includes(column), `ServerConfigs.${column}`); + } + assert.deepEqual(await columnsOf(db, 'UserProfiles'), [ + 'id', + 'serverId', + 'userId', + 'birthdayYear', + 'birthdayMonth', + 'birthdayDay', + 'activityScore', + 'createdAt', + 'updatedAt', + ]); + + const indexes = await db.getQueryInterface().showIndex('UserProfiles'); + const unique = (indexes as { name: string; unique: boolean }[]).find((i) => i.name === INDEX); + assert.ok(unique?.unique); + + const meta = await db.query<{ name: string }>('SELECT name FROM `SequelizeMeta` ORDER BY name', { + type: QueryTypes.SELECT, + }); + assert.deepEqual( + meta.map((m) => m.name), + ALL + ); + }); +}); + +test('running migrations twice is a no-op', async () => { + await withDb(async (db) => { + await migrate(db); + await UserProfile.create({ serverId: 's1', userId: 'u1', activityScore: 4 }); + assert.deepEqual(await migrate(db), []); + assert.equal(await UserProfile.count(), 1); + }); +}); + +test('baseline is a no-op on a legacy sync() database and later migrations preserve data', async () => { + await withDb(async (db) => { + await db.query(LEGACY_SERVER_CONFIGS); + await db.query(LEGACY_USER_PROFILES); + await db.query( + "INSERT INTO `ServerConfigs` (serverId, prefix, adminRoleId, auditChannelId, createdAt, updatedAt) VALUES ('s1', '!', 'admin', 'chan', '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')" + ); + await insertProfiles(db, [ + profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'u2', null, null, 9, '2024-01-01 00:00:00.000 +00:00'), + ]); + const before = await db.query('SELECT * FROM `UserProfiles` ORDER BY id', { + type: QueryTypes.SELECT, + }); + + await runUpTo(db, '001-baseline'); + assert.deepEqual( + await db.query('SELECT * FROM `UserProfiles` ORDER BY id', { type: QueryTypes.SELECT }), + before + ); + assert.ok(!(await columnsOf(db, 'ServerConfigs')).includes('accessFailureCount')); + + assert.deepEqual(await migrate(db), ALL.slice(1)); + + const config = await ServerConfig.findByPk('s1'); + assert.equal(config?.auditChannelId, 'chan'); + assert.equal(config?.accessFailureCount, 0); + assert.equal(config?.firstAccessFailureAt, null); + const legacy = await db.query<{ prefix: string; adminRoleId: string }>( + 'SELECT prefix, adminRoleId FROM `ServerConfigs`', + { type: QueryTypes.SELECT } + ); + assert.deepEqual(legacy, [{ prefix: '!', adminRoleId: 'admin' }]); + + const profiles = await UserProfile.findAll({ order: [['id', 'ASC']] }); + assert.deepEqual( + profiles.map((p) => [p.userId, p.birthdayMonth, p.activityScore]), + [ + ['u1', 5, 3], + ['u2', null, 9], + ] + ); + }); +}); + +test('baseline adds missing nullable columns to an older schema', async () => { + await withDb(async (db) => { + await db.query( + 'CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)' + ); + await migrate(db); + assert.ok((await columnsOf(db, 'ServerConfigs')).includes('honeyPotChannelId')); + assert.ok((await columnsOf(db, 'UserProfiles')).includes('activityScore')); + }); +}); + +test('002 dedupes UserProfiles and enforces uniqueness afterwards', async () => { + await withDb(async (db) => { + await runUpTo(db, '001-baseline'); + await insertProfiles(db, [ + // a: only one has a birthday; it is older and lower score + profileRow('s1', 'a', null, null, 10, '2024-03-01 00:00:00.000 +00:00'), + profileRow('s1', 'a', 2, 14, 1, '2024-01-01 00:00:00.000 +00:00'), + // b: conflicting birthdays; most recent wins + profileRow('s1', 'b', 1, 1, 5, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'b', 7, 7, 2, '2024-02-01 00:00:00.000 +00:00'), + profileRow('s1', 'b', null, null, 8, '2024-04-01 00:00:00.000 +00:00'), + // c: no birthdays; most recent kept, max score + profileRow('s1', 'c', null, null, 7, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'c', null, null, 3, '2024-02-01 00:00:00.000 +00:00'), + // same user on another server and a unique row are untouched + profileRow('s2', 'a', 3, 3, 1, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'd', 4, 4, 0, '2024-01-01 00:00:00.000 +00:00'), + ]); + + const warnings: string[] = []; + const originalWarn = Logger.warn; + Logger.warn = (message: string) => { + warnings.push(message); + }; + try { + await runUpTo(db, '002-userprofile-unique'); + } finally { + Logger.warn = originalWarn; + } + + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Conflicting birthdays/); + + const rows = await UserProfile.findAll({ order: [['serverId', 'ASC'], ['userId', 'ASC']] }); + assert.deepEqual( + rows.map((r) => [r.serverId, r.userId, r.birthdayMonth, r.birthdayDay, r.activityScore]), + [ + ['s1', 'a', 2, 14, 10], + ['s1', 'b', 7, 7, 8], + ['s1', 'c', null, null, 7], + ['s1', 'd', 4, 4, 0], + ['s2', 'a', 3, 3, 1], + ] + ); + + await assert.rejects(UserProfile.create({ serverId: 's1', userId: 'a' })); + }); +}); + +test('002 rolls back entirely when it fails', async () => { + await withDb(async (db) => { + await runUpTo(db, '001-baseline'); + await insertProfiles(db, [ + profileRow('s1', 'a', null, null, 1, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'a', null, null, 5, '2024-02-01 00:00:00.000 +00:00'), + ]); + await db.query('CREATE INDEX `user_profiles_server_user_unique` ON `UserProfiles` (serverId)'); + + await assert.rejects(runUpTo(db, '002-userprofile-unique')); + assert.equal(await UserProfile.count(), 2); + }); +}); + +test('down migrations remove the added index and columns', async () => { + await withDb(async (db) => { + await migrate(db); + await createMigrator(db).down({ to: '001-baseline' }); + + const columns = await columnsOf(db, 'ServerConfigs'); + assert.ok(!columns.includes('accessFailureCount')); + assert.ok(columns.includes('serverId')); + const indexes = (await db.getQueryInterface().showIndex('UserProfiles')) as { name: string }[]; + assert.ok(!indexes.some((i) => i.name === INDEX)); + }); +}); diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts new file mode 100644 index 00000000..46b3fea8 --- /dev/null +++ b/tests/database/purgeGuild.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import type { Sequelize } from 'sequelize-typescript'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import { ConfigService } from '../../src/utilities/ConfigService'; +import { createTestDb } from '../helpers/db'; + +let db: Sequelize; + +before(async () => { + db = await createTestDb(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(async () => { + await UserProfile.destroy({ where: {} }); + await ServerConfig.destroy({ where: {} }); + await ServerConfig.bulkCreate([{ serverId: 's1' }, { serverId: 's2' }]); + await UserProfile.bulkCreate([ + { serverId: 's1', userId: 'a' }, + { serverId: 's1', userId: 'b' }, + { serverId: 's2', userId: 'a' }, + ]); +}); + +test('purgeGuild deletes only the target server and reports counts', async () => { + assert.deepEqual(await ConfigService.purgeGuild('s1'), { config: true, profiles: 2 }); + + assert.equal(await ServerConfig.findByPk('s1'), null); + assert.ok(await ServerConfig.findByPk('s2')); + assert.equal(await UserProfile.count({ where: { serverId: 's1' } }), 0); + assert.equal(await UserProfile.count({ where: { serverId: 's2' } }), 1); +}); + +test('purgeGuild on an unknown server deletes nothing', async () => { + assert.deepEqual(await ConfigService.purgeGuild('nope', db), { config: false, profiles: 0 }); + assert.equal(await ServerConfig.count(), 2); + assert.equal(await UserProfile.count(), 3); +}); + +test('purgeGuild rolls back the profile delete when the config delete fails', async () => { + const original = ServerConfig.destroy; + ServerConfig.destroy = (() => Promise.reject(new Error('boom'))) as typeof ServerConfig.destroy; + try { + await assert.rejects(ConfigService.purgeGuild('s1'), /boom/); + } finally { + ServerConfig.destroy = original; + } + + assert.equal(await UserProfile.count({ where: { serverId: 's1' } }), 2); + assert.ok(await ServerConfig.findByPk('s1')); +}); diff --git a/tests/database.test.ts b/tests/database/schema.test.ts similarity index 59% rename from tests/database.test.ts rename to tests/database/schema.test.ts index 7a6f5ce4..692bd8ee 100644 --- a/tests/database.test.ts +++ b/tests/database/schema.test.ts @@ -1,18 +1,18 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { createSequelize } from '../src/client/database/createSequelize'; -import { ServerConfig } from '../src/client/models/ServerConfig'; -import { UserProfile } from '../src/client/models/UserProfile'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import { createTestDb } from '../helpers/db'; -test('createSequelize(":memory:") syncs both models', async () => { - const db = createSequelize(':memory:'); +test('migrated :memory: database works with both models', async () => { + const db = await createTestDb(); try { - await db.sync(); - const [config] = await ServerConfig.findOrCreate({ where: { serverId: 's1' } }); await config.reload(); assert.equal(config.systemMessagesEnabled, false); assert.equal(config.auditChannelId, null); + assert.equal(config.accessFailureCount, 0); + assert.equal(config.firstAccessFailureAt, null); const profile = await UserProfile.create({ serverId: 's1', userId: 'u1' }); assert.equal(profile.activityScore, 0); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index 5954e4e7..aebf4210 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; import type { Sequelize } from 'sequelize-typescript'; -import { createSequelize } from '../src/client/database/createSequelize'; +import { createTestDb } from './helpers/db'; import type { NovaClient } from '../src/client/NovaClient'; import { Command, defineCommand } from '../src/framework/command'; import interactionCreate from '../src/events/interactionCreate'; @@ -13,8 +13,7 @@ const original = { log: console.log, warn: console.warn, error: console.error }; let errors: string[]; before(async () => { - db = createSequelize(':memory:'); - await db.sync(); + db = await createTestDb(); }); after(async () => { diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 00000000..9ae34141 --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,9 @@ +import type { Sequelize } from 'sequelize-typescript'; +import { createSequelize } from '../../src/client/database/createSequelize'; +import { migrate } from '../../src/client/database/migrator'; + +export async function createTestDb(): Promise { + const db = createSequelize(':memory:'); + await migrate(db); + return db; +} diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 2a4568f2..c879222e 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { ChannelType } from 'discord.js'; import type { Sequelize } from 'sequelize-typescript'; -import { createSequelize } from '../src/client/database/createSequelize'; +import { createTestDb } from './helpers/db'; import { ServerConfig } from '../src/client/models/ServerConfig'; import { CommandContext, defineCommand, Handler } from '../src/framework/command'; import { @@ -20,8 +20,7 @@ let config: ServerConfig; let events: string[]; before(async () => { - db = createSequelize(':memory:'); - await db.sync(); + db = await createTestDb(); }); after(async () => { From 402a0cb2e7f1dd08b11d265b2325aef0e517fa64 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:42:45 +0100 Subject: [PATCH 08/77] Add transactional guild purge, trim services, drop underscore Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildDelete.ts | 8 +++---- src/utilities/BirthdayManager.ts | 23 +++++++++++-------- src/utilities/ConfigService.ts | 35 +++++++++++++++-------------- src/utilities/DataCheck.ts | 3 +-- src/utilities/UserProfileService.ts | 34 ++++++---------------------- 5 files changed, 43 insertions(+), 60 deletions(-) diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 9f49ea33..456da29a 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,16 +1,14 @@ import { defineEvent } from '../framework/event'; import { ConfigService } from '../utilities/ConfigService'; import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; export default defineEvent({ name: 'guildDelete', run: async (client, guild) => { - const configDeleted = await ConfigService.deleteConfig(guild.id); - const userProfilesDeleted = await UserProfileService.deleteUsersByServer(guild.id); + const result = await ConfigService.purgeGuild(guild.id); Logger.writeLog(`Bot removed from guild: ${guild.name} (${guild.id}).`); - Logger.writeLog(`Config deleted: ${configDeleted}.`); - Logger.writeLog(`User profiles deleted: ${userProfilesDeleted}.`); + Logger.writeLog(`Config deleted: ${result.config}.`); + Logger.writeLog(`User profiles deleted: ${result.profiles}.`); }, }); diff --git a/src/utilities/BirthdayManager.ts b/src/utilities/BirthdayManager.ts index 6539d2d4..b66bea68 100644 --- a/src/utilities/BirthdayManager.ts +++ b/src/utilities/BirthdayManager.ts @@ -1,7 +1,6 @@ import { NovaClient } from '../client/NovaClient'; import { Client, Guild, GuildMember, TextChannel } from 'discord.js'; import { DateTime } from 'luxon'; -import _ from 'underscore'; import { ConfigService } from './ConfigService'; import { UserProfileService } from './UserProfileService'; import { Logger } from './Logger'; @@ -88,18 +87,21 @@ export class BirthdayManager { }; }); - const sorted = _.sortBy(mapped, (o) => o.birthday).slice(0, 10); - const groupedSort = _.groupBy(sorted, 'birthday'); + const sorted = mapped.sort((a, b) => a.birthday.toMillis() - b.birthday.toMillis()).slice(0, 10); + const groupedSort = Object.groupBy(sorted, (o) => o.birthday.toString()); messageContent += "Here's the next 10 birthdays in this guild!\n"; messageContent += 'Add your birthday using `/mybirthday`\n'; messageContent += '-------------'; - for (const group in groupedSort) { - const date = groupedSort[group][0].birthday.toLocaleString(DateTime.DATE_FULL); + for (const group of Object.values(groupedSort)) { + if (!group) { + continue; + } + const date = group[0].birthday.toLocaleString(DateTime.DATE_FULL); let members = ''; - groupedSort[group].forEach((m) => { + group.forEach((m) => { members += `<@${m.userId}>\n`; }); messageContent += `\n**${date}**\n${members}`; @@ -161,12 +163,15 @@ export class BirthdayManager { Logger.writeLog('Running birthday notifications job.'); const profiles = await UserProfileService.getBirthdaysToday(); - const usersWithBirthdaysByServer = _.groupBy(profiles, 'serverId'); + const usersWithBirthdaysByServer = Object.groupBy(profiles, (p) => p.serverId); - for (const server in usersWithBirthdaysByServer) { + for (const [server, serverProfiles] of Object.entries(usersWithBirthdaysByServer)) { + if (!serverProfiles) { + continue; + } const config = await ConfigService.getConfig(server); try { - await this.notifyServerBirthdays(client, usersWithBirthdaysByServer[server], config); + await this.notifyServerBirthdays(client, serverProfiles, config); } catch (err) { Logger.writeError(`Error sending birthday notifications for server ${server}.`, err); } diff --git a/src/utilities/ConfigService.ts b/src/utilities/ConfigService.ts index 14491da6..925f2eef 100644 --- a/src/utilities/ConfigService.ts +++ b/src/utilities/ConfigService.ts @@ -1,23 +1,8 @@ -import { ChatInputCommandInteraction, Message } from 'discord.js'; +import type { Sequelize } from 'sequelize-typescript'; import { ServerConfig } from '../client/models/ServerConfig'; +import { UserProfile } from '../client/models/UserProfile'; export class ConfigService { - public static async getConfigByMessage( - message: Message | ChatInputCommandInteraction - ): Promise { - if (!message.guild) { - return undefined; - } - - const [config] = await ServerConfig.findOrCreate({ - where: { - serverId: message.guild.id, - }, - }); - - return config; - } - public static async getConfig(serverId: string): Promise { const [config] = await ServerConfig.findOrCreate({ where: { @@ -41,4 +26,20 @@ export class ConfigService { return recordsDeleted > 0; } + + public static async purgeGuild( + serverId: string, + sequelize?: Sequelize + ): Promise<{ config: boolean; profiles: number }> { + const db = sequelize ?? ServerConfig.sequelize; + if (!db) { + throw new Error('ServerConfig is not bound to a Sequelize instance'); + } + + return db.transaction(async (transaction) => { + const profiles = await UserProfile.destroy({ where: { serverId }, transaction }); + const configs = await ServerConfig.destroy({ where: { serverId }, transaction }); + return { config: configs > 0, profiles }; + }); + } } diff --git a/src/utilities/DataCheck.ts b/src/utilities/DataCheck.ts index 2a9e7e59..93ad3514 100644 --- a/src/utilities/DataCheck.ts +++ b/src/utilities/DataCheck.ts @@ -1,4 +1,3 @@ -import _ from 'underscore'; import { NovaClient } from '../client/NovaClient'; import { ConfigService } from './ConfigService'; import { Logger } from './Logger'; @@ -9,7 +8,7 @@ export class DataCheck { Logger.writeLog('Running data checkup job.'); const servers = await ConfigService.getConfigs(); - const groupedSrvs = _.groupBy(servers, 'serverId'); + const groupedSrvs = Object.groupBy(servers, (s) => s.serverId); for (const srv in groupedSrvs) { const guild = await client.guilds.fetch(srv).catch(async (err) => { diff --git a/src/utilities/UserProfileService.ts b/src/utilities/UserProfileService.ts index 5f000b0f..981ef161 100644 --- a/src/utilities/UserProfileService.ts +++ b/src/utilities/UserProfileService.ts @@ -17,19 +17,6 @@ export class UserProfileService { }); } - public static async getAllBirthdays(): Promise { - return UserProfile.findAll({ - where: { - birthdayDay: { - [Op.not]: null, - }, - birthdayMonth: { - [Op.not]: null, - }, - }, - }); - } - public static async getServerBirthdays(serverId: string): Promise { return UserProfile.findAll({ where: { @@ -55,23 +42,16 @@ export class UserProfileService { return profiles; } - public static async incrementActivityScore( + public static async findUserProfile( serverId: string, userId: string - ): Promise { + ): Promise { + return UserProfile.findOne({ where: { serverId, userId } }); + } + + public static async incrementActivityScore(serverId: string, userId: string): Promise { await this.getUserProfile(serverId, userId); - await UserProfile.increment( - { - activityScore: 1, - }, - { - where: { - serverId: serverId, - userId: userId, - }, - } - ); - return this.getUserProfile(serverId, userId); + await UserProfile.increment({ activityScore: 1 }, { where: { serverId, userId } }); } public static async decrementActivityScore( From 88b9f4267c4653711a747c7307e65921ec870545 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:47:42 +0100 Subject: [PATCH 09/77] Rework moderation commands and message events Activity score is +1 per human guild message with no decrement; honeypot logic moved to handleHoneypot. Message/member events use AuditEmbed and handle partials. /post, /noroles and /rolesince defer first, check permissions, and paginate long listings into a text attachment. Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberRemove.ts | 34 ++--- src/events/messageCreate.ts | 151 +++++++++---------- src/events/messageDelete.ts | 50 ++++--- src/events/messageUpdate.ts | 39 ++--- src/services/MemberListing.ts | 53 +++++++ src/slash-commands/Admin/noroles.ts | 33 ++--- src/slash-commands/Admin/post.ts | 96 ++++++------ src/slash-commands/Admin/rolesince.ts | 56 +++---- src/utilities/UserProfileService.ts | 19 --- tests/fakes/messages.ts | 85 +++++++++++ tests/moderation/events.test.ts | 196 +++++++++++++++++++++++++ tests/moderation/memberListing.test.ts | 105 +++++++++++++ tests/moderation/post.test.ts | 132 +++++++++++++++++ 13 files changed, 790 insertions(+), 259 deletions(-) create mode 100644 src/services/MemberListing.ts create mode 100644 tests/fakes/messages.ts create mode 100644 tests/moderation/events.test.ts create mode 100644 tests/moderation/memberListing.test.ts create mode 100644 tests/moderation/post.test.ts diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 64d3ee2e..66b5604f 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,37 +1,29 @@ -import { EmbedColours } from '../resources/EmbedColours'; import { defineEvent } from '../framework/event'; -import { ChannelService } from '../utilities/ChannelService'; +import { EmbedColours } from '../resources/EmbedColours'; +import { AuditEmbed } from '../services/AuditEmbed'; +import { sendAudit } from '../services/AuditService'; import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { UserProfileService } from '../utilities/UserProfileService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; +import { UserProfileService } from '../utilities/UserProfileService'; export default defineEvent({ name: 'guildMemberRemove', run: async (client, member) => { - if (member.user.bot) { + const user = member.user; + if (user.bot) { return; } - if (HoneyPotEnforcementService.isActive(member.guild.id, member.user.id)) { - await UserProfileService.deleteUser(member.guild.id, member.user.id); + const dataDeleted = await UserProfileService.deleteUser(member.guild.id, user.id); + if (HoneyPotEnforcementService.isActive(member.guild.id, user.id)) { return; } - const dataDeleted = await UserProfileService.deleteUser(member.guild.id, member.user.id); - - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setDescription('Member left') - .addField('ID', member.user.id) - .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data') - .setTimestamp(); + const audit = AuditEmbed.forMember(member.partial ? user : member, EmbedColours.negative, 'Member left') + .addField('ID', user.id) + .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data'); - const serverConfig = await ConfigService.getConfig(member.guild.id); - await ChannelService.sendAuditMessage(client, serverConfig, audit); + const config = await ConfigService.getConfig(member.guild.id); + await sendAudit(client, config, audit); }, }); diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 78c565bf..b78a6172 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,9 +1,10 @@ -import { Message, PermissionFlagsBits } from 'discord.js'; -import { EmbedColours } from '../resources/EmbedColours'; -import { NovaClient } from '../client/NovaClient'; +import { GuildMember, Message, PermissionFlagsBits } from 'discord.js'; +import type { NovaClient } from '../client/NovaClient'; +import type { ServerConfig } from '../client/models/ServerConfig'; import { defineEvent } from '../framework/event'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { ChannelService } from '../utilities/ChannelService'; +import { EmbedColours } from '../resources/EmbedColours'; +import { AuditEmbed } from '../services/AuditEmbed'; +import { sendAudit } from '../services/AuditService'; import { ConfigService } from '../utilities/ConfigService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; import { Logger } from '../utilities/Logger'; @@ -11,92 +12,92 @@ import { UserProfileService } from '../utilities/UserProfileService'; const DELETE_MESSAGE_SECONDS = 7 * 24 * 60 * 60; -const formatError = (error: unknown): string => { - return error instanceof Error ? error.message : String(error); -}; +const formatError = (error: unknown): string => + error instanceof Error ? error.message : String(error); -const sendAudit = async ( +const auditHoneypot = async ( client: NovaClient, - message: Message, + config: ServerConfig, + member: GuildMember, + channelId: string, description: string, action: string ): Promise => { - const config = await ConfigService.getConfig(message.guild.id); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: message.author.tag, - iconURL: message.author.displayAvatarURL(), - }) - .setDescription(description) - .addField('Member ID', message.author.id) - .addField('Channel', message.channel.toString()) - .addField('Action', action) - .setTimestamp(); - - await ChannelService.sendAuditMessage(client, config, audit); + const audit = AuditEmbed.forMember(member, EmbedColours.negative, description) + .addField('Member ID', member.id) + .addField('Channel', `<#${channelId}>`) + .addField('Action', action); + await sendAudit(client, config, audit); }; -export default defineEvent({ - name: 'messageCreate', - run: async (client, message) => { - if (!message.inGuild() || message.author.bot) { - return; - } +/** Returns true when the message was posted in the honeypot channel and has been dealt with. */ +export const handleHoneypot = async ( + client: NovaClient, + message: Message, + config: ServerConfig +): Promise => { + if (!config.honeyPotChannelId || config.honeyPotChannelId !== message.channelId) { + return false; + } - const config = await ConfigService.getConfig(message.guild.id); - if (config.honeyPotChannelId !== message.channelId) { - return; - } + const member = + message.member ?? + (await message.guild.members.fetch(message.author.id).catch(() => null)); + if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { + return true; + } - const member = - message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); - if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { - return; - } + if (!HoneyPotEnforcementService.begin(message.guild.id, member.id)) { + return true; + } - if (!HoneyPotEnforcementService.begin(message.guild.id, member.id)) { - return; - } + if (!member.bannable) { + HoneyPotEnforcementService.cancel(message.guild.id, member.id); + const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; + Logger.writeError(`Honey-pot ban failed for ${member.id}.`, error); + await auditHoneypot(client, config, member, message.channelId, 'Honey-pot ban failed', error); + return true; + } - if (!member.bannable) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); - const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, error); - await sendAudit(client, message, 'Honey-pot ban failed', error).catch((auditError) => { - Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); - }); + try { + await member.ban({ + deleteMessageSeconds: DELETE_MESSAGE_SECONDS, + reason: `Posted in honey-pot channel ${message.channelId}`, + }); + } catch (error) { + HoneyPotEnforcementService.cancel(message.guild.id, member.id); + const errorMessage = formatError(error); + Logger.writeError(`Honey-pot ban failed for ${member.id}.`, errorMessage); + await auditHoneypot(client, config, member, message.channelId, 'Honey-pot ban failed', errorMessage); + return true; + } + + await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { + Logger.writeError(`Could not delete profile data for honey-pot ban ${member.id}.`, formatError(error)); + }); + await auditHoneypot( + client, + config, + member, + message.channelId, + 'Honey-pot triggered', + "Banned and deleted the member's messages from the past 7 days." + ); + return true; +}; + +export default defineEvent({ + name: 'messageCreate', + run: async (client, message) => { + if (!message.inGuild() || message.author.bot || message.webhookId || message.system) { return; } - try { - await member.ban({ - deleteMessageSeconds: DELETE_MESSAGE_SECONDS, - reason: `Posted in honey-pot channel ${message.channelId}`, - }); - } catch (error) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); - const errorMessage = formatError(error); - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, errorMessage); - await sendAudit(client, message, 'Honey-pot ban failed', errorMessage).catch((auditError) => { - Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); - }); + const config = await ConfigService.getConfig(message.guildId); + if (await handleHoneypot(client, message, config)) { return; } - await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { - Logger.writeError( - `Could not delete profile data for honey-pot ban ${member.id}.`, - formatError(error) - ); - }); - await sendAudit( - client, - message, - 'Honey-pot triggered', - "Banned and deleted the member's messages from the past 7 days." - ).catch((error) => { - Logger.writeError('Could not send honey-pot success audit.', formatError(error)); - }); + await UserProfileService.incrementActivityScore(message.guildId, message.author.id); }, }); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 2f9382e2..cf6e9cc9 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,45 +1,49 @@ -import { EmbedColours } from '../resources/EmbedColours'; import { defineEvent } from '../framework/event'; -import { ChannelService } from '../utilities/ChannelService'; +import { EmbedColours } from '../resources/EmbedColours'; +import { AuditEmbed } from '../services/AuditEmbed'; +import { sendAudit } from '../services/AuditService'; import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { UserProfileService } from '../utilities/UserProfileService'; import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; export default defineEvent({ name: 'messageDelete', run: async (client, message) => { - if (!message.author || !message.guild) { + if (!message.guildId) { return; } - - if (HoneyPotEnforcementService.isActive(message.guild.id, message.author.id)) { + if (message.author?.bot) { + return; + } + if (message.author && HoneyPotEnforcementService.isActive(message.guildId, message.author.id)) { return; } - const serverConfig = await ConfigService.getConfig(message.guild.id); - - await UserProfileService.decrementActivityScore(message.guild.id, message.author.id); + const config = await ConfigService.getConfig(message.guildId); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: message.author.tag, - iconURL: message.author.displayAvatarURL(), - }) - .setDescription('A message was deleted') - .setTimestamp(); + if (message.partial || !message.author) { + const audit = new AuditEmbed() + .setColor(EmbedColours.neutral) + .setDescription('A message was deleted (not cached — content unavailable)') + .setTimestamp() + .addField('Channel', `<#${message.channelId}>`) + .addField('Message ID', message.id); + await sendAudit(client, config, audit); + return; + } + const audit = AuditEmbed.forMember(message.author, EmbedColours.neutral, 'A message was deleted') + .addField('Channel', `<#${message.channelId}>`) + .addField('Author ID', message.author.id); if (message.content) { audit.addField('Message', message.content); } + if (message.attachments.size > 0) { + const names = [...message.attachments.values()].map((a) => a.name).join(', '); + audit.addField('Attachments', `${message.attachments.size}: ${names}`); + } if (message.embeds.length > 0) { audit.addField('Embeds', message.embeds.length.toString()); } - if (message.attachments.size > 0) { - audit.addField('Attachments', message.attachments.size.toString()); - } - - await ChannelService.sendAuditMessage(client, serverConfig, audit); + await sendAudit(client, config, audit); }, }); diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 8e4de811..898bd845 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -1,40 +1,41 @@ -import { EmbedColours } from '../resources/EmbedColours'; import { defineEvent } from '../framework/event'; -import { ChannelService } from '../utilities/ChannelService'; +import { EmbedColours } from '../resources/EmbedColours'; +import { AuditEmbed } from '../services/AuditEmbed'; +import { sendAudit } from '../services/AuditService'; import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; export default defineEvent({ name: 'messageUpdate', - run: async (client, oldMessage, newMessage) => { - if (!oldMessage.content) { + run: async (client, oldMessage, partialNew) => { + if (!partialNew.guildId) { return; } + let newMessage = partialNew; if (newMessage.partial) { - newMessage = await newMessage.fetch(); + try { + newMessage = await newMessage.fetch(); + } catch { + return; + } } - if (newMessage.author.bot || !newMessage.guild) { + if (newMessage.author.bot || !newMessage.guildId) { return; } - if (oldMessage.content === newMessage.content) { + const previous = oldMessage.partial ? null : oldMessage.content; + if (previous === newMessage.content) { return; } - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: newMessage.author.tag, - iconURL: newMessage.author.displayAvatarURL(), - }) - .setDescription('A message was edited') - .addField('Previous', oldMessage.content) + const audit = AuditEmbed.forMember(newMessage.author, EmbedColours.neutral, 'A message was edited') + .addField('Channel', `<#${newMessage.channelId}>`) + .addField('Previous', previous === null ? '*(not cached)*' : previous) .addField('Current', newMessage.content) - .setTimestamp(); + .addField('Jump to message', newMessage.url); - const serverConfig = await ConfigService.getConfig(newMessage.guild.id); - await ChannelService.sendAuditMessage(client, serverConfig, audit); + const config = await ConfigService.getConfig(newMessage.guildId); + await sendAudit(client, config, audit); }, }); diff --git a/src/services/MemberListing.ts b/src/services/MemberListing.ts new file mode 100644 index 00000000..95ac7334 --- /dev/null +++ b/src/services/MemberListing.ts @@ -0,0 +1,53 @@ +import { AttachmentBuilder, GuildMember } from 'discord.js'; +import { DateTime } from 'luxon'; +import type { ReplyOptions } from '../framework/command'; + +export const INLINE_LIMIT = 2000; +const DAY_MS = 24 * 60 * 60 * 1000; + +const byJoinDate = (a: GuildMember, b: GuildMember): number => + (a.joinedTimestamp ?? Infinity) - (b.joinedTimestamp ?? Infinity); + +const inlineLine = (member: GuildMember): string => + member.joinedTimestamp === null + ? `<@${member.id}> join date unknown` + : `<@${member.id}> joined `; + +const fileLine = (member: GuildMember): string => + `${member.user.username} (${member.id}) — ${ + member.joinedTimestamp === null + ? 'join date unknown' + : `joined ${DateTime.fromMillis(member.joinedTimestamp, { zone: 'utc' }).toISODate()}` + }`; + +export const wholeDaysSinceJoin = (member: GuildMember, now: number = Date.now()): number | null => { + if (member.joinedTimestamp === null) { + return null; + } + const joined = DateTime.fromMillis(member.joinedTimestamp, { zone: 'utc' }).startOf('day'); + const today = DateTime.fromMillis(now, { zone: 'utc' }).startOf('day'); + return Math.floor((today.toMillis() - joined.toMillis()) / DAY_MS); +}; + +export const buildMemberListing = ( + header: string, + emptyMessage: string, + members: GuildMember[] +): ReplyOptions => { + if (members.length === 0) { + return { content: emptyMessage, allowedMentions: { parse: [] } }; + } + const sorted = [...members].sort(byJoinDate); + const inline = `${header}\n------\n${sorted.map(inlineLine).join('\n')}`; + if (inline.length <= INLINE_LIMIT) { + return { content: inline, allowedMentions: { parse: [] } }; + } + const file = new AttachmentBuilder(Buffer.from(sorted.map(fileLine).join('\n'), 'utf8'), { + name: 'members.txt', + }); + return { + content: `${header}\n${sorted.length} members matched; the full list is attached.`, + files: [file], + allowedMentions: { parse: [] }, + }; +}; diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts index bd558252..f6cb2bb7 100644 --- a/src/slash-commands/Admin/noroles.ts +++ b/src/slash-commands/Admin/noroles.ts @@ -1,33 +1,18 @@ import { defineCommand } from '../../framework/command'; +import { buildMemberListing } from '../../services/MemberListing'; export default defineCommand({ name: 'noroles', description: 'Returns members of this guild with no roles assigned', adminOnly: true, + defer: 'ephemeral', run: async (ctx) => { - const allMembers = await ctx.guild.members.fetch(); - - const members = allMembers.filter((member) => member.roles.cache.size === 1); - - let response: string; - - if (members.size < 1) { - response = 'There are no users with no roles.'; - } else { - response = '**Users with no roles**\n------\n'; - for (const mem of members.values()) { - if (mem.partial) { - await mem.fetch(); - } - response += `${mem.toString()} joined \n`; - } - } - - await ctx.reply({ - content: response, - allowedMentions: { - parse: [], - }, - }); + const all = await ctx.guild.members.fetch(); + const members = [...all.values()].filter( + (member) => !member.user.bot && member.roles.cache.size === 1 + ); + await ctx.reply( + buildMemberListing('**Users with no roles**', 'There are no users with no roles.', members) + ); }, }); diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts index f9d51bff..4e8206f7 100644 --- a/src/slash-commands/Admin/post.ts +++ b/src/slash-commands/Admin/post.ts @@ -1,34 +1,11 @@ -import { Attachment } from 'discord.js'; -import { ServerConfig } from '../../client/models/ServerConfig'; -import { CommandContext, defineCommand } from '../../framework/command'; +import { ChannelType, Message, PermissionFlagsBits } from 'discord.js'; +import { defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { ChannelService } from '../../utilities/ChannelService'; +import { AuditEmbed } from '../../services/AuditEmbed'; +import { sendAudit } from '../../services/AuditService'; +import { resolveTextChannel } from '../../services/MemberResolver'; import { Logger } from '../../utilities/Logger'; -const sendAudit = async ( - ctx: CommandContext, - config: ServerConfig, - file: Attachment | null, - content: string | null -) => { - const embed = new EmbedCompatLayer(); - const member = ctx.guild.members.cache.get(ctx.interaction.user.id); - - embed - .setColor(EmbedColours.neutral) - .setAuthor({ - name: member?.displayName ?? ctx.interaction.user.username, - iconURL: (member ?? ctx.interaction.user).displayAvatarURL(), - }) - .addField('Content', content ? content : 'No') - .addField('Attachment', file ? 'Yes' : 'No') - .setDescription('Post created via Dingir') - .setTimestamp(); - - await ChannelService.sendAuditMessage(ctx.interaction.client, config, embed, file ?? undefined); -}; - export default defineCommand({ name: 'post', description: 'Posts a simple message and/or attachment to the given channel', @@ -37,42 +14,69 @@ export default defineCommand({ options: (b) => b .addChannelOption((opt) => - opt.setName('channel').setDescription('Channel to post in').setRequired(true) + opt + .setName('channel') + .setDescription('Channel to post in') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true) ) .addStringOption((opt) => - opt.setName('content').setDescription('Optional simple message to send') + opt.setName('content').setDescription('Optional simple message to send').setMaxLength(2000) ) .addAttachmentOption((opt) => opt.setName('attachment').setDescription('Optional attachment to send') ), run: async (ctx) => { - const cmd = ctx.interaction; - const channel = cmd.options.getChannel('channel', true); - const content = cmd.options.getString('content'); - const attachment = cmd.options.getAttachment('attachment'); + const options = ctx.interaction.options; + const target = options.getChannel('channel', true); + const content = options.getString('content'); + const attachment = options.getAttachment('attachment'); if (!content && !attachment) { - await ctx.reply('You must provide at least text or an attachment'); + await ctx.reply('You must provide at least text or an attachment.'); return; } - const guildChannel = ctx.guild.channels.cache.get(channel.id); - if (!guildChannel || !guildChannel.isTextBased()) { - await ctx.reply('The provided channel is not valid'); + const channel = await resolveTextChannel(ctx.guild, target.id); + if (!channel) { + await ctx.reply('The provided channel is not valid.'); return; } - await guildChannel - .send({ + const required = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]; + if (attachment) { + required.push(PermissionFlagsBits.AttachFiles); + } + const botPermissions = channel.permissionsFor(ctx.guild.members.me ?? ctx.guild.client.user); + const missing = required.filter((flag) => !botPermissions?.has(flag)); + if (missing.length > 0) { + const names = Object.entries(PermissionFlagsBits) + .filter(([, flag]) => missing.includes(flag)) + .map(([name]) => name); + await ctx.reply(`I am missing permissions in ${channel.toString()}: ${names.join(', ')}.`); + return; + } + + let sent: Message; + try { + sent = await channel.send({ content: content ?? undefined, files: attachment ? [attachment] : undefined, - }) - .catch(() => ctx.reply('An error was encountered sending this message')); + allowedMentions: { parse: ['users', 'roles'] }, + }); + } catch (error) { + Logger.warn('Failed to send /post message', { guildId: ctx.guild.id, channelId: channel.id }, error); + await ctx.reply('An error was encountered sending this message.'); + return; + } - await sendAudit(ctx, ctx.config, attachment, content).catch((err: unknown) => - Logger.writeError('Could not send post audit.', err) - ); + const audit = AuditEmbed.forMember(ctx.member, EmbedColours.neutral, 'Post created via Dingir') + .addField('Channel', channel.toString()) + .addField('Content', content ?? 'No text') + .addField('Attachment', attachment ? attachment.name : 'No') + .addField('Message', sent.url); + await sendAudit(ctx.interaction.client, ctx.config, audit); - await ctx.reply('Message successfully sent'); + await ctx.reply(`Posted in ${channel.toString()}: ${sent.url}`); }, }); diff --git a/src/slash-commands/Admin/rolesince.ts b/src/slash-commands/Admin/rolesince.ts index c016f411..c8269abc 100644 --- a/src/slash-commands/Admin/rolesince.ts +++ b/src/slash-commands/Admin/rolesince.ts @@ -1,49 +1,41 @@ -import { DateTime } from 'luxon'; import { defineCommand } from '../../framework/command'; +import { buildMemberListing, wholeDaysSinceJoin } from '../../services/MemberListing'; export default defineCommand({ name: 'rolesince', - description: 'Returns members in the given role for the specified number of days', + description: 'List members of a role who joined the server at least N days ago', adminOnly: true, + defer: 'ephemeral', options: (b) => b .addRoleOption((opt) => - opt.setName('role').setDescription('Role to search again').setRequired(true) + opt.setName('role').setDescription('Role to search').setRequired(true) ) - .addNumberOption((opt) => - opt.setName('days').setDescription('Minimum number of days in the role') + .addIntegerOption((opt) => + opt + .setName('days') + .setDescription('Minimum number of days since the member joined the server') + .setMinValue(0) ), run: async (ctx) => { const role = ctx.interaction.options.getRole('role', true); - const days = ctx.interaction.options.getNumber('days') ?? 0; + const days = ctx.interaction.options.getInteger('days') ?? 0; - const allMembers = await ctx.guild.members.fetch(); - - const members = allMembers.filter((member) => { - const joined = DateTime.fromMillis(member.joinedTimestamp ?? Date.now()).startOf('day'); - const daysInServer = DateTime.local().startOf('day').diff(joined, 'days').days; - return member.roles.cache.find((r) => r.id === role.id) && daysInServer >= days; - }); - - let response: string; - - if (members.size < 1) { - response = `There are no users in ${role.toString()} that have been in the server for at least ${days} days.`; - } else { - response = `**Users in ${role.toString()} that have been in the server for at least ${days} days.**\n------\n`; - for (const mem of members.values()) { - if (mem.partial) { - await mem.fetch(); - } - response += `${mem.toString()} joined \n`; + await ctx.guild.members.fetch(); + const members = [...role.members.values()].filter((member) => { + if (member.user.bot) { + return false; } - } - - await ctx.reply({ - content: response, - allowedMentions: { - parse: [], - }, + const joinedDays = wholeDaysSinceJoin(member); + return joinedDays === null ? days === 0 : joinedDays >= days; }); + + await ctx.reply( + buildMemberListing( + `**Users in ${role.toString()} that joined the server at least ${days} days ago**`, + `There are no users in ${role.toString()} that joined the server at least ${days} days ago.`, + members + ) + ); }, }); diff --git a/src/utilities/UserProfileService.ts b/src/utilities/UserProfileService.ts index 981ef161..f68e9568 100644 --- a/src/utilities/UserProfileService.ts +++ b/src/utilities/UserProfileService.ts @@ -54,25 +54,6 @@ export class UserProfileService { await UserProfile.increment({ activityScore: 1 }, { where: { serverId, userId } }); } - public static async decrementActivityScore( - serverId: string, - userId: string - ): Promise { - await this.getUserProfile(serverId, userId); - await UserProfile.increment( - { - activityScore: -1, - }, - { - where: { - serverId: serverId, - userId: userId, - }, - } - ); - return this.getUserProfile(serverId, userId); - } - public static async deleteUser(serverId: string, userId: string): Promise { const recordsDeleted = await UserProfile.destroy({ where: { diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts new file mode 100644 index 00000000..dd236459 --- /dev/null +++ b/tests/fakes/messages.ts @@ -0,0 +1,85 @@ +import { Collection, User } from 'discord.js'; +import type { NovaClient } from '../../src/client/NovaClient'; +import type { Command } from '../../src/framework/command'; +import interactionCreate from '../../src/events/interactionCreate'; +import type { FakeInteraction } from './interaction'; + +export interface AuditSink { + client: NovaClient; + sent: any[]; +} + +export function fakeAuditClient(channelId = 'audit-1', commands: Command[] = []): AuditSink { + const sent: any[] = []; + const channel = { + isSendable: () => true, + send: async (payload: unknown) => void sent.push(payload), + }; + const client = { + channels: { fetch: async (id: string) => (id === channelId ? channel : null) }, + slashCommands: new Collection(commands.map((c) => [c.name, c])), + } as unknown as NovaClient; + return { client, sent }; +} + +export const auditJson = (sent: any[], index = 0): any => sent[index].embeds[0].toJSON(); + +export function fakeUser(id: string, overrides: Record = {}) { + return Object.assign(Object.create(User.prototype), { + id, + bot: false, + username: `user${id}`, + globalName: null, + discriminator: '0', + displayAvatarURL: () => 'https://example.com/a.png', + ...overrides, + }) as User; +} + +export function fakeMember(id: string, overrides: Record = {}) { + const user = (overrides.user as User) ?? fakeUser(id); + return { + id, + user, + displayName: `Member ${id}`, + joinedTimestamp: Date.now(), + permissions: { has: () => false }, + roles: { cache: new Collection([['everyone', {}]]) }, + bannable: true, + ban: async () => undefined, + displayAvatarURL: () => 'https://example.com/a.png', + ...overrides, + }; +} + +export function fakeMessage(overrides: Record = {}) { + const author = (overrides.author as User | null | undefined) ?? fakeUser('u1'); + const base = { + id: 'm1', + partial: false, + guildId: 'guild-1', + channelId: 'chan-1', + webhookId: null, + system: false, + content: 'hello', + author, + member: fakeMember(author.id, { user: author }), + guild: { id: 'guild-1', members: { fetch: async () => null } }, + attachments: new Collection(), + embeds: [] as unknown[], + url: 'https://discord.com/channels/guild-1/chan-1/m1', + inGuild() { + return Boolean(this.guildId); + }, + }; + return { ...base, ...overrides } as any; +} + +export async function runSlash( + client: NovaClient, + fake: FakeInteraction, + extra: Record = {} +): Promise { + Object.assign(fake.interaction, { client, ...extra }); + await interactionCreate.run(client, fake.interaction); +} diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts new file mode 100644 index 00000000..f8a322df --- /dev/null +++ b/tests/moderation/events.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, before, beforeEach, test } from 'node:test'; +import { Collection } from 'discord.js'; +import type { Sequelize } from 'sequelize-typescript'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import guildMemberRemove from '../../src/events/guildMemberRemove'; +import messageCreate from '../../src/events/messageCreate'; +import messageDelete from '../../src/events/messageDelete'; +import messageUpdate from '../../src/events/messageUpdate'; +import { ConfigService } from '../../src/utilities/ConfigService'; +import { HoneyPotEnforcementService } from '../../src/utilities/HoneyPotEnforcementService'; +import { UserProfileService } from '../../src/utilities/UserProfileService'; +import { createTestDb } from '../helpers/db'; +import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages'; + +let db: Sequelize; +const original = { log: console.log, warn: console.warn, error: console.error }; + +before(async () => { + db = await createTestDb(); + const config = await ConfigService.getConfig('guild-1'); + await config.update({ auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(() => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; +}); + +afterEach(async () => { + Object.assign(console, original); + HoneyPotEnforcementService.cancel('guild-1', 'u1'); + await UserProfile.destroy({ where: {} }); +}); + +const score = async (): Promise => + (await UserProfile.findOne({ where: { serverId: 'guild-1', userId: 'u1' } }))?.activityScore ?? null; + +test('honeypot message bans the member and does not count activity', async () => { + const sink = fakeAuditClient(); + let banned: any = null; + const message = fakeMessage({ + channelId: 'honey-1', + member: fakeMember('u1', { + ban: async (opts: unknown) => { + banned = opts; + }, + }), + }); + await messageCreate.run(sink.client, message); + assert.ok(banned); + assert.equal(await score(), null); + assert.equal(sink.sent.length, 1); + assert.equal(auditJson(sink.sent).description, 'Honey-pot triggered'); +}); + +test('normal messages increment activity once', async () => { + const sink = fakeAuditClient(); + await messageCreate.run(sink.client, fakeMessage()); + await messageCreate.run(sink.client, fakeMessage()); + assert.equal(await score(), 2); + assert.equal(sink.sent.length, 0); +}); + +test('bot, webhook, system and DM messages do not count', async () => { + const sink = fakeAuditClient(); + await messageCreate.run(sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); + await messageCreate.run(sink.client, fakeMessage({ webhookId: 'w1' })); + await messageCreate.run(sink.client, fakeMessage({ system: true })); + await messageCreate.run(sink.client, fakeMessage({ guildId: null })); + assert.equal(await score(), null); +}); + +test('messageDelete never changes the activity score', async () => { + const sink = fakeAuditClient(); + await UserProfileService.incrementActivityScore('guild-1', 'u1'); + await messageDelete.run(sink.client, fakeMessage()); + assert.equal(await score(), 1); + assert.equal(sink.sent.length, 1); +}); + +test('messageDelete audits uncached messages without content', async () => { + const sink = fakeAuditClient(); + const message = fakeMessage({ partial: true, author: null, id: 'm42', channelId: 'chan-7' }); + message.author = null; + await messageDelete.run(sink.client, message); + const embed = auditJson(sink.sent); + assert.match(embed.description, /not cached/); + assert.deepEqual( + embed.fields.map((f: any) => f.value), + ['<#chan-7>', 'm42'] + ); +}); + +test('messageDelete handles very long content and attachments', async () => { + const sink = fakeAuditClient(); + const message = fakeMessage({ + content: 'x'.repeat(5000), + attachments: new Collection([['a', { name: 'a.png' }], ['b', { name: 'b.png' }]]), + embeds: [{}], + }); + await messageDelete.run(sink.client, message); + const embed = auditJson(sink.sent); + const text = embed.fields.find((f: any) => f.name === 'Message'); + assert.ok(text.value.length <= 1024); + assert.equal(embed.fields.find((f: any) => f.name === 'Attachments').value, '2: a.png, b.png'); + assert.equal(embed.fields.find((f: any) => f.name === 'Embeds').value, '1'); +}); + +test('messageDelete skips bots and active honeypot enforcement', async () => { + const sink = fakeAuditClient(); + await messageDelete.run(sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); + HoneyPotEnforcementService.begin('guild-1', 'u1'); + await messageDelete.run(sink.client, fakeMessage()); + assert.equal(sink.sent.length, 0); +}); + +test('messageUpdate notes an uncached previous message', async () => { + const sink = fakeAuditClient(); + await messageUpdate.run( + sink.client, + fakeMessage({ partial: true, content: null }), + fakeMessage({ content: 'edited' }) + ); + const embed = auditJson(sink.sent); + const previous = embed.fields.find((f: any) => f.name === 'Previous'); + assert.equal(previous.value, '*(not cached)*'); + assert.ok(embed.fields.some((f: any) => f.name === 'Jump to message')); + assert.ok(embed.fields.some((f: any) => f.name === 'Channel')); +}); + +test('messageUpdate fetches partial new messages and returns when fetching fails', async () => { + const sink = fakeAuditClient(); + const failing = fakeMessage({ + partial: true, + fetch: async () => { + throw new Error('Unknown Message'); + }, + }); + await messageUpdate.run(sink.client, fakeMessage({ content: 'old' }), failing); + assert.equal(sink.sent.length, 0); + + const fetched = fakeMessage({ content: 'new' }); + const partial = fakeMessage({ partial: true, fetch: async () => fetched }); + await messageUpdate.run(sink.client, fakeMessage({ content: 'old' }), partial); + assert.equal(sink.sent.length, 1); +}); + +test('messageUpdate skips unchanged content and bots', async () => { + const sink = fakeAuditClient(); + await messageUpdate.run(sink.client, fakeMessage(), fakeMessage()); + await messageUpdate.run( + sink.client, + fakeMessage({ content: 'a' }), + fakeMessage({ content: 'b', author: fakeUser('u1', { bot: true }) }) + ); + assert.equal(sink.sent.length, 0); +}); + +test('messageUpdate truncates long content', async () => { + const sink = fakeAuditClient(); + await messageUpdate.run( + sink.client, + fakeMessage({ content: 'a'.repeat(4000) }), + fakeMessage({ content: 'b'.repeat(4000) }) + ); + assert.equal(sink.sent.length, 1); + assert.ok(auditJson(sink.sent).fields.every((f: any) => f.value.length <= 1024)); +}); + +test('guildMemberRemove handles partial members and deletes profile data', async () => { + const sink = fakeAuditClient(); + await UserProfileService.incrementActivityScore('guild-1', 'u1'); + const partial = { partial: true, guild: { id: 'guild-1' }, user: fakeUser('u1') } as any; + await guildMemberRemove.run(sink.client, partial); + assert.equal(await score(), null); + const embed = auditJson(sink.sent); + assert.equal(embed.description, 'Member left'); + assert.equal(embed.author.name, 'useru1'); + assert.equal(embed.fields[1].value, 'Deleted'); +}); + +test('guildMemberRemove during honeypot enforcement only deletes data', async () => { + const sink = fakeAuditClient(); + await UserProfileService.incrementActivityScore('guild-1', 'u1'); + HoneyPotEnforcementService.begin('guild-1', 'u1'); + const member = fakeMember('u1', { guild: { id: 'guild-1' } }) as any; + await guildMemberRemove.run(sink.client, member); + assert.equal(await score(), null); + assert.equal(sink.sent.length, 0); +}); diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts new file mode 100644 index 00000000..9567e039 --- /dev/null +++ b/tests/moderation/memberListing.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { Collection } from 'discord.js'; +import type { Sequelize } from 'sequelize-typescript'; +import noroles from '../../src/slash-commands/Admin/noroles'; +import rolesince from '../../src/slash-commands/Admin/rolesince'; +import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing'; +import { createTestDb } from '../helpers/db'; +import { fakeInteraction } from '../fakes/interaction'; +import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages'; + +let db: Sequelize; +before(async () => { + db = await createTestDb(); +}); +after(async () => { + await db.close(); +}); + +const DAY = 24 * 60 * 60 * 1000; +const members = (count: number, joined: number | null = Date.now()) => + Array.from({ length: count }, (_, i) => fakeMember(String(100000000000000000 + i), { joinedTimestamp: joined })) as any[]; + +test('small listings reply inline without mentions', () => { + const result = buildMemberListing('**Header**', 'none', members(3)); + assert.ok(result.content!.length <= INLINE_LIMIT); + assert.equal(result.files, undefined); + assert.deepEqual(result.allowedMentions, { parse: [] }); + assert.match(result.content!, /joined /); +}); + +test('large listings attach a text file with a summary line', () => { + const result = buildMemberListing('**Header**', 'none', members(200, Date.UTC(2024, 0, 15))); + assert.ok(result.content!.length < INLINE_LIMIT); + assert.match(result.content!, /200 members/); + assert.equal(result.files!.length, 1); + const file = result.files![0] as any; + assert.equal(file.name, 'members.txt'); + const text = file.attachment.toString('utf8'); + assert.equal(text.split('\n').length, 200); + assert.match(text, /^user\d+ \(\d+\) — joined 2024-01-15/); +}); + +test('null join dates are described as unknown', () => { + const result = buildMemberListing('**Header**', 'none', members(1, null)); + assert.match(result.content!, /join date unknown/); + const big = buildMemberListing('**Header**', 'none', members(200, null)); + assert.match((big.files![0] as any).attachment.toString('utf8'), /join date unknown/); +}); + +test('empty listing returns the empty message', () => { + assert.equal(buildMemberListing('h', 'nobody', []).content, 'nobody'); +}); + +test('rolesince filters by days in server and excludes bots', async () => { + const now = Date.now(); + const old = fakeMember('1', { joinedTimestamp: now - 40 * DAY }); + const recent = fakeMember('2', { joinedTimestamp: now - 2 * DAY }); + const bot = fakeMember('3', { joinedTimestamp: now - 90 * DAY, user: fakeUser('3', { bot: true }) }); + const role = { + toString: () => '<@&r1>', + members: new Collection([old, recent, bot].map((m) => [m.id, m])), + }; + let fetched = false; + const guild = { id: 'guild-1', members: { fetch: async () => void (fetched = true) } }; + const sink = fakeAuditClient('a', [rolesince]); + + const fake = fakeInteraction({ commandName: 'rolesince', options: { role }, guild }); + Object.assign(fake.interaction.options, { getInteger: () => 30 }); + await runSlash(sink.client, fake); + assert.equal(fetched, true); + assert.equal(fake.calls[0].method, 'deferReply'); + const content = fake.calls.at(-1)!.payload.content as string; + assert.match(content, /<@1>/); + assert.doesNotMatch(content, /<@2>/); + assert.doesNotMatch(content, /<@3>/); + assert.match(content, /at least 30 days ago/); + + const all = fakeInteraction({ commandName: 'rolesince', options: { role }, guild }); + Object.assign(all.interaction.options, { getInteger: () => null }); + await runSlash(sink.client, all); + const everyone = all.calls.at(-1)!.payload.content as string; + assert.match(everyone, /<@1>/); + assert.match(everyone, /<@2>/); +}); + +test('noroles lists only non-bot members with just the everyone role', async () => { + const plain = fakeMember('1'); + const withRole = fakeMember('2', { + roles: { cache: new Collection([['e', {}], ['r', {}]]) }, + }); + const bot = fakeMember('3', { user: fakeUser('3', { bot: true }) }); + const guild = { + id: 'guild-1', + members: { fetch: async () => new Collection([plain, withRole, bot].map((m) => [m.id, m])) }, + }; + const sink = fakeAuditClient('a', [noroles]); + const fake = fakeInteraction({ commandName: 'noroles', guild }); + await runSlash(sink.client, fake); + assert.equal(fake.calls[0].method, 'deferReply'); + const content = fake.calls.at(-1)!.payload.content as string; + assert.match(content, /<@1>/); + assert.doesNotMatch(content, /<@2>/); + assert.doesNotMatch(content, /<@3>/); +}); diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts new file mode 100644 index 00000000..bdbc5db7 --- /dev/null +++ b/tests/moderation/post.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import { Collection, PermissionFlagsBits } from 'discord.js'; +import type { Sequelize } from 'sequelize-typescript'; +import post from '../../src/slash-commands/Admin/post'; +import { ConfigService } from '../../src/utilities/ConfigService'; +import { createTestDb } from '../helpers/db'; +import { fakeInteraction } from '../fakes/interaction'; +import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages'; + +let db: Sequelize; +const original = { log: console.log, warn: console.warn, error: console.error }; + +before(async () => { + db = await createTestDb(); + const config = await ConfigService.getConfig('guild-1'); + await config.update({ auditChannelId: 'audit-1' }); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(() => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; +}); + +after(() => { + Object.assign(console, original); +}); + +const target = (send: () => Promise, granted: bigint[]) => ({ + id: 'chan-9', + isTextBased: () => true, + isDMBased: () => false, + isSendable: () => true, + toString: () => '<#chan-9>', + permissionsFor: () => ({ has: (flag: bigint) => granted.includes(flag) }), + send, +}); + +const allPerms = [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, +]; + +const setup = (channel: ReturnType, options: Record) => { + const guild = { + id: 'guild-1', + members: { me: {} }, + client: { user: {} }, + channels: { + cache: new Collection([[channel.id, channel]]), + fetch: async () => channel, + }, + }; + const fake = fakeInteraction({ + commandName: 'post', + options: { channel: { id: channel.id }, ...options }, + guild, + }); + return fake; +}; + +test('send failure replies with an error and neither succeeds nor audits', async () => { + const sink = fakeAuditClient('audit-1', [post]); + const fake = setup( + target(async () => { + throw new Error('boom'); + }, allPerms), + { content: 'hi' } + ); + await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + const reply = fake.calls.at(-1)!; + assert.equal(reply.method, 'editReply'); + assert.match(reply.payload.content, /error was encountered/); + assert.equal(sink.sent.length, 0); +}); + +test('success audits then replies with the jump link', async () => { + const sink = fakeAuditClient('audit-1', [post]); + const sentPayloads: any[] = []; + const fake = setup( + target(async () => { + sentPayloads.push(1); + return { url: 'https://discord.com/channels/g/c/m' }; + }, allPerms), + { content: 'hello world', attachment: { name: 'pic.png' } } + ); + await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + assert.equal(sentPayloads.length, 1); + assert.equal(sink.sent.length, 1); + const embed = auditJson(sink.sent); + const names = embed.fields.map((f: any) => f.name); + assert.deepEqual(names, ['Channel', 'Content', 'Attachment', 'Message']); + assert.equal(embed.fields[2].value, 'pic.png'); + assert.equal( + fake.calls.at(-1)!.payload.content, + 'Posted in <#chan-9>: https://discord.com/channels/g/c/m' + ); +}); + +test('missing permission replies clearly and does not send', async () => { + const sink = fakeAuditClient('audit-1', [post]); + let sends = 0; + const fake = setup( + target(async () => { + sends += 1; + return { url: 'x' }; + }, [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]), + { attachment: { name: 'pic.png' } } + ); + await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + assert.equal(sends, 0); + assert.equal(sink.sent.length, 0); + assert.match(fake.calls.at(-1)!.payload.content, /AttachFiles/); +}); + +test('requires content or attachment and limits content length', async () => { + const sink = fakeAuditClient('audit-1', [post]); + const fake = setup(target(async () => ({ url: 'x' }), allPerms), {}); + await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + assert.match(fake.calls.at(-1)!.payload.content, /at least text or an attachment/); + + const content = post.toJSON().options!.find((o: any) => o.name === 'content') as any; + assert.equal(content.max_length, 2000); + const channel = post.toJSON().options!.find((o: any) => o.name === 'channel') as any; + assert.deepEqual(channel.channel_types, [0, 5]); +}); From 594a27c4bef563747986cb1ade05c8157c9b71ac Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:47:56 +0100 Subject: [PATCH 10/77] Add BirthdayService and DataCheckService, rework birthday commands, profile and lifecycle events Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildCreate.ts | 18 +- src/events/guildDelete.ts | 10 +- src/events/ready.ts | 41 ++-- src/services/BirthdayService.ts | 202 ++++++++++++++++++ src/services/DataCheckService.ts | 90 ++++++++ .../Config/Subcommands/birthdays.ts | 76 +++++-- src/slash-commands/Info/mybirthday.ts | 139 ++++++------ src/slash-commands/Info/profile.ts | 38 ++-- src/utilities/BirthdayManager.ts | 182 ---------------- src/utilities/ConfigService.ts | 9 + src/utilities/DataCheck.ts | 36 ---- src/utilities/UserProfileService.ts | 53 +++-- tests/birthdays/BirthdayService.test.ts | 144 +++++++++++++ tests/birthdays/commands.test.ts | 112 ++++++++++ tests/datacheck/DataCheckService.test.ts | 114 ++++++++++ tests/fakes/guild.ts | 145 +++++++++++++ 16 files changed, 1049 insertions(+), 360 deletions(-) create mode 100644 src/services/BirthdayService.ts create mode 100644 src/services/DataCheckService.ts delete mode 100644 src/utilities/BirthdayManager.ts delete mode 100644 src/utilities/DataCheck.ts create mode 100644 tests/birthdays/BirthdayService.test.ts create mode 100644 tests/birthdays/commands.test.ts create mode 100644 tests/datacheck/DataCheckService.test.ts create mode 100644 tests/fakes/guild.ts diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 924ce5f7..4711ea84 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -5,20 +5,8 @@ import { Logger } from '../utilities/Logger'; export default defineEvent({ name: 'guildCreate', run: async (client, guild) => { - ConfigService.getConfig(guild.id) - .then((config) => { - if (config) { - return Logger.writeLog(`Bot added to new guild: ${guild.name} (${guild.id}).`); - } - return Logger.writeError( - `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).` - ); - }) - .catch((err) => { - return Logger.writeError( - `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).`, - err - ); - }); + const config = await ConfigService.getConfig(guild.id); + await ConfigService.resetAccessFailures(config); + Logger.info('Bot added to guild', { guild: guild.id, name: guild.name }); }, }); diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 456da29a..0d637de1 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -6,9 +6,11 @@ export default defineEvent({ name: 'guildDelete', run: async (client, guild) => { const result = await ConfigService.purgeGuild(guild.id); - - Logger.writeLog(`Bot removed from guild: ${guild.name} (${guild.id}).`); - Logger.writeLog(`Config deleted: ${result.config}.`); - Logger.writeLog(`User profiles deleted: ${result.profiles}.`); + Logger.info('Bot removed from guild', { + guild: guild.id, + name: guild.name, + config: result.config, + profiles: result.profiles, + }); }, }); diff --git a/src/events/ready.ts b/src/events/ready.ts index 8cdde8a4..53b61d5d 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,32 +1,43 @@ import { env } from '../config/env'; -import schedule from 'node-schedule'; import { defineEvent } from '../framework/event'; -import { BirthdayManager } from '../utilities/BirthdayManager'; +import { registerShutdownHook } from '../framework/shutdown'; +import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService'; +import { run as runDataCheck } from '../services/DataCheckService'; +import { Scheduler } from '../services/Scheduler'; import { CommandRegistrar } from '../utilities/CommandRegistrar'; -import { DataCheck } from '../utilities/DataCheck'; import { Logger } from '../utilities/Logger'; +let schedulerInstalled = false; + export default defineEvent({ name: 'clientReady', once: true, run: async (client) => { client.user!.setPresence({ status: 'online' }); - Logger.writeLog('Online'); + Logger.info('Online'); await CommandRegistrar.register([...client.slashCommands.values()]); - const logFailure = (job: string) => (err: unknown) => - Logger.writeError(`Scheduled job failed: ${job}.`, err); - - const birthdaySchedule = schedule.scheduleJob(env.jobSchedule, () => { - DataCheck.dataCleanup(client).catch(logFailure('data cleanup')); - BirthdayManager.notifyBirthdays(client).catch(logFailure('birthday notifications')); - BirthdayManager.populateCalendars(client).catch(logFailure('birthday calendars')); - }); - if (!birthdaySchedule) { - Logger.writeError(`Could not schedule job with "${env.jobSchedule}".`); + if (schedulerInstalled) { return; } - Logger.writeLog(`Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}`); + schedulerInstalled = true; + + try { + const scheduler = new Scheduler(env.jobSchedule, env.timezone, [ + { name: 'data-check', run: () => runDataCheck(client) }, + { name: 'birthday-notifications', run: () => notifyBirthdays(client) }, + { name: 'birthday-calendars', run: () => refreshAllCalendars(client) }, + ]); + scheduler.start(); + registerShutdownHook(() => scheduler.stop()); + Logger.info('Scheduler started', { + schedule: env.jobSchedule, + timezone: env.timezone, + next: scheduler.nextInvocation()?.toISOString(), + }); + } catch (error) { + Logger.error('Could not start scheduler; scheduled jobs are disabled', undefined, error); + } }, }); diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts new file mode 100644 index 00000000..67bd617c --- /dev/null +++ b/src/services/BirthdayService.ts @@ -0,0 +1,202 @@ +import { Client } from 'discord.js'; +import { DateTime } from 'luxon'; +import { ServerConfig } from '../client/models/ServerConfig'; +import { env } from '../config/env'; +import { ConfigService } from '../utilities/ConfigService'; +import { Logger } from '../utilities/Logger'; +import { UserProfileService } from '../utilities/UserProfileService'; +import { BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates'; +import { resolveMember, resolveTextChannel } from './MemberResolver'; + +export type CalendarStatus = + | 'updated' + | 'not-configured' + | 'channel-missing' + | 'message-missing' + | 'failed'; + +export interface BirthdayOptions { + now?: DateTime; + zone?: string; +} + +const MESSAGE_LIMIT = 2000; +const SEPARATOR = '-------------'; +const LOCALE = 'en-GB'; + +export const parseCalendarPath = ( + path: string | null | undefined +): { channelId: string; messageId: string } | null => { + const [channelId, messageId, ...rest] = (path ?? '').split('/'); + return channelId && messageId && rest.length === 0 ? { channelId, messageId } : null; +}; + +export const calendarMessageUrl = (guildId: string, path: string): string => + `https://discord.com/channels/${guildId}/${path}`; + +export const buildCalendarContent = ( + profiles: readonly BirthdayProfile[], + now: DateTime, + zone: string +): string => { + const groups = upcoming(profiles, now, zone); + let content = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; + if (groups.length === 0) { + return `${content}${SEPARATOR}\nThere are no birthdays in this server, set yours with \`/mybirthday set\``; + } + content += "Here's the next birthdays in this guild!\n"; + content += 'Set yours with `/mybirthday set`\n'; + content += SEPARATOR; + for (const group of groups) { + const date = group.date.setLocale(LOCALE).toLocaleString(DateTime.DATE_FULL); + const section = `\n**${date}**\n${group.userIds.map((id) => `<@${id}>`).join('\n')}\n${SEPARATOR}`; + if (content.length + section.length > MESSAGE_LIMIT) { + break; + } + content += section; + } + return content; +}; + +export const refreshCalendar = async ( + client: Client, + config: ServerConfig, + opts: BirthdayOptions = {} +): Promise => { + const guild = config.serverId; + const target = parseCalendarPath(config.birthdayCalendarMessagePath); + if (!target) { + return 'not-configured'; + } + try { + const channel = await client.channels.fetch(target.channelId).catch((error: unknown) => { + Logger.warn('Birthday calendar channel fetch failed', { guild }, error); + return null; + }); + if (!channel || !channel.isTextBased()) { + return 'channel-missing'; + } + const message = await channel.messages.fetch(target.messageId).catch((error: unknown) => { + Logger.warn('Birthday calendar message fetch failed', { guild }, error); + return null; + }); + if (!message) { + return 'message-missing'; + } + const profiles = (await UserProfileService.getServerBirthdays(guild)).map((p) => ({ + userId: p.userId, + month: p.birthdayMonth!, + day: p.birthdayDay!, + })); + const content = buildCalendarContent( + profiles, + opts.now ?? DateTime.now(), + opts.zone ?? env.timezone + ); + await message.edit({ content, allowedMentions: { parse: [] } }); + return 'updated'; + } catch (error) { + Logger.error('Birthday calendar refresh failed', { guild }, error); + return 'failed'; + } +}; + +export const deleteCalendarMessage = async ( + client: Client, + path: string | null | undefined +): Promise => { + const target = parseCalendarPath(path); + if (!target) { + return; + } + try { + const channel = await client.channels.fetch(target.channelId); + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(target.messageId); + await message.delete(); + } + } catch { + // best effort: the message may already be gone + } +}; + +export const refreshAllCalendars = async ( + client: Client, + opts: BirthdayOptions = {} +): Promise => { + Logger.info('Refreshing birthday calendars'); + const configs = await ConfigService.getConfigs(); + for (const config of configs) { + if (!config.birthdayCalendarMessagePath) { + continue; + } + try { + const status = await refreshCalendar(client, config, opts); + if (status !== 'updated') { + Logger.warn('Birthday calendar not updated', { guild: config.serverId, status }); + } + } catch (error) { + Logger.error('Birthday calendar refresh crashed', { guild: config.serverId }, error); + } + } +}; + +const notifyGuild = async ( + client: Client, + config: ServerConfig, + now: DateTime, + zone: string +): Promise => { + const guild = + client.guilds.cache.get(config.serverId) ?? (await client.guilds.fetch(config.serverId)); + const channel = await resolveTextChannel(guild, config.announcementsChannelId!); + if (!channel) { + Logger.warn('Announcements channel unavailable', { guild: config.serverId }); + return; + } + const profiles = await UserProfileService.getServerBirthdays(config.serverId); + const ids: string[] = []; + for (const profile of profiles) { + if (!isBirthdayToday(profile.birthdayMonth!, profile.birthdayDay!, now, zone)) { + continue; + } + try { + if (await resolveMember(guild, profile.userId)) { + ids.push(profile.userId); + } + } catch (error) { + Logger.warn( + 'Could not resolve birthday member', + { guild: config.serverId, user: profile.userId }, + error + ); + } + } + if (ids.length === 0) { + return; + } + await channel.send({ + content: `Happy Birthday to ${ids.map((id) => `<@${id}>`).join(', ')}!`, + allowedMentions: { users: ids }, + }); +}; + +export const notifyBirthdays = async ( + client: Client, + opts: BirthdayOptions = {} +): Promise => { + Logger.info('Sending birthday notifications'); + const now = opts.now ?? DateTime.now(); + const zone = opts.zone ?? env.timezone; + const configs = await ConfigService.getConfigs(); + for (const config of configs) { + if (!config.announcementsChannelId) { + continue; + } + try { + await notifyGuild(client, config, now, zone); + } catch (error) { + Logger.error('Birthday notification failed', { guild: config.serverId }, error); + } + } +}; diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts new file mode 100644 index 00000000..a2d3d242 --- /dev/null +++ b/src/services/DataCheckService.ts @@ -0,0 +1,90 @@ +import { Client } from 'discord.js'; +import { env } from '../config/env'; +import { ConfigService } from '../utilities/ConfigService'; +import { Logger } from '../utilities/Logger'; +import { UserProfileService } from '../utilities/UserProfileService'; +import { + AccessOutcome, + RetentionPolicyConfig, + classifyGuildFetchError, + nextAccessState, +} from './RetentionPolicy'; + +const cleanMembers = async ( + client: Client, + serverId: string +): Promise => { + const guild = await client.guilds.fetch(serverId); + const members = await guild.members.fetch(); + if (members.size === 0) { + Logger.warn('Member list empty; skipping profile cleanup', { guild: serverId }); + return; + } + const profiles = await UserProfileService.getServerProfiles(serverId); + const departed = profiles.filter((p) => !members.has(p.userId)).map((p) => p.userId); + if (departed.length > 0) { + const removed = await UserProfileService.deleteUsers(serverId, departed); + Logger.info('Removed profiles of departed members', { guild: serverId, removed }); + } +}; + +export const run = async ( + client: Client, + now: Date = new Date(), + policy: RetentionPolicyConfig = { + minFailures: env.purgeMinFailures, + graceDays: env.purgeGraceDays, + } +): Promise => { + Logger.info('Running data check'); + const configs = await ConfigService.getConfigs(); + for (const config of configs) { + const guild = config.serverId; + try { + let outcome: AccessOutcome = 'ok'; + try { + await client.guilds.fetch(guild); + } catch (error) { + outcome = classifyGuildFetchError(error); + Logger.warn('Guild fetch failed', { guild, outcome }, error); + } + + const next = nextAccessState( + { + accessFailureCount: config.accessFailureCount, + firstAccessFailureAt: config.firstAccessFailureAt, + }, + outcome, + now, + policy + ); + + if (next.purge) { + const result = await ConfigService.purgeGuild(guild); + Logger.warn('Purged guild after sustained loss of access', { + guild, + failures: next.accessFailureCount, + config: result.config, + profiles: result.profiles, + }); + continue; + } + + if ( + next.accessFailureCount !== config.accessFailureCount || + next.firstAccessFailureAt?.getTime() !== config.firstAccessFailureAt?.getTime() + ) { + config.accessFailureCount = next.accessFailureCount; + config.firstAccessFailureAt = next.firstAccessFailureAt; + await config.save(); + } + + if (outcome === 'ok') { + await cleanMembers(client, guild); + } + } catch (error) { + Logger.error('Data check failed for guild', { guild }, error); + } + } + Logger.info('Data check complete'); +}; diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index c6b1e70d..5d3bfdda 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -1,5 +1,11 @@ +import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { defineSubcommandGroup } from '../../../framework/command'; -import { BirthdayManager } from '../../../utilities/BirthdayManager'; +import { + calendarMessageUrl, + deleteCalendarMessage, + refreshCalendar, +} from '../../../services/BirthdayService'; +import { resolveTextChannel } from '../../../services/MemberResolver'; export const BirthdaysGroup = defineSubcommandGroup({ name: 'birthdays', @@ -8,44 +14,84 @@ export const BirthdaysGroup = defineSubcommandGroup({ { name: 'create', description: 'Creates or recreates a birthday calendar for this server', + defer: 'ephemeral', options: (sub) => sub.addChannelOption((opt) => opt .setName('channel') .setDescription('Channel to create the birthday calendar in') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) .setRequired(true) ), run: async (ctx) => { const { id } = ctx.interaction.options.getChannel('channel', true); - const channel = ctx.guild.channels.cache.get(id); - - if (!channel || !channel.isTextBased()) { - await ctx.reply({ content: 'Provided channel is not a text channel', ephemeral: false }); + const channel = await resolveTextChannel(ctx.guild, id); + if (!channel) { + await ctx.reply('I cannot post in that channel. Pick a text channel.'); + return; + } + const me = ctx.guild.members.me; + const perms = me ? channel.permissionsFor(me) : null; + if ( + !perms?.has(PermissionFlagsBits.ViewChannel) || + !perms.has(PermissionFlagsBits.SendMessages) + ) { + await ctx.reply('I need permission to view and send messages in that channel.'); return; } - const birthdaysCalendar = await channel.send({ + const client = ctx.interaction.client; + await deleteCalendarMessage(client, ctx.config.birthdayCalendarMessagePath); + + const message = await channel.send({ content: 'Placeholder calendar message - populating...', + allowedMentions: { parse: [] }, }); - ctx.config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; + const path = `${message.channelId}/${message.id}`; + ctx.config.birthdayCalendarMessagePath = path; await ctx.config.save(); - await BirthdayManager.populateCalendars(ctx.interaction.client, ctx.guild.id); - await ctx.reply('Birthday calendar has been created.'); + const status = await refreshCalendar(client, ctx.config); + const link = calendarMessageUrl(ctx.guild.id, path); + await ctx.reply( + status === 'updated' + ? `Birthday calendar has been created: ${link}` + : `Birthday calendar message was created (${link}) but could not be populated yet; try \`/config birthdays sync\`.` + ); }, }, { name: 'sync', description: 'Syncs the birthday calendar for this server', + defer: 'ephemeral', + run: async (ctx) => { + const status = await refreshCalendar(ctx.interaction.client, ctx.config); + const replies = { + updated: `Calendar successfully synchronised for ${ctx.guild.name}.`, + 'not-configured': + 'The birthday calendar is not configured. Use `/config birthdays create`.', + 'channel-missing': + 'The calendar channel is missing or inaccessible. Recreate it with `/config birthdays create`.', + 'message-missing': + 'The calendar message is missing. Recreate it with `/config birthdays create`.', + failed: 'An error occurred syncing the calendar. Try again later.', + } as const; + await ctx.reply(replies[status]); + }, + }, + { + name: 'remove', + description: 'Removes the birthday calendar for this server', + defer: 'ephemeral', run: async (ctx) => { - try { - await BirthdayManager.populateCalendars(ctx.interaction.client, ctx.config.serverId); - } catch { - await ctx.reply('An error ocurred running the calendar sync for this server.'); + if (!ctx.config.birthdayCalendarMessagePath) { + await ctx.reply('There is no birthday calendar configured.'); return; } - - await ctx.reply(`Calendar successfully synchronised for ${ctx.guild.name}.`); + await deleteCalendarMessage(ctx.interaction.client, ctx.config.birthdayCalendarMessagePath); + ctx.config.birthdayCalendarMessagePath = null; + await ctx.config.save(); + await ctx.reply('Birthday calendar removed.'); }, }, ], diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index 1ca978e9..6fa82125 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -1,74 +1,83 @@ import { DateTime } from 'luxon'; -import { defineCommand } from '../../framework/command'; -import { BirthdayManager } from '../../utilities/BirthdayManager'; +import { env } from '../../config/env'; +import { CommandContext, defineCommand } from '../../framework/command'; +import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates'; +import { refreshCalendar } from '../../services/BirthdayService'; +import { Logger } from '../../utilities/Logger'; import { UserProfileService } from '../../utilities/UserProfileService'; -export default defineCommand({ - name: 'mybirthday', - description: 'Set your birthday in this server', - options: (b) => - b - .addNumberOption((opt) => - opt - .setName('day') - .setDescription('Day of the month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(31) - ) - .addNumberOption((opt) => - opt - .setName('month') - .setDescription('Month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(12) - ), - run: async (ctx) => { - const cmd = ctx.interaction; - let day = cmd.options.getNumber('day', true); - const month = cmd.options.getNumber('month', true); - - let alteredForLeap = false; - - const now = DateTime.local(); - - if (!now.isInLeapYear && day === 29 && month === 2) { - day--; - alteredForLeap = true; - } - - let nextDate = DateTime.local(now.year, month, day); - - if (nextDate <= now) { - nextDate = nextDate.plus({ - year: 1, - }); - if (nextDate.isInLeapYear && alteredForLeap) { - nextDate = nextDate.plus({ - day: 1, - }); - } - } - - if (!nextDate.isValid) { - await ctx.reply( - 'It looks like that date was invalid, make sure a valid day and month were given' - ); - return; +const refresh = async (ctx: CommandContext): Promise => { + try { + const status = await refreshCalendar(ctx.interaction.client, ctx.config); + if (status !== 'updated' && status !== 'not-configured') { + Logger.warn('Birthday calendar not refreshed', { guild: ctx.guild.id, status }); } + } catch (error) { + Logger.error('Birthday calendar refresh failed', { guild: ctx.guild.id }, error); + } +}; - const guildId = ctx.guild.id; - const userProfile = await UserProfileService.getUserProfile(guildId, cmd.user.id); +export default defineCommand({ + name: 'mybirthday', + description: 'Manage your birthday in this server', + subcommands: [ + { + name: 'set', + description: 'Set your birthday in this server', + options: (sub) => + sub + .addIntegerOption((opt) => + opt + .setName('day') + .setDescription('Day of the month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(31) + ) + .addIntegerOption((opt) => + opt + .setName('month') + .setDescription('Month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(12) + ), + run: async (ctx) => { + const day = ctx.interaction.options.getInteger('day', true); + const month = ctx.interaction.options.getInteger('month', true); - userProfile.birthdayDay = day; - userProfile.birthdayMonth = month; + if (!isValidBirthday(month, day)) { + await ctx.reply('That date is invalid; check the day and month and try again.'); + return; + } - await userProfile.save(); + await UserProfileService.setBirthday(ctx.guild.id, ctx.interaction.user.id, month, day); - await ctx.reply( - `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!` - ); - await BirthdayManager.populateCalendars(cmd.client, guildId); - }, + const now = DateTime.now(); + const next = nextOccurrence(month, day, now, env.timezone); + const isToday = next.hasSame(now.setZone(env.timezone), 'day'); + const text = isToday + ? 'today 🎉' + : next.setLocale('en-GB').toLocaleString(DateTime.DATE_FULL); + await ctx.reply(`Saved! Your next birthday is ${text}`); + await refresh(ctx); + }, + }, + { + name: 'clear', + description: 'Remove your birthday from this server', + run: async (ctx) => { + const cleared = await UserProfileService.clearBirthday( + ctx.guild.id, + ctx.interaction.user.id + ); + await ctx.reply( + cleared ? 'Your birthday has been removed.' : "You don't have a birthday set." + ); + if (cleared) { + await refresh(ctx); + } + }, + }, + ], }); diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index 9efaa1ed..8a5a6e8f 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -1,6 +1,8 @@ +import { EmbedBuilder } from 'discord.js'; +import { DateTime } from 'luxon'; import { defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; +import { resolveMember } from '../../services/MemberResolver'; import { UserProfileService } from '../../utilities/UserProfileService'; export default defineCommand({ @@ -13,27 +15,37 @@ export default defineCommand({ ), run: async (ctx) => { const user = ctx.interaction.options.getUser('member', true); - const guild = ctx.guild; - const member = guild.members.cache.get(user.id); + const member = await resolveMember(ctx.guild, user); if (!member) { - await ctx.reply('Could not find that member in this server.'); + await ctx.reply("That user isn't a member of this server."); return; } - const userProfile = await UserProfileService.getUserProfile(guild.id, member.id); + const profile = await UserProfileService.findUserProfile(ctx.guild.id, member.id); + const birthday = + profile?.birthdayMonth && profile.birthdayDay + ? DateTime.utc(2024, profile.birthdayMonth, profile.birthdayDay) + .setLocale('en-GB') + .toFormat('d MMMM') + : null; - const embed = new EmbedCompatLayer() + const embed = new EmbedBuilder() .setThumbnail(member.displayAvatarURL()) .setColor(EmbedColours.info) .setTitle('User Profile') .setTimestamp() - .addField('Member', member.toString()) - .addField('Nickname', member.nickname ? member.nickname : 'Not set') - .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) - .addField('Joined', ``) - .addField('Screening', member.pending ? 'Not completed' : 'Passed') - .addField('Activity Score', userProfile ? userProfile.activityScore.toString() : 'Not found') - .addField('ID', member.user.id); + .addFields( + { name: 'Member', value: member.toString() }, + { name: 'Nickname', value: member.nickname ?? 'Not set' }, + { name: 'Username', value: member.user.username }, + { name: 'Joined', value: `` }, + { name: 'Onboarding', value: member.pending ? 'Not completed' : 'Completed' }, + { name: 'Activity Score', value: String(profile?.activityScore ?? 0) } + ); + if (birthday) { + embed.addFields({ name: 'Birthday', value: birthday }); + } + embed.addFields({ name: 'ID', value: member.id }); await ctx.reply({ embeds: [embed] }); }, diff --git a/src/utilities/BirthdayManager.ts b/src/utilities/BirthdayManager.ts deleted file mode 100644 index b66bea68..00000000 --- a/src/utilities/BirthdayManager.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { NovaClient } from '../client/NovaClient'; -import { Client, Guild, GuildMember, TextChannel } from 'discord.js'; -import { DateTime } from 'luxon'; -import { ConfigService } from './ConfigService'; -import { UserProfileService } from './UserProfileService'; -import { Logger } from './Logger'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { UserProfile } from '../client/models/UserProfile'; - -export class BirthdayManager { - public static async populateCalendars(client: Client, serverId?: string): Promise { - Logger.writeLog('Running birthday calendar update job.'); - - let parsedConfigs: ServerConfig[] = []; - - if (serverId) { - const serverConfig = await ConfigService.getConfig(serverId).catch((err) => { - return Logger.writeError('Error fetching server config for single run.', err); - }); - if (serverConfig) { - parsedConfigs = [serverConfig]; - } - } else { - const serverConfigs = await ConfigService.getConfigs().catch((err) => { - return Logger.writeError('Error fetching server configs for bulk run.', err); - }); - if (serverConfigs) { - parsedConfigs = serverConfigs; - } - } - - if (parsedConfigs.length < 1) { - return Logger.writeLog('No server configs to run birthday calendar population for.'); - } - - parsedConfigs = parsedConfigs.filter((config) => config.birthdayCalendarMessagePath); - - for (const config of parsedConfigs) { - const profiles = await UserProfileService.getServerBirthdays(config.serverId); - const [channelId, messageId] = config.birthdayCalendarMessagePath!.split('/'); - let messageContent = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; - - const chanToEdit = await client.channels.fetch(channelId); - if (!chanToEdit || !chanToEdit.isTextBased()) { - Logger.writeError(`Could not find birthday channel for server ${config.serverId}`); - } - - const birthdayMessage = await (chanToEdit as TextChannel).messages.fetch(messageId); - if (!birthdayMessage) { - Logger.writeError(`Could not find birthday message for server ${config.serverId}`); - } - - if (profiles.length < 1) { - messageContent += '-------------'; - messageContent += 'There are no birthdays in this server, set yours with `/mybirthday`'; - } else { - const mapped = profiles.map((u) => { - let alteredForLeap = false; - - const now = DateTime.local(); - - // getServerBirthdays only returns profiles with a month and day set - const month = u.birthdayMonth!; - let day = u.birthdayDay!; - - if (!now.isInLeapYear && day === 29 && month === 2) { - day--; - alteredForLeap = true; - } - - let nextDate = DateTime.local(now.year, month, day); - - if (nextDate <= now) { - nextDate = nextDate.plus({ - year: 1, - }); - if (nextDate.isInLeapYear && alteredForLeap) { - nextDate = nextDate.plus({ - day: 1, - }); - } - } - - return { - userId: u.userId, - birthday: nextDate, - }; - }); - - const sorted = mapped.sort((a, b) => a.birthday.toMillis() - b.birthday.toMillis()).slice(0, 10); - const groupedSort = Object.groupBy(sorted, (o) => o.birthday.toString()); - - messageContent += "Here's the next 10 birthdays in this guild!\n"; - messageContent += 'Add your birthday using `/mybirthday`\n'; - messageContent += '-------------'; - - for (const group of Object.values(groupedSort)) { - if (!group) { - continue; - } - const date = group[0].birthday.toLocaleString(DateTime.DATE_FULL); - let members = ''; - - group.forEach((m) => { - members += `<@${m.userId}>\n`; - }); - messageContent += `\n**${date}**\n${members}`; - messageContent += '-------------'; - } - } - - await birthdayMessage.edit({ - content: messageContent, - allowedMentions: { - users: [], - }, - }); - } - - Logger.writeLog('Finished birthday calendar update job.'); - } - - public static async notifyServerBirthdays( - client: NovaClient, - profiles: UserProfile[], - config: ServerConfig - ) { - if (config && config.announcementsChannelId) { - const server: Guild | undefined = client.guilds.cache.get(config.serverId); - if (!server) { - return Logger.writeError(`Server missing with id ${config.serverId}`); - } - - const announcementsChannel = server.channels.cache.get(config.announcementsChannelId); - if (!announcementsChannel) { - return Logger.writeError(`Announcements Channel missing for: ${server.toString()}`); - } - - if (!announcementsChannel.isTextBased()) { - return Logger.writeError(`Channel not text based for ${server.toString()}`); - } - - let tags = ''; - - for (const user of profiles) { - const member: GuildMember = await server.members.fetch(user.userId); - if (member) { - tags += `${member.toString()}, `; - } - } - - if (tags) { - tags = tags.replace(/,\s*$/, ''); - const msg = `Happy Birthday to ${tags}!`; - announcementsChannel.send(msg).catch((err) => { - return Logger.writeError('Error sending Birthday message', err); - }); - } - } - } - - public static async notifyBirthdays(client: NovaClient): Promise { - Logger.writeLog('Running birthday notifications job.'); - - const profiles = await UserProfileService.getBirthdaysToday(); - const usersWithBirthdaysByServer = Object.groupBy(profiles, (p) => p.serverId); - - for (const [server, serverProfiles] of Object.entries(usersWithBirthdaysByServer)) { - if (!serverProfiles) { - continue; - } - const config = await ConfigService.getConfig(server); - try { - await this.notifyServerBirthdays(client, serverProfiles, config); - } catch (err) { - Logger.writeError(`Error sending birthday notifications for server ${server}.`, err); - } - } - - Logger.writeLog('Finished birthday notifications job.'); - } -} diff --git a/src/utilities/ConfigService.ts b/src/utilities/ConfigService.ts index 925f2eef..c61eff21 100644 --- a/src/utilities/ConfigService.ts +++ b/src/utilities/ConfigService.ts @@ -27,6 +27,15 @@ export class ConfigService { return recordsDeleted > 0; } + public static async resetAccessFailures(config: ServerConfig): Promise { + if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { + return; + } + config.accessFailureCount = 0; + config.firstAccessFailureAt = null; + await config.save(); + } + public static async purgeGuild( serverId: string, sequelize?: Sequelize diff --git a/src/utilities/DataCheck.ts b/src/utilities/DataCheck.ts deleted file mode 100644 index 93ad3514..00000000 --- a/src/utilities/DataCheck.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NovaClient } from '../client/NovaClient'; -import { ConfigService } from './ConfigService'; -import { Logger } from './Logger'; -import { UserProfileService } from './UserProfileService'; - -export class DataCheck { - public static async dataCleanup(client: NovaClient): Promise { - Logger.writeLog('Running data checkup job.'); - - const servers = await ConfigService.getConfigs(); - const groupedSrvs = Object.groupBy(servers, (s) => s.serverId); - - for (const srv in groupedSrvs) { - const guild = await client.guilds.fetch(srv).catch(async (err) => { - if (err.code === 50001) { - Logger.writeLog(`No longer have access to guild: ${srv}.`); - await ConfigService.deleteConfig(srv); - await UserProfileService.deleteUsersByServer(srv); - } - }); - - if (guild) { - const users = await UserProfileService.getServerBirthdays(guild.id); - const guildUsers = await guild.members.fetch(); - - for (const u of users) { - if (!guildUsers.find((gu) => gu.user.id === u.userId)) { - await UserProfileService.deleteUser(u.serverId, u.userId); - } - } - } - } - - Logger.writeLog('Data checkup job complete.'); - } -} diff --git a/src/utilities/UserProfileService.ts b/src/utilities/UserProfileService.ts index f68e9568..7cc7b17c 100644 --- a/src/utilities/UserProfileService.ts +++ b/src/utilities/UserProfileService.ts @@ -1,22 +1,7 @@ -import { DateTime } from 'luxon'; import { Op } from 'sequelize'; import { UserProfile } from '../client/models/UserProfile'; export class UserProfileService { - public static async getBirthdaysToday(): Promise { - const today = DateTime.local(); - const includeLeapDayInNoneLeapYear = - !today.isInLeapYear && today.day === 28 && today.month === 2; - const day = includeLeapDayInNoneLeapYear ? [28, 29] : today.day; - - return UserProfile.findAll({ - where: { - birthdayDay: day, - birthdayMonth: today.month, - }, - }); - } - public static async getServerBirthdays(serverId: string): Promise { return UserProfile.findAll({ where: { @@ -31,6 +16,44 @@ export class UserProfileService { }); } + public static async getServerProfiles(serverId: string): Promise { + return UserProfile.findAll({ where: { serverId } }); + } + + public static async setBirthday( + serverId: string, + userId: string, + month: number, + day: number + ): Promise { + const profile = await this.getUserProfile(serverId, userId); + profile.birthdayMonth = month; + profile.birthdayDay = day; + await profile.save(); + } + + public static async clearBirthday(serverId: string, userId: string): Promise { + const profile = await this.findUserProfile(serverId, userId); + if (!profile) { + return false; + } + profile.birthdayYear = null; + profile.birthdayMonth = null; + profile.birthdayDay = null; + await profile.save(); + return true; + } + + public static async deleteUsers(serverId: string, userIds: string[]): Promise { + let removed = 0; + for (let i = 0; i < userIds.length; i += 500) { + removed += await UserProfile.destroy({ + where: { serverId, userId: { [Op.in]: userIds.slice(i, i + 500) } }, + }); + } + return removed; + } + public static async getUserProfile(serverId: string, userId: string): Promise { const [profiles] = await UserProfile.findOrCreate({ where: { diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts new file mode 100644 index 00000000..49fe5808 --- /dev/null +++ b/tests/birthdays/BirthdayService.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import { DateTime } from 'luxon'; +import type { Sequelize } from 'sequelize-typescript'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService'; +import { + apiError, + fakeClient, + fakeGuildWithMembers, + fakeMessage, + fakeTextChannel, +} from '../fakes/guild'; +import { createTestDb } from '../helpers/db'; + +console.warn = (): void => undefined; +console.error = (): void => undefined; +console.log = (): void => undefined; + +const zone = 'Europe/London'; +const at = (iso: string): DateTime => DateTime.fromISO(iso, { zone }); + +let db: Sequelize; + +before(async () => { + db = await createTestDb(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(async () => { + await UserProfile.destroy({ where: {} }); + await ServerConfig.destroy({ where: {} }); +}); + +const makeConfig = (fields: Record = {}): Promise => + ServerConfig.create({ serverId: 'g1', ...fields }); + +test('refreshCalendar: not configured', async () => { + const config = await makeConfig(); + assert.equal(await refreshCalendar(fakeClient(), config, { zone }), 'not-configured'); +}); + +test('refreshCalendar: channel fetch rejecting keeps the stored path', async () => { + const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const client = fakeClient({ channelFetchError: apiError(50001) }); + assert.equal(await refreshCalendar(client, config, { zone }), 'channel-missing'); + assert.equal(config.birthdayCalendarMessagePath, 'c1/m1'); +}); + +test('refreshCalendar: message missing', async () => { + const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const client = fakeClient({ channels: [fakeTextChannel('c1')] }); + assert.equal(await refreshCalendar(client, config, { zone }), 'message-missing'); +}); + +test('refreshCalendar: edit failure reports failed', async () => { + const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const message = fakeMessage('m1'); + message.edit = async () => { + throw new Error('boom'); + }; + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + assert.equal(await refreshCalendar(client, config, { zone }), 'failed'); +}); + +test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', async () => { + const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + await UserProfile.bulkCreate([ + { serverId: 'g1', userId: 'leap', birthdayMonth: 2, birthdayDay: 29 }, + { serverId: 'g1', userId: 'today', birthdayMonth: 2, birthdayDay: 27 }, + { serverId: 'g1', userId: 'nobday' }, + ]); + const message = fakeMessage('m1'); + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + + const status = await refreshCalendar(client, config, { zone, now: at('2027-02-27T10:00') }); + assert.equal(status, 'updated'); + const [edit] = message.edits; + assert.deepEqual(edit.allowedMentions, { parse: [] }); + assert.match(edit.content, /Upcoming Birthdays/); + assert.match(edit.content, /Set yours with `\/mybirthday set`/); + assert.match(edit.content, /\*\*27 February 2027\*\*\n<@today>/); + assert.match(edit.content, /\*\*28 February 2027\*\*\n<@leap>/); + assert.doesNotMatch(edit.content, /nobday/); + assert.ok(edit.content.indexOf('<@today>') < edit.content.indexOf('<@leap>')); +}); + +test('refreshCalendar: empty state', async () => { + const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const message = fakeMessage('m1'); + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + assert.equal(await refreshCalendar(client, config, { zone }), 'updated'); + assert.match(message.edits[0].content, /There are no birthdays in this server/); +}); + +test('notifyBirthdays: skips departed members and mentions only present ones', async () => { + await makeConfig({ announcementsChannelId: 'ann' }); + await UserProfile.bulkCreate([ + { serverId: 'g1', userId: 'here', birthdayMonth: 6, birthdayDay: 1 }, + { serverId: 'g1', userId: 'gone', birthdayMonth: 6, birthdayDay: 1 }, + { serverId: 'g1', userId: 'other', birthdayMonth: 6, birthdayDay: 2 }, + ]); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here', 'other'], channels: [channel] }); + const client = fakeClient({ guilds: { g1: guild } }); + + await notifyBirthdays(client, { zone, now: at('2027-06-01T09:00') }); + assert.equal(channel.sent.length, 1); + assert.equal(channel.sent[0].content, 'Happy Birthday to <@here>!'); + assert.deepEqual(channel.sent[0].allowedMentions, { users: ['here'] }); +}); + +test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async () => { + await makeConfig({ announcementsChannelId: 'ann' }); + await UserProfile.bulkCreate([ + { serverId: 'g1', userId: 'a', birthdayMonth: 2, birthdayDay: 29 }, + { serverId: 'g1', userId: 'b', birthdayMonth: 2, birthdayDay: 28 }, + ]); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a', 'b'], channels: [channel] }); + const client = fakeClient({ guilds: { g1: guild } }); + + await notifyBirthdays(client, { zone, now: at('2027-02-28T09:00') }); + assert.equal(channel.sent.length, 1); + assert.match(channel.sent[0].content, /<@a>/); + assert.match(channel.sent[0].content, /<@b>/); + + channel.sent.length = 0; + await notifyBirthdays(client, { zone, now: at('2027-03-01T09:00') }); + assert.equal(channel.sent.length, 0); +}); + +test('notifyBirthdays: no announcements channel sends nothing', async () => { + await makeConfig(); + await UserProfile.create({ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'], channels: [channel] }); + await notifyBirthdays(fakeClient({ guilds: { g1: guild } }), { zone, now: at('2027-06-01T09:00') }); + assert.equal(channel.sent.length, 0); +}); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts new file mode 100644 index 00000000..f3e469fc --- /dev/null +++ b/tests/birthdays/commands.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import type { Sequelize } from 'sequelize-typescript'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import { initEnv } from '../../src/config/env'; +import { CommandContext, createReply } from '../../src/framework/command'; +import mybirthday from '../../src/slash-commands/Info/mybirthday'; +import profile from '../../src/slash-commands/Info/profile'; +import { fakeClient, fakeGuildWithMembers } from '../fakes/guild'; +import { fakeInteraction } from '../fakes/interaction'; +import { createTestDb } from '../helpers/db'; + +initEnv({ TOKEN: 't', CLIENT_ID: 'c', DB_PATH: ':memory:', BOT_TIMEZONE: 'Europe/London' }); + +console.warn = (): void => undefined; +console.error = (): void => undefined; +console.log = (): void => undefined; + +let db: Sequelize; + +before(async () => { + db = await createTestDb(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(async () => { + await UserProfile.destroy({ where: {} }); + await ServerConfig.destroy({ where: {} }); +}); + +const exec = async ( + command: typeof mybirthday, + opts: { subcommand?: string; options?: Record; guild?: unknown } +) => { + const config = await ServerConfig.create({ serverId: 'guild-1' }); + const fake = fakeInteraction({ subcommand: opts.subcommand, options: opts.options }); + const raw = fake.interaction as any; + raw.options.getInteger = raw.options.getNumber; + raw.client = fakeClient(); + const guild = opts.guild ?? { id: 'guild-1', name: 'Test Guild' }; + raw.guild = guild; + const resolved = command.resolve(fake.interaction)!; + const ctx = { + interaction: fake.interaction, + guild, + member: raw.member, + config, + reply: createReply(fake.interaction), + } as unknown as CommandContext; + await resolved.run(ctx); + return fake.calls; +}; + +const lastContent = (calls: { payload: any }[]): string => calls[calls.length - 1].payload.content; + +test('mybirthday set stores Feb 29 as 29', async () => { + const calls = await exec(mybirthday, { subcommand: 'set', options: { day: 29, month: 2 } }); + const stored = await UserProfile.findOne({ where: { userId: 'user-1' } }); + assert.equal(stored?.birthdayDay, 29); + assert.equal(stored?.birthdayMonth, 2); + assert.match(lastContent(calls), /^Saved! Your next birthday is /); +}); + +test('mybirthday set rejects invalid dates', async () => { + const calls = await exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }); + assert.match(lastContent(calls), /invalid/); + assert.equal(await UserProfile.count(), 0); +}); + +test('mybirthday clear removes the birthday but keeps the profile', async () => { + await UserProfile.create({ + serverId: 'guild-1', + userId: 'user-1', + birthdayMonth: 5, + birthdayDay: 5, + activityScore: 7, + }); + await exec(mybirthday, { subcommand: 'clear' }); + const stored = await UserProfile.findOne({ where: { userId: 'user-1' } }); + assert.equal(stored?.birthdayDay, null); + assert.equal(stored?.birthdayMonth, null); + assert.equal(stored?.activityScore, 7); +}); + +test('profile replies for a non-member without creating a profile', async () => { + const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: [] }); + const calls = await exec(profile as any, { options: { member: { id: 'ghost' } }, guild }); + assert.equal(lastContent(calls), "That user isn't a member of this server."); + assert.equal(await UserProfile.count(), 0); +}); + +test('profile for a member without a profile does not create one', async () => { + const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: ['m1'] }); + const member = guild.members.cache.get('m1') as any; + Object.assign(member, { + nickname: null, + pending: true, + joinedTimestamp: 0, + user: { username: 'someone' }, + displayAvatarURL: () => 'https://example.com/a.png', + }); + const calls = await exec(profile as any, { options: { member: { id: 'm1' } }, guild }); + const embed = calls[calls.length - 1].payload.embeds[0].toJSON(); + const field = (name: string): string => embed.fields.find((f: any) => f.name === name).value; + assert.equal(field('Onboarding'), 'Not completed'); + assert.equal(field('Activity Score'), '0'); + assert.equal(await UserProfile.count(), 0); +}); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts new file mode 100644 index 00000000..4e158dcf --- /dev/null +++ b/tests/datacheck/DataCheckService.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import type { Sequelize } from 'sequelize-typescript'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import { UserProfile } from '../../src/client/models/UserProfile'; +import { run } from '../../src/services/DataCheckService'; +import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild'; +import { createTestDb } from '../helpers/db'; + +console.warn = (): void => undefined; +console.error = (): void => undefined; +console.log = (): void => undefined; + +const policy = { minFailures: 3, graceDays: 7 }; +const DAY = 24 * 60 * 60 * 1000; +const t0 = new Date('2027-01-01T00:00:00Z'); + +let db: Sequelize; + +before(async () => { + db = await createTestDb(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(async () => { + await UserProfile.destroy({ where: {} }); + await ServerConfig.destroy({ where: {} }); +}); + +const reload = (id: string): Promise => ServerConfig.findByPk(id); + +test('transient errors never purge or change counters', async () => { + await ServerConfig.create({ serverId: 'g1', accessFailureCount: 5, firstAccessFailureAt: t0 }); + await UserProfile.create({ serverId: 'g1', userId: 'a' }); + const client = fakeClient({ guilds: { g1: apiError(500) } }); + await run(client, new Date(t0.getTime() + 30 * DAY), policy); + const config = await reload('g1'); + assert.equal(config?.accessFailureCount, 5); + assert.equal(await UserProfile.count(), 1); +}); + +test('gone guild is purged only after min failures and grace period', async () => { + await ServerConfig.create({ serverId: 'g1' }); + await UserProfile.create({ serverId: 'g1', userId: 'a' }); + const client = fakeClient({ guilds: { g1: apiError(50001) } }); + + await run(client, t0, policy); + await run(client, new Date(t0.getTime() + 3 * DAY), policy); + await run(client, new Date(t0.getTime() + 6 * DAY), policy); + const config = await reload('g1'); + assert.equal(config?.accessFailureCount, 3); + assert.equal(await UserProfile.count(), 1); + + await run(client, new Date(t0.getTime() + 7 * DAY), policy); + assert.equal(await reload('g1'), null); + assert.equal(await UserProfile.count(), 0); +}); + +test('guild missing from the client entirely counts as gone', async () => { + await ServerConfig.create({ serverId: 'g1' }); + await run(fakeClient(), t0, policy); + assert.equal((await reload('g1'))?.accessFailureCount, 1); +}); + +test('a successful fetch resets counters', async () => { + await ServerConfig.create({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); + await run(fakeClient({ guilds: { g1: guild } }), new Date(t0.getTime() + DAY), policy); + const config = await reload('g1'); + assert.equal(config?.accessFailureCount, 0); + assert.equal(config?.firstAccessFailureAt, null); +}); + +test('profiles of departed members are deleted, including non-birthday ones', async () => { + await ServerConfig.create({ serverId: 'g1' }); + await UserProfile.bulkCreate([ + { serverId: 'g1', userId: 'here' }, + { serverId: 'g1', userId: 'gone-plain' }, + { serverId: 'g1', userId: 'gone-bday', birthdayMonth: 1, birthdayDay: 1 }, + { serverId: 'other', userId: 'gone-plain' }, + ]); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here'] }); + await run(fakeClient({ guilds: { g1: guild } }), t0, policy); + const remaining = (await UserProfile.findAll()).map((p) => `${p.serverId}/${p.userId}`).sort(); + assert.deepEqual(remaining, ['g1/here', 'other/gone-plain']); +}); + +test('an empty member list does not wipe profiles', async () => { + await ServerConfig.create({ serverId: 'g1' }); + await UserProfile.create({ serverId: 'g1', userId: 'a' }); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: [] }); + await run(fakeClient({ guilds: { g1: guild } }), t0, policy); + assert.equal(await UserProfile.count(), 1); +}); + +test('one guild throwing does not stop the others', async () => { + await ServerConfig.bulkCreate([{ serverId: 'g1' }, { serverId: 'g2' }]); + await UserProfile.bulkCreate([ + { serverId: 'g1', userId: 'x' }, + { serverId: 'g2', userId: 'x' }, + ]); + const broken = fakeGuildWithMembers({ + id: 'g1', + memberIds: ['a'], + membersFetchError: new Error('members unavailable'), + }); + const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); + await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0, policy); + const remaining = (await UserProfile.findAll()).map((p) => p.serverId); + assert.deepEqual(remaining, ['g1']); +}); diff --git a/tests/fakes/guild.ts b/tests/fakes/guild.ts new file mode 100644 index 00000000..5eb5886f --- /dev/null +++ b/tests/fakes/guild.ts @@ -0,0 +1,145 @@ +import { Collection } from 'discord.js'; +import type { Client } from 'discord.js'; + +export const apiError = (code: number): Error => + Object.assign(new Error(`Discord error ${code}`), { code }); + +export interface FakeMessage { + id: string; + edits: any[]; + deleted: boolean; + edit(payload: unknown): Promise; + delete(): Promise; +} + +export const fakeMessage = (id: string): FakeMessage => { + const message: FakeMessage = { + id, + edits: [], + deleted: false, + edit: async (payload) => { + message.edits.push(payload); + return message; + }, + delete: async () => { + message.deleted = true; + }, + }; + return message; +}; + +export interface FakeChannel { + id: string; + sent: any[]; + messages: { fetch(id: string): Promise }; + isTextBased(): boolean; + isDMBased(): boolean; + isSendable(): boolean; + send(payload: unknown): Promise<{ id: string; channelId: string }>; +} + +export const fakeTextChannel = (id: string, messages: FakeMessage[] = []): FakeChannel => { + const channel: FakeChannel = { + id, + sent: [], + messages: { + fetch: async (messageId) => { + const found = messages.find((m) => m.id === messageId); + if (!found) { + throw apiError(10008); + } + return found; + }, + }, + isTextBased: () => true, + isDMBased: () => false, + isSendable: () => true, + send: async (payload) => { + channel.sent.push(payload); + return { id: `sent-${channel.sent.length}`, channelId: id }; + }, + }; + return channel; +}; + +export interface FakeGuildOptions { + id: string; + memberIds?: string[]; + channels?: FakeChannel[]; + membersFetchError?: Error; +} + +export const fakeMember = (id: string): { id: string; toString(): string } => ({ + id, + toString: () => `<@${id}>`, +}); + +export const fakeGuildWithMembers = (opts: FakeGuildOptions) => { + const members = new Collection>( + (opts.memberIds ?? []).map((id) => [id, fakeMember(id)]) + ); + const channels = new Collection((opts.channels ?? []).map((c) => [c.id, c])); + return { + id: opts.id, + name: `Guild ${opts.id}`, + members: { + cache: members, + fetch: async (id?: string) => { + if (opts.membersFetchError) { + throw opts.membersFetchError; + } + if (id === undefined) { + return members; + } + const found = members.get(id); + if (!found) { + throw apiError(10007); + } + return found; + }, + }, + channels: { cache: channels, fetch: async (id: string) => channels.get(id) ?? null }, + }; +}; + +export type FakeGuildWithMembers = ReturnType; + +export interface FakeClientOptions { + guilds?: Record; + channels?: FakeChannel[]; + channelFetchError?: Error; +} + +export const fakeClient = (opts: FakeClientOptions = {}): Client => { + const guilds = opts.guilds ?? {}; + const channels = opts.channels ?? []; + const cache = new Collection(); + for (const [id, value] of Object.entries(guilds)) { + if (!(value instanceof Error)) { + cache.set(id, value); + } + } + return { + guilds: { + cache, + fetch: async (id: string) => { + const value = guilds[id]; + if (value === undefined) { + throw apiError(10004); + } + if (value instanceof Error) { + throw value; + } + return value; + }, + }, + channels: { + fetch: async (id: string) => { + if (opts.channelFetchError) { + throw opts.channelFetchError; + } + return channels.find((c) => c.id === id) ?? null; + }, + }, + } as unknown as Client; +}; From 9847a00a365e180e02d0ac7b69fa6d794da949b0 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:48:06 +0100 Subject: [PATCH 11/77] Add OnboardingService and WelcomeImage; rework welcome, newroles, debug, simulate and member events Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberAdd.ts | 20 +- src/events/guildMemberUpdate.ts | 204 +----------- src/services/OnboardingService.ts | 293 ++++++++++++++++++ src/services/WelcomeImage.ts | 121 ++++++++ src/slash-commands/Admin/simulate.ts | 82 +++-- .../Config/Subcommands/debug.ts | 34 +- .../Config/Subcommands/newroles.ts | 110 ++++--- .../Config/Subcommands/welcome.ts | 168 ++++++---- tests/fakes/onboarding.ts | 117 +++++++ tests/onboarding/OnboardingService.test.ts | 181 +++++++++++ tests/onboarding/commands.test.ts | 132 ++++++++ tests/onboarding/events.test.ts | 59 ++++ 12 files changed, 1151 insertions(+), 370 deletions(-) create mode 100644 src/services/OnboardingService.ts create mode 100644 src/services/WelcomeImage.ts create mode 100644 tests/fakes/onboarding.ts create mode 100644 tests/onboarding/OnboardingService.test.ts create mode 100644 tests/onboarding/commands.test.ts create mode 100644 tests/onboarding/events.test.ts diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 74711f5b..a30b94a3 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,24 +1,16 @@ import { defineEvent } from '../framework/event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { ChannelService } from '../utilities/ChannelService'; +import { auditJoin, complete } from '../services/OnboardingService'; import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; export default defineEvent({ name: 'guildMemberAdd', run: async (client, member) => { - const serverConfig = await ConfigService.getConfig(member.guild.id); + const config = await ConfigService.getConfig(member.guild.id); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setDescription('New member joined') - .addField('ID', member.user.id) - .setTimestamp(); + await auditJoin(client, member, config); - await ChannelService.sendAuditMessage(client, serverConfig, audit); + if (!member.pending) { + await complete(client, member, config); + } }, }); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index d5a04d4e..681ae0c0 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,203 +1,23 @@ -import { GuildMember, AttachmentBuilder } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; import { defineEvent } from '../framework/event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { ChannelService } from '../utilities/ChannelService'; +import { complete, completedScreening } from '../services/OnboardingService'; import { ConfigService } from '../utilities/ConfigService'; -import { DateTime } from 'luxon'; -import { ServerConfig } from '../client/models/ServerConfig'; import { Logger } from '../utilities/Logger'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { Canvas, createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; - -const applyText = (canvas: Canvas, text: string, baseSize: number) => { - const ctx = canvas.getContext('2d'); - - let fontSize = baseSize; - - do { - fontSize--; - ctx.font = `${fontSize}px Roboto`; - } while (ctx.measureText(text).width > canvas.width - 300); - - return ctx.font; -}; - -const getWelcomeMessage = async (config: ServerConfig, member: GuildMember) => { - return (config.welcomeMessage ?? '').replace('{member}', `<@${member.id}>`); -}; - -const getWelcomeImage = async (config: ServerConfig, member: GuildMember) => { - GlobalFonts.registerFromPath(`${__dirname}/../resources/fonts/Roboto-Regular.ttf`, 'Roboto'); - - const canvas = createCanvas(700, 250); - const ctx = canvas.getContext('2d'); - if (member.joinedTimestamp === null) { - throw new Error(`Join date unavailable for member ${member.id}.`); - } - const backgroundUrl = config.welcomeMessageBackgroundUrl; - if (!backgroundUrl) { - throw new Error('Welcome message background URL is not configured.'); - } - const joinedTs = DateTime.fromMillis(member.joinedTimestamp).toLocaleString(DateTime.DATE_FULL); - - // Draw background - const background = await loadImage(backgroundUrl); - ctx.drawImage(background, 0, 0, canvas.width, canvas.height); - - // Draw Username - ctx.font = applyText(canvas, member.displayName, 48); - ctx.fillStyle = '#ffffff'; - ctx.fillText(member.displayName, 225, 125); - - // Draw Server name - ctx.font = applyText(canvas, `Welcome to ${member.guild.name}!`, 34); - ctx.fillStyle = '#ffffff'; - ctx.fillText(`Welcome to ${member.guild.name}!`, 225, 160); - - // Draw Joined at - ctx.font = applyText(canvas, `Joined: ${joinedTs}`, 20); - ctx.fillStyle = '#ffffff'; - ctx.fillText(`Joined: ${joinedTs}`, 225, 200); - - // Draw Avatar - ctx.beginPath(); - ctx.arc(125, 125, 75, 0, Math.PI * 2, true); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.lineWidth = 10; - ctx.stroke(); - ctx.closePath(); - ctx.clip(); - - const avatar = await loadImage( - member.displayAvatarURL({ - extension: 'jpg', - }) - ); - ctx.drawImage(avatar, 50, 50, 150, 150); - - const attachment = new AttachmentBuilder(await canvas.encode('png')).setName('welcome-image.png'); - - return attachment; -}; - -const addGuestRoles = async ( - serverConfig: ServerConfig, - newMember: GuildMember, - client: NovaClient -) => { - try { - const guestRoleIds = (serverConfig.guestRoleIds ?? '').split(','); - const guildRoles = await newMember.guild.roles.fetch(); - - await newMember.roles.add(guildRoles.filter((role) => guestRoleIds.includes(role.id))); - } catch (e) { - Logger.writeError( - `Adding guest roles failed in guildMemberUpdate for server: ${serverConfig.id}.`, - e - ); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Unable to provide guest role(s) to user.') - .addField('ID', newMember.user.id) - .setTimestamp(); - - return ChannelService.sendAuditMessage(client, serverConfig, audit); - } -}; - -const sendScreenAudit = async ( - serverConfig: ServerConfig, - newMember: GuildMember, - client: NovaClient -) => { - try { - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Rules accepted by member.') - .addField('ID', newMember.user.id) - .setTimestamp(); - - await ChannelService.sendAuditMessage(client, serverConfig, audit); - } catch (e) { - return Logger.writeError( - `Sending audit failed in guildMemberUpdate for server: ${serverConfig.id}.`, - e - ); - } -}; export default defineEvent({ name: 'guildMemberUpdate', run: async (client, oldMember, newMember) => { - if (newMember.partial) { - await newMember.fetch(); + if (oldMember.partial) { + Logger.debug('Skipping onboarding: previous member state unknown', { + guild: newMember.guild.id, + member: newMember.id, + }); + return; } - - const serverConfig = await ConfigService.getConfig(newMember.guild.id); - const notPassedScreen = - oldMember.pending || (oldMember.pending === null && newMember.roles.cache.size === 1); - - if (notPassedScreen && !newMember.pending) { - await sendScreenAudit(serverConfig, newMember, client); - - if (serverConfig.guestRoleIds) { - await addGuestRoles(serverConfig, newMember, client).catch((err: unknown) => - Logger.writeError( - `Guest role audit failed in guildMemberUpdate for ${serverConfig.id}.`, - err - ) - ); - } - - if ( - serverConfig.systemMessagesEnabled && - (serverConfig.welcomeMessage || serverConfig.welcomeMessageBackgroundUrl) - ) { - try { - let attachment: AttachmentBuilder | undefined; - let content: string | undefined; - if (serverConfig.welcomeMessageBackgroundUrl) { - attachment = await getWelcomeImage(serverConfig, newMember); - } - - if (serverConfig.welcomeMessage) { - content = await getWelcomeMessage(serverConfig, newMember); - } - - const systemChannel = newMember.guild.systemChannel; - if (!systemChannel) { - throw new Error(`No system channel for guild ${newMember.guild.id}.`); - } - await systemChannel.send({ - content, - files: attachment ? [attachment] : undefined, - }); - } catch (e) { - Logger.writeError( - `Sending welcome message failed in guildMemberUpdate for server: ${serverConfig.serverId}.`, - e - ); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Unable to send welcome message.') - .addField('ID', newMember.user.id) - .setTimestamp(); - await ChannelService.sendAuditMessage(client, serverConfig, audit); - } - } + if (!completedScreening(oldMember, newMember)) { + return; } + + const config = await ConfigService.getConfig(newMember.guild.id); + await complete(client, newMember, config); }, }); diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts new file mode 100644 index 00000000..0b0de5f4 --- /dev/null +++ b/src/services/OnboardingService.ts @@ -0,0 +1,293 @@ +import { AttachmentBuilder, Client, Guild, GuildMember, PartialGuildMember, Role } from 'discord.js'; +import type { ServerConfig } from '../client/models/ServerConfig'; +import { EmbedColours } from '../resources/EmbedColours'; +import { Logger } from '../utilities/Logger'; +import { AuditEmbed } from './AuditEmbed'; +import { sendAudit } from './AuditService'; +import { WelcomeImage, WelcomeImageRenderer } from './WelcomeImage'; + +/** 'done' | 'skipped:' | 'failed:' */ +export type StepResult = string; + +export interface WelcomePayload { + content?: string; + image?: AttachmentBuilder; + imageError?: string; +} + +export interface RoleSkip { + id: string; + reason: string; +} + +export interface OnboardingResult { + dryRun: boolean; + audit: StepResult; + roles: StepResult; + welcome: StepResult; + debug: StepResult; + rolesAdded: string[]; + rolesSkipped: RoleSkip[]; + welcomePayload?: WelcomePayload; +} + +export interface OnboardingDeps { + renderImage?: WelcomeImageRenderer; +} + +export interface OnboardingOptions { + dryRun?: boolean; +} + +const MAX_ERROR_LENGTH = 200; + +const messageOf = (error: unknown): string => { + const text = error instanceof Error ? error.message : String(error); + return text.length > MAX_ERROR_LENGTH ? `${text.slice(0, MAX_ERROR_LENGTH)}…` : text; +}; + +/** Returns why the bot could not hand out this role, or null when it can. */ +export const roleProblem = (guild: Guild, role: Role): string | null => { + if (role.id === guild.id) { + return '@everyone cannot be assigned'; + } + if (role.managed) { + return 'managed by an integration'; + } + const me = guild.members.me; + if (!me) { + return 'bot member unavailable'; + } + if (role.position >= me.roles.highest.position) { + return 'at or above the bot\'s highest role'; + } + return null; +}; + +export const parseRoleIds = (raw: string | null | undefined): string[] => + (raw ?? '') + .split(',') + .map((id) => id.trim()) + .filter((id) => id !== ''); + +/** True only when a member is known to have just passed membership screening. */ +export const completedScreening = ( + oldMember: GuildMember | PartialGuildMember, + newMember: GuildMember | PartialGuildMember +): boolean => oldMember.pending === true && newMember.pending === false; + +export const formatOnboardingSummary = (result: OnboardingResult): string => { + const lines = [ + `Audit: ${result.audit}`, + `Guest roles: ${result.roles}`, + `Welcome message: ${result.welcome}`, + ]; + if (result.rolesAdded.length > 0) { + lines.push(`${result.dryRun ? 'Would add' : 'Added'}: ${result.rolesAdded.map((id) => `<@&${id}>`).join(', ')}`); + } + for (const skip of result.rolesSkipped) { + lines.push(`Skipped role ${skip.id}: ${skip.reason}`); + } + return lines.join('\n'); +}; + +export const auditJoin = (client: Client, member: GuildMember, config: ServerConfig): Promise => + sendAudit( + client, + config, + AuditEmbed.forMember(member, EmbedColours.positive, 'New member joined').addField('ID', member.id) + ); + +const auditFailure = ( + client: Client, + member: GuildMember, + config: ServerConfig, + description: string, + detail: string +): Promise => + sendAudit( + client, + config, + AuditEmbed.forMember(member, EmbedColours.negative, description) + .addField('ID', member.id) + .addField('Reason', detail) + ); + +async function runStep( + name: string, + member: GuildMember, + step: () => Promise +): Promise { + try { + return await step(); + } catch (error) { + Logger.error(`Onboarding step failed: ${name}`, { guild: member.guild.id, member: member.id }, error); + return `failed:${messageOf(error)}`; + } +} + +async function addGuestRoles( + client: Client, + member: GuildMember, + config: ServerConfig, + dryRun: boolean, + result: OnboardingResult +): Promise { + const ids = parseRoleIds(config.guestRoleIds); + if (ids.length === 0) { + return 'skipped:no guest roles configured'; + } + const { guild } = member; + const assignable: Role[] = []; + for (const id of ids) { + const role = guild.roles.cache.get(id) ?? (await guild.roles.fetch(id).catch(() => null)); + if (!role) { + result.rolesSkipped.push({ id, reason: 'role no longer exists' }); + continue; + } + const problem = roleProblem(guild, role); + if (problem) { + result.rolesSkipped.push({ id, reason: problem }); + continue; + } + assignable.push(role); + } + result.rolesAdded = assignable.map((r) => r.id); + if (assignable.length === 0) { + return 'skipped:no assignable guest roles'; + } + if (dryRun) { + return 'skipped:dry run'; + } + try { + await member.roles.add(assignable.map((r) => r.id)); + } catch (error) { + result.rolesAdded = []; + await auditFailure( + client, + member, + config, + 'Unable to provide guest role(s) to member.', + messageOf(error) + ); + throw error; + } + return 'done'; +} + +async function sendWelcome( + client: Client, + member: GuildMember, + config: ServerConfig, + dryRun: boolean, + result: OnboardingResult, + renderImage: WelcomeImageRenderer +): Promise { + const text = config.welcomeMessage ?? ''; + const url = config.welcomeMessageBackgroundUrl; + if (!config.systemMessagesEnabled) { + return 'skipped:system messages are disabled'; + } + if (text === '' && !url) { + return 'skipped:no welcome message or image configured'; + } + const channel = member.guild.systemChannel; + if (!channel) { + return 'skipped:server has no system channel'; + } + + const payload: WelcomePayload = {}; + if (text !== '') { + payload.content = text.replaceAll('{member}', `<@${member.id}>`); + } + if (url) { + try { + payload.image = await renderImage(member, url); + } catch (error) { + payload.imageError = messageOf(error); + Logger.warn('Welcome image failed', { guild: member.guild.id, member: member.id }, error); + if (!dryRun) { + await auditFailure(client, member, config, 'Unable to generate welcome image.', payload.imageError); + } + } + } + if (payload.content === undefined && !payload.image) { + throw new Error(`Welcome image failed: ${payload.imageError ?? 'unknown error'}`); + } + + result.welcomePayload = payload; + if (dryRun) { + return 'skipped:dry run'; + } + try { + await channel.send({ + content: payload.content, + files: payload.image ? [payload.image] : undefined, + allowedMentions: { users: [member.id] }, + }); + } catch (error) { + await auditFailure(client, member, config, 'Unable to send welcome message.', messageOf(error)); + throw error; + } + return 'done'; +} + +export async function complete( + client: Client, + member: GuildMember, + config: ServerConfig, + options: OnboardingOptions = {}, + deps: OnboardingDeps = {} +): Promise { + const dryRun = options.dryRun ?? false; + const renderImage = deps.renderImage ?? WelcomeImage.render; + const result: OnboardingResult = { + dryRun, + audit: 'skipped:dry run', + roles: '', + welcome: '', + debug: 'skipped:debug is disabled', + rolesAdded: [], + rolesSkipped: [], + }; + + if (!dryRun) { + result.audit = await runStep('audit', member, async () => { + const sent = await sendAudit( + client, + config, + AuditEmbed.forMember(member, EmbedColours.neutral, 'Member completed onboarding').addField( + 'ID', + member.id + ) + ); + return sent ? 'done' : 'skipped:no usable audit channel'; + }); + } + + result.roles = await runStep('roles', member, () => + addGuestRoles(client, member, config, dryRun, result) + ); + result.welcome = await runStep('welcome', member, () => + sendWelcome(client, member, config, dryRun, result, renderImage) + ); + + if (config.debug && !dryRun) { + result.debug = await runStep('debug', member, async () => { + const sent = await sendAudit( + client, + config, + AuditEmbed.forMember(member, EmbedColours.info, 'Onboarding diagnostics') + .addField('ID', member.id) + .addField('Steps', formatOnboardingSummary(result)) + ); + return sent ? 'done' : 'skipped:no usable audit channel'; + }); + } else if (config.debug) { + result.debug = 'skipped:dry run'; + } + + return result; +} + +export const OnboardingService = { complete }; diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts new file mode 100644 index 00000000..668c5180 --- /dev/null +++ b/src/services/WelcomeImage.ts @@ -0,0 +1,121 @@ +import path from 'node:path'; +import { AttachmentBuilder, GuildMember } from 'discord.js'; +import { DateTime } from 'luxon'; +import { Canvas, createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; +import { env } from '../config/env'; + +const FONT_FAMILY = 'Roboto'; +const FONT_PATH = path.join(__dirname, '..', 'resources', 'fonts', 'Roboto-Regular.ttf'); +const DOWNLOAD_TIMEOUT_MS = 10_000; +const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024; +const MIN_FONT_SIZE = 8; + +let fontRegistered = false; +const ensureFont = (): void => { + if (!fontRegistered) { + GlobalFonts.registerFromPath(FONT_PATH, FONT_FAMILY); + fontRegistered = true; + } +}; + +export const isHttpUrl = (value: string): boolean => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +export const applyText = (canvas: Canvas, text: string, baseSize: number): string => { + const ctx = canvas.getContext('2d'); + let fontSize = baseSize; + ctx.font = `${fontSize}px ${FONT_FAMILY}`; + while (fontSize > MIN_FONT_SIZE && ctx.measureText(text).width > canvas.width - 300) { + fontSize -= 1; + ctx.font = `${fontSize}px ${FONT_FAMILY}`; + } + return ctx.font; +}; + +const downloadImage = async (url: string): Promise => { + if (!isHttpUrl(url)) { + throw new Error('Image URL must start with http:// or https://'); + } + let response: Response; + try { + response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + } catch (error) { + if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { + throw new Error(`Downloading the image timed out after ${DOWNLOAD_TIMEOUT_MS / 1000} seconds`, { + cause: error, + }); + } + throw new Error( + `Could not download the image: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ); + } + if (!response.ok) { + throw new Error(`Could not download the image (HTTP ${response.status})`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length > MAX_DOWNLOAD_BYTES) { + throw new Error('The image is larger than 10 MB'); + } + return buffer; +}; + +export type WelcomeImageRenderer = ( + member: GuildMember, + backgroundUrl: string +) => Promise; + +export const render: WelcomeImageRenderer = async (member, backgroundUrl) => { + ensureFont(); + if (member.joinedTimestamp === null) { + throw new Error(`Join date unavailable for member ${member.id}`); + } + + const data = await downloadImage(backgroundUrl); + let background; + try { + background = await loadImage(data); + } catch (error) { + throw new Error('The URL did not return a valid image', { cause: error }); + } + + const canvas = createCanvas(700, 250); + const ctx = canvas.getContext('2d'); + const joined = DateTime.fromMillis(member.joinedTimestamp, { zone: env.timezone }).toLocaleString( + DateTime.DATE_FULL + ); + const name = member.displayName; + const welcome = `Welcome to ${member.guild.name}!`; + const joinedText = `Joined: ${joined}`; + + ctx.drawImage(background, 0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#ffffff'; + + ctx.font = applyText(canvas, name, 48); + ctx.fillText(name, 225, 125); + ctx.font = applyText(canvas, welcome, 34); + ctx.fillText(welcome, 225, 160); + ctx.font = applyText(canvas, joinedText, 20); + ctx.fillText(joinedText, 225, 200); + + ctx.beginPath(); + ctx.arc(125, 125, 75, 0, Math.PI * 2, true); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.lineWidth = 10; + ctx.stroke(); + ctx.closePath(); + ctx.clip(); + + const avatar = await loadImage(member.displayAvatarURL({ extension: 'png', size: 256 })); + ctx.drawImage(avatar, 50, 50, 150, 150); + + return new AttachmentBuilder(await canvas.encode('png')).setName('welcome-image.png'); +}; + +export const WelcomeImage = { render }; diff --git a/src/slash-commands/Admin/simulate.ts b/src/slash-commands/Admin/simulate.ts index 2a7ed5f8..81e4d802 100644 --- a/src/slash-commands/Admin/simulate.ts +++ b/src/slash-commands/Admin/simulate.ts @@ -1,57 +1,71 @@ -import { GuildMember } from 'discord.js'; +import { GuildMember, SlashCommandSubcommandBuilder } from 'discord.js'; import { CommandContext, defineCommand } from '../../framework/command'; +import { resolveMember } from '../../services/MemberResolver'; +import { auditJoin, complete, formatOnboardingSummary } from '../../services/OnboardingService'; -const findMember = (ctx: CommandContext) => { - const members = ctx.guild.members.cache; +const NO_MENTIONS = { parse: [] }; + +const targetOf = async (ctx: CommandContext): Promise => { const user = ctx.interaction.options.getUser('member'); - return (user ? members.get(user.id) : undefined) ?? members.get(ctx.interaction.user.id); + if (!user) { + return ctx.member; + } + const member = await resolveMember(ctx.guild, user); + if (!member) { + await ctx.reply(`Could not find ${user.toString()} in this server.`); + } + return member; }; +const memberOption = (description: string) => (b: SlashCommandSubcommandBuilder) => + b.addUserOption((opt) => opt.setName('member').setDescription(description)); + export default defineCommand({ name: 'simulate', - description: 'Simulate events in this server', + description: 'Simulate member events safely (no roles or welcome messages are applied)', adminOnly: true, subcommands: [ { name: 'join', - description: 'Simulate someone joining this server', - options: (b) => - b.addUserOption((opt) => - opt.setName('member').setDescription('Member to simulate joining as') - ), + description: 'Send only the "member joined" audit for a member (no roles or welcome)', + defer: 'ephemeral', + options: memberOption('Member to simulate joining as (defaults to you)'), run: async (ctx) => { - const guildMember = findMember(ctx); - if (!guildMember) { - await ctx.reply('Could not find that member.'); + const target = await targetOf(ctx); + if (!target) { return; } - ctx.interaction.client.emit('guildMemberAdd', guildMember); - - await ctx.reply(`Emitted guildMemberAdd for ${guildMember.toString()}`); + const sent = await auditJoin(ctx.interaction.client, target, ctx.config); + await ctx.reply({ + content: sent + ? `Sent the "member joined" audit for ${target.toString()}. No roles or welcome message were applied.` + : 'No audit message was sent: the audit channel is not configured or not usable.', + allowedMentions: NO_MENTIONS, + }); }, }, { - name: 'screen', - description: 'Simulate someone passing screening in this server', - options: (b) => - b.addUserOption((opt) => - opt.setName('member').setDescription('Member to simulate passing screening as') - ), + name: 'onboard', + description: 'Dry run: show what onboarding (guest roles, welcome) would do for a member', + defer: 'ephemeral', + options: memberOption('Member to simulate completing onboarding as (defaults to you)'), run: async (ctx) => { - const guildMember = findMember(ctx); - if (!guildMember) { - await ctx.reply('Could not find that member.'); + const target = await targetOf(ctx); + if (!target) { return; } - - const oldMemberMock = Object.assign({}, guildMember, { - pending: true, - }) as unknown as GuildMember; - const newMemberMock = guildMember; - - ctx.interaction.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); - - await ctx.reply(`Emitted guildMemberUpdate for ${guildMember.toString()}`); + const result = await complete(ctx.interaction.client, target, ctx.config, { dryRun: true }); + const payload = result.welcomePayload; + const welcome = payload + ? `\n\nWelcome preview:\n${payload.content ?? '*(no text)*'}${ + payload.imageError ? `\nImage failed: ${payload.imageError}` : '' + }` + : ''; + await ctx.reply({ + content: `Dry run for ${target.toString()} (nothing was changed or sent):\n${formatOnboardingSummary(result)}${welcome}`, + files: payload?.image ? [payload.image] : undefined, + allowedMentions: NO_MENTIONS, + }); }, }, ], diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/slash-commands/Config/Subcommands/debug.ts index e27a8856..e07f8f83 100644 --- a/src/slash-commands/Config/Subcommands/debug.ts +++ b/src/slash-commands/Config/Subcommands/debug.ts @@ -1,34 +1,10 @@ -import { defineSubcommandGroup } from '../../../framework/command'; +import { booleanSetting } from '../../../framework/settings'; -export const DebugGroup = defineSubcommandGroup({ +export const DebugGroup = booleanSetting({ name: 'debug', - description: 'Toggle the debugging mode for this server', - subcommands: [ - { - name: 'set', - description: "Set's whether debug mode is enabled for this server", - options: (sub) => - sub.addBooleanOption((opt) => - opt - .setName('enabled') - .setDescription('Whether to enable debug mode or not') - .setRequired(true) - ), - run: async (ctx) => { - ctx.config.debug = ctx.interaction.options.getBoolean('enabled', true); - await ctx.config.save(); - - await ctx.reply(`Debug mode is: ${ctx.config.debug ? 'enabled' : 'disabled'}`); - }, - }, - { - name: 'get', - description: "Get's whether debug mode is enabled or not", - run: async (ctx) => { - await ctx.reply(`Debug mode is: ${ctx.config.debug ? 'enabled' : 'disabled'}`); - }, - }, - ], + description: 'Post a diagnostic audit summarising each onboarding run', + field: 'debug', + label: 'Onboarding diagnostics', }); export default DebugGroup; diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index e81313e1..798fea21 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -1,89 +1,101 @@ +import { Role } from 'discord.js'; import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; import { EmbedColours } from '../../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../../types/EmbedCompatLayer'; -import { ChannelService } from '../../../utilities/ChannelService'; -import { Logger } from '../../../utilities/Logger'; +import { AuditEmbed } from '../../../services/AuditEmbed'; +import { sendAudit } from '../../../services/AuditService'; +import { parseRoleIds, roleProblem } from '../../../services/OnboardingService'; -const get = async (ctx: CommandContext) => { - const { config, guild, interaction: cmd } = ctx; - if (!config.guestRoleIds) { - await ctx.reply({ content: 'No guest roles configured for this server', ephemeral: false }); - return; - } - - const roleIds: string[] = config.guestRoleIds.split(','); - const roles = guild.roles.cache.filter((r) => roleIds.includes(r.id)); - const member = guild.members.cache.get(cmd.user.id); +const ROLE_OPTIONS = ['role-one', 'role-two', 'role-three'] as const; +const NO_MENTIONS = { parse: [] }; - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.info) - .setAuthor({ - name: member?.displayName ?? cmd.user.username, - iconURL: cmd.user.displayAvatarURL(), - }) - .setDescription(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`) - .setTimestamp(); - - if (config.guestRoleIds) { - audit.addField('New user roles', roles.map((r) => r.toString()).join('\n')); +const auditChange = (ctx: CommandContext, description: string, roleIds: string[]) => { + const embed = AuditEmbed.forMember(ctx.member, EmbedColours.info, description); + if (roleIds.length > 0) { + embed.addField('New member roles', roleIds.map((id) => `<@&${id}>`).join('\n')); } + return sendAudit(ctx.interaction.client, ctx.config, embed); +}; - if (roles.size !== roleIds.length) { - audit.addField( - 'WARNING', - 'Not all roles configured are available in this server, please reconfigure new user roles' - ); +const get = async (ctx: CommandContext) => { + const ids = parseRoleIds(ctx.config.guestRoleIds); + if (ids.length === 0) { + await ctx.reply('No new-member roles configured'); + return; } - - await ChannelService.sendAuditMessage(cmd.client, config, audit).catch((err: unknown) => - Logger.writeError('Could not send new roles audit.', err) + const lines = ids.map((id) => + ctx.guild.roles.cache.has(id) ? `<@&${id}>` : `${id} (this role no longer exists)` ); - await ctx.reply(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`); + await ctx.reply({ + content: `Roles given to members when they complete onboarding:\n${lines.join('\n')}`, + allowedMentions: NO_MENTIONS, + }); }; export const NewRolesGroup = defineSubcommandGroup({ name: 'newroles', - description: 'Control the roles assigned to newly screened members', + description: 'Roles given to members when they complete onboarding', subcommands: [ { name: 'get', - description: 'Gets the roles assigned to newly screened members', + description: 'Show the roles given to members when they complete onboarding', run: get, }, { name: 'set', - description: 'Sets the roles of newly screened members', + description: 'Set the roles given to members when they complete onboarding', options: (sub) => sub .addRoleOption((option) => option - .setName('role-one') - .setDescription('First role to give to newly screened members') + .setName(ROLE_OPTIONS[0]) + .setDescription('Role to give to members when they complete onboarding') .setRequired(true) ) .addRoleOption((option) => - option - .setName('role-two') - .setDescription('Second optional role to give to newly screened members') + option.setName(ROLE_OPTIONS[1]).setDescription('Optional second role to give') + ) + .addRoleOption((option) => + option.setName(ROLE_OPTIONS[2]).setDescription('Optional third role to give') ), run: async (ctx) => { - const role1 = ctx.interaction.options.getRole('role-one', true); - const role2 = ctx.interaction.options.getRole('role-two'); + const roles = new Map(); + for (const name of ROLE_OPTIONS) { + const role = ctx.interaction.options.getRole(name); + if (role) { + roles.set(role.id, role); + } + } - ctx.config.guestRoleIds = [role1.id, role2?.id].filter(Boolean).join(','); - await ctx.config.save(); + const problems = [...roles.values()].flatMap((role) => { + const problem = roleProblem(ctx.guild, role); + return problem ? [`${role.name}: ${problem}`] : []; + }); + if (problems.length > 0) { + await ctx.reply({ + content: `Those roles cannot be used:\n${problems.join('\n')}`, + allowedMentions: NO_MENTIONS, + }); + return; + } - await get(ctx); + const ids = [...roles.keys()]; + ctx.config.guestRoleIds = ids.join(','); + await ctx.config.save(); + await auditChange(ctx, 'New member roles updated', ids); + await ctx.reply({ + content: `New-member roles set to ${ids.map((id) => `<@&${id}>`).join(', ')}.`, + allowedMentions: NO_MENTIONS, + }); }, }, { name: 'clear', - description: 'Clears the roles assigned to newly screened members', + description: 'Clear the roles given to members when they complete onboarding', run: async (ctx) => { ctx.config.guestRoleIds = null; await ctx.config.save(); - - await get(ctx); + await auditChange(ctx, 'New member roles cleared', []); + await ctx.reply('New-member roles cleared.'); }, }, ], diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts index 90e36ce1..4023c4ab 100644 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ b/src/slash-commands/Config/Subcommands/welcome.ts @@ -1,23 +1,92 @@ import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; +import { complete } from '../../../services/OnboardingService'; +import { isHttpUrl, WelcomeImage } from '../../../services/WelcomeImage'; -const imageChoice = { - name: 'Welcome Image', - value: 'welcomeMessageBackgroundUrl', -} as const; -const msgChoice = { - name: 'Welcome Message', - value: 'welcomeMessage', -} as const; +export const MAX_WELCOME_MESSAGE_LENGTH = 1500; -type WelcomeField = typeof imageChoice.value | typeof msgChoice.value; +export const validateWelcomeMessage = (text: string): string | null => + text.trim() === '' + ? 'The welcome message cannot be empty. Use /config welcome clear to remove it.' + : text.length > MAX_WELCOME_MESSAGE_LENGTH + ? `The welcome message is too long (${text.length} characters, maximum ${MAX_WELCOME_MESSAGE_LENGTH}).` + : null; -const fieldOf = (ctx: CommandContext) => - ctx.interaction.options.getString('content', true) as WelcomeField; +export const validateImageUrl = (url: string): string | null => + isHttpUrl(url) ? null : 'The image URL must start with http:// or https://'; + +const NO_MENTIONS = { parse: [] }; + +const setMessage = async (ctx: CommandContext) => { + const text = ctx.interaction.options.getString('text', true); + const problem = validateWelcomeMessage(text); + if (problem) { + await ctx.reply(problem); + return; + } + ctx.config.welcomeMessage = text; + await ctx.config.save(); + await ctx.reply({ + content: 'Welcome message saved. Use `{member}` in the text to mention the new member.', + allowedMentions: NO_MENTIONS, + }); +}; + +const setImage = async (ctx: CommandContext) => { + const url = ctx.interaction.options.getString('url', true).trim(); + const problem = validateImageUrl(url); + if (problem) { + await ctx.reply(problem); + return; + } + try { + await WelcomeImage.render(ctx.member, url); + } catch (error) { + await ctx.reply( + `That image could not be used: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + ctx.config.welcomeMessageBackgroundUrl = url; + await ctx.config.save(); + await ctx.reply('Welcome image saved. Use /config welcome preview to see how it looks.'); +}; const get = async (ctx: CommandContext) => { - const value = ctx.config[fieldOf(ctx)]; + const { welcomeMessage, welcomeMessageBackgroundUrl } = ctx.config; + await ctx.reply({ + content: [ + `Welcome message: ${welcomeMessage ? `\n${welcomeMessage}` : 'Not set'}`, + `Welcome image: ${welcomeMessageBackgroundUrl ?? 'Not set'}`, + ].join('\n'), + allowedMentions: NO_MENTIONS, + }); +}; - await ctx.reply(`Current value is: ${value ? value : 'Not Set'}`); +const clear = async (ctx: CommandContext) => { + const which = ctx.interaction.options.getString('which', true); + if (which === 'message' || which === 'all') { + ctx.config.welcomeMessage = null; + } + if (which === 'image' || which === 'all') { + ctx.config.welcomeMessageBackgroundUrl = null; + } + await ctx.config.save(); + await ctx.reply(`Cleared welcome ${which === 'all' ? 'message and image' : which}.`); +}; + +const preview = async (ctx: CommandContext) => { + const result = await complete(ctx.interaction.client, ctx.member, ctx.config, { dryRun: true }); + const payload = result.welcomePayload; + if (!payload) { + await ctx.reply(`Nothing would be sent: ${result.welcome.replace(/^(skipped|failed):/, '')}.`); + return; + } + const notes = payload.imageError ? `\n\nThe image failed to render: ${payload.imageError}` : ''; + await ctx.reply({ + content: `Preview of the welcome message for you:\n\n${payload.content ?? '*(no text)*'}${notes}`, + files: payload.image ? [payload.image] : undefined, + allowedMentions: NO_MENTIONS, + }); }; export const WelcomeGroup = defineSubcommandGroup({ @@ -25,60 +94,55 @@ export const WelcomeGroup = defineSubcommandGroup({ description: 'Control welcome information for this server', subcommands: [ { - name: 'set', - description: "Set's the welcome information for this server", + name: 'set-message', + description: 'Set the welcome message ({member} is replaced by a mention)', options: (sub) => - sub - .addStringOption((opt) => - opt - .setName('content') - .setDescription('Set the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice) - ) - .addStringOption((opt) => - opt - .setName('value') - .setDescription('URL to the welcome image, or the welcome text') - .setRequired(true) - ), - run: async (ctx) => { - ctx.config[fieldOf(ctx)] = ctx.interaction.options.getString('value', true); - await ctx.config.save(); - - await get(ctx); - }, + sub.addStringOption((opt) => + opt + .setName('text') + .setDescription('The welcome text; use {member} to mention the new member') + .setMaxLength(MAX_WELCOME_MESSAGE_LENGTH) + .setRequired(true) + ), + run: setMessage, }, { - name: 'get', - description: "Get's the welcome information for this server", + name: 'set-image', + description: 'Set the background image used for the welcome image', + defer: 'ephemeral', options: (sub) => sub.addStringOption((opt) => - opt - .setName('content') - .setDescription('Get the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice) + opt.setName('url').setDescription('http(s) URL of the background image').setRequired(true) ), + run: setImage, + }, + { + name: 'get', + description: 'Show the welcome message and image for this server', run: get, }, { name: 'clear', - description: "Clear's the welcome information for this server", + description: 'Clear the welcome message, image, or both', options: (sub) => sub.addStringOption((opt) => opt - .setName('content') - .setDescription('Clear the welcome image, or text?') + .setName('which') + .setDescription('What to clear') .setRequired(true) - .addChoices(imageChoice, msgChoice) + .addChoices( + { name: 'Welcome message', value: 'message' }, + { name: 'Welcome image', value: 'image' }, + { name: 'Both', value: 'all' } + ) ), - run: async (ctx) => { - ctx.config[fieldOf(ctx)] = null; - await ctx.config.save(); - - await get(ctx); - }, + run: clear, + }, + { + name: 'preview', + description: 'Preview the welcome message as it would be sent for you', + defer: 'ephemeral', + run: preview, }, ], }); diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts new file mode 100644 index 00000000..56b248b3 --- /dev/null +++ b/tests/fakes/onboarding.ts @@ -0,0 +1,117 @@ +import type { Client, GuildMember } from 'discord.js'; +import type { ServerConfig } from '../../src/client/models/ServerConfig'; +import type { CommandContext } from '../../src/framework/command'; +import { fakeInteraction } from './interaction'; + +export interface FakeRole { + id: string; + name: string; + position: number; + managed: boolean; +} + +export interface FakeOnboardingOptions { + roles?: FakeRole[]; + botPosition?: number; + config?: Partial>; + systemChannel?: boolean; + auditChannel?: boolean; + failRolesAdd?: boolean; + failSystemSend?: boolean; + failAuditSend?: boolean; +} + +export const role = (id: string, position = 1, managed = false): FakeRole => ({ + id, + name: `role-${id}`, + position, + managed, +}); + +export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { + const roleAdds: string[][] = []; + const systemSends: any[] = []; + const auditSends: any[] = []; + + const roles = new Map((opts.roles ?? []).map((r) => [r.id, r])); + const guild: any = { + id: 'guild-1', + name: 'Test Guild', + roles: { cache: roles, fetch: async () => null }, + members: { me: { roles: { highest: { position: opts.botPosition ?? 10 } } } }, + systemChannel: + opts.systemChannel === false + ? null + : { + send: async (payload: unknown) => { + if (opts.failSystemSend) { + throw new Error('cannot send'); + } + systemSends.push(payload); + }, + }, + }; + + const member = { + id: 'member-1', + displayName: 'Member', + user: { id: 'member-1', tag: 'member#0001' }, + guild, + displayAvatarURL: () => 'https://example.com/a.png', + toString: () => '<@member-1>', + roles: { + add: async (ids: string[]) => { + if (opts.failRolesAdd) { + throw new Error('missing permissions'); + } + roleAdds.push(ids); + }, + }, + } as unknown as GuildMember; + + const auditChannel = { + isSendable: () => true, + send: async (payload: unknown) => { + if (opts.failAuditSend) { + throw new Error('audit down'); + } + auditSends.push(payload); + }, + }; + const client = { + channels: { fetch: async () => auditChannel }, + } as unknown as Client; + + const config = { + serverId: 'guild-1', + auditChannelId: opts.auditChannel === false ? null : 'audit-1', + guestRoleIds: null, + welcomeMessage: null, + welcomeMessageBackgroundUrl: null, + systemMessagesEnabled: false, + debug: false, + save: async () => undefined, + ...opts.config, + } as unknown as ServerConfig; + + return { client, member, guild, config, roleAdds, systemSends, auditSends }; +} + +export function fakeCommandContext( + options: Record, + env: ReturnType +) { + const { interaction } = fakeInteraction({ options }); + (interaction as any).client = env.client; + const replies: any[] = []; + const ctx = { + interaction, + guild: env.guild, + member: env.member, + config: env.config, + reply: async (response: unknown) => { + replies.push(typeof response === 'string' ? { content: response } : response); + }, + } as unknown as CommandContext; + return { ctx, replies }; +} diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts new file mode 100644 index 00000000..a5a6895a --- /dev/null +++ b/tests/onboarding/OnboardingService.test.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { AttachmentBuilder } from 'discord.js'; +import { + complete, + completedScreening, + formatOnboardingSummary, + parseRoleIds, +} from '../../src/services/OnboardingService'; +import { fakeOnboarding, role } from '../fakes/onboarding'; + +const okImage = async () => new AttachmentBuilder(Buffer.from('x')).setName('welcome-image.png'); +const failImage = async () => { + throw new Error('bad image'); +}; + +test('parseRoleIds trims and drops empties', () => { + assert.deepEqual(parseRoleIds(' a, b ,,c'), ['a', 'b', 'c']); + assert.deepEqual(parseRoleIds(null), []); +}); + +test('completedScreening only for pending true to false', () => { + const m = (pending: boolean | null | undefined) => ({ pending }) as any; + assert.equal(completedScreening(m(true), m(false)), true); + assert.equal(completedScreening(m(false), m(false)), false); + assert.equal(completedScreening(m(true), m(true)), false); + assert.equal(completedScreening(m(null), m(false)), false); + assert.equal(completedScreening(m(undefined), m(false)), false); +}); + +test('unconfigured server: audit done, roles and welcome skipped, no debug audit', async () => { + const env = fakeOnboarding(); + const result = await complete(env.client, env.member, env.config, {}, { renderImage: okImage }); + assert.equal(result.audit, 'done'); + assert.match(result.roles, /^skipped:/); + assert.match(result.welcome, /^skipped:/); + assert.equal(env.auditSends.length, 1); + assert.match(JSON.stringify(env.auditSends[0].embeds[0]), /completed onboarding/); +}); + +test('guest roles are added in one call and unassignable ones filtered', async () => { + const env = fakeOnboarding({ + roles: [role('ok1', 1), role('ok2', 2), role('managed', 1, true), role('high', 10), role('guild-1', 0)], + config: { guestRoleIds: 'ok1,ok2,managed,high,guild-1,gone' }, + }); + const result = await complete(env.client, env.member, env.config, {}, { renderImage: okImage }); + assert.equal(result.roles, 'done'); + assert.deepEqual(env.roleAdds, [['ok1', 'ok2']]); + assert.deepEqual( + result.rolesSkipped.map((s) => s.id).sort(), + ['gone', 'guild-1', 'high', 'managed'] + ); +}); + +test('no assignable roles skips the roles step without calling add', async () => { + const env = fakeOnboarding({ roles: [role('high', 10)], config: { guestRoleIds: 'high' } }); + const result = await complete(env.client, env.member, env.config); + assert.match(result.roles, /^skipped:/); + assert.equal(env.roleAdds.length, 0); +}); + +test('a failing roles step is audited and does not stop the welcome step', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + failRolesAdd: true, + config: { + guestRoleIds: 'r1', + systemMessagesEnabled: true, + welcomeMessage: 'Hi {member}', + }, + }); + const result = await complete(env.client, env.member, env.config); + assert.match(result.roles, /^failed:missing permissions/); + assert.equal(result.welcome, 'done'); + assert.equal(env.systemSends.length, 1); + assert.ok(env.auditSends.some((a) => /guest role/.test(JSON.stringify(a.embeds[0])))); +}); + +test('audit failure does not stop later steps', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + failAuditSend: true, + config: { guestRoleIds: 'r1' }, + }); + const result = await complete(env.client, env.member, env.config); + assert.match(result.audit, /^skipped:/); + assert.equal(result.roles, 'done'); +}); + +test('welcome replaces every {member} and does not ping others', async () => { + const env = fakeOnboarding({ + config: { systemMessagesEnabled: true, welcomeMessage: '{member} hello {member}!' }, + }); + const result = await complete(env.client, env.member, env.config); + assert.equal(result.welcome, 'done'); + assert.equal(env.systemSends[0].content, '<@member-1> hello <@member-1>!'); + assert.deepEqual(env.systemSends[0].allowedMentions, { users: ['member-1'] }); +}); + +test('welcome skip reasons', async () => { + const disabled = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); + assert.match((await complete(disabled.client, disabled.member, disabled.config)).welcome, /disabled/); + + const empty = fakeOnboarding({ config: { systemMessagesEnabled: true } }); + assert.match((await complete(empty.client, empty.member, empty.config)).welcome, /^skipped:/); + + const noChannel = fakeOnboarding({ + systemChannel: false, + config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, + }); + assert.match((await complete(noChannel.client, noChannel.member, noChannel.config)).welcome, /no system channel/); +}); + +test('image failure still sends text and audits the image failure', async () => { + const env = fakeOnboarding({ + config: { + systemMessagesEnabled: true, + welcomeMessage: 'hi {member}', + welcomeMessageBackgroundUrl: 'https://example.com/bg.png', + }, + }); + const result = await complete(env.client, env.member, env.config, {}, { renderImage: failImage }); + assert.equal(result.welcome, 'done'); + assert.equal(env.systemSends.length, 1); + assert.equal(env.systemSends[0].files, undefined); + assert.ok(env.auditSends.some((a) => /welcome image/.test(JSON.stringify(a.embeds[0])))); +}); + +test('image failure with no text fails the welcome step', async () => { + const env = fakeOnboarding({ + config: { systemMessagesEnabled: true, welcomeMessageBackgroundUrl: 'https://example.com/bg.png' }, + }); + const result = await complete(env.client, env.member, env.config, {}, { renderImage: failImage }); + assert.match(result.welcome, /^failed:/); + assert.equal(env.systemSends.length, 0); +}); + +test('welcome send failure is audited and reported', async () => { + const env = fakeOnboarding({ + failSystemSend: true, + config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, + }); + const result = await complete(env.client, env.member, env.config); + assert.match(result.welcome, /^failed:cannot send/); + assert.ok(env.auditSends.some((a) => /welcome message/.test(JSON.stringify(a.embeds[0])))); +}); + +test('dryRun makes no role, send or audit calls and returns the payload', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + config: { + guestRoleIds: 'r1', + debug: true, + systemMessagesEnabled: true, + welcomeMessage: 'hi {member}', + welcomeMessageBackgroundUrl: 'https://example.com/bg.png', + }, + }); + const result = await complete(env.client, env.member, env.config, { dryRun: true }, { renderImage: okImage }); + assert.equal(env.roleAdds.length, 0); + assert.equal(env.systemSends.length, 0); + assert.equal(env.auditSends.length, 0); + assert.deepEqual(result.rolesAdded, ['r1']); + assert.equal(result.welcomePayload?.content, 'hi <@member-1>'); + assert.ok(result.welcomePayload?.image); + assert.match(formatOnboardingSummary(result), /Would add/); +}); + +test('debug summary audit is sent only when debug is enabled', async () => { + const off = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); + await complete(off.client, off.member, off.config); + assert.equal(off.auditSends.length, 1); + + const on = fakeOnboarding({ config: { debug: true } }); + const result = await complete(on.client, on.member, on.config); + assert.equal(result.debug, 'done'); + assert.equal(on.auditSends.length, 2); + const summary = JSON.stringify(on.auditSends[1].embeds[0]); + assert.match(summary, /diagnostics/); + assert.match(summary, /skipped:no guest roles configured/); +}); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts new file mode 100644 index 00000000..72735e15 --- /dev/null +++ b/tests/onboarding/commands.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Handler } from '../../src/framework/command'; +import NewRolesGroup from '../../src/slash-commands/Config/Subcommands/newroles'; +import WelcomeGroup, { + MAX_WELCOME_MESSAGE_LENGTH, + validateImageUrl, + validateWelcomeMessage, +} from '../../src/slash-commands/Config/Subcommands/welcome'; +import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding'; + +const handler = (group: typeof NewRolesGroup, name: string): Handler => + group.subcommands.find((s) => s.name === name)!.run; + +test('newroles set rejects @everyone, managed and too-high roles and saves nothing', async () => { + const env = fakeOnboarding({ roles: [role('ok', 1), role('m', 1, true), role('hi', 10)] }); + let saved = false; + env.config.save = (async () => { + saved = true; + }) as never; + const { ctx, replies } = fakeCommandContext( + { + 'role-one': env.guild.roles.cache.get('ok'), + 'role-two': env.guild.roles.cache.get('m'), + 'role-three': env.guild.roles.cache.get('hi'), + }, + env + ); + await handler(NewRolesGroup, 'set')(ctx); + assert.equal(saved, false); + assert.match(replies[0].content, /managed/); + assert.match(replies[0].content, /highest role/); + + const everyone = fakeCommandContext({ 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, env); + await handler(NewRolesGroup, 'set')(everyone.ctx); + assert.match(everyone.replies[0].content, /everyone/); + assert.equal(saved, false); +}); + +test('newroles set saves valid roles, dedupes, and audits', async () => { + const env = fakeOnboarding({ roles: [role('a', 1), role('b', 2)] }); + const a = env.guild.roles.cache.get('a'); + const { ctx, replies } = fakeCommandContext( + { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, + env + ); + await handler(NewRolesGroup, 'set')(ctx); + assert.equal(env.config.guestRoleIds, 'a,b'); + assert.equal(env.auditSends.length, 1); + assert.match(replies[0].content, /set to/); +}); + +test('newroles get is read-only: no audit, marks missing roles', async () => { + const env = fakeOnboarding({ roles: [role('a')], config: { guestRoleIds: 'a,gone' } }); + const { ctx, replies } = fakeCommandContext({}, env); + await handler(NewRolesGroup, 'get')(ctx); + assert.equal(env.auditSends.length, 0); + assert.match(replies[0].content, /<@&a>/); + assert.match(replies[0].content, /gone \(this role no longer exists\)/); + + const empty = fakeOnboarding(); + const e = fakeCommandContext({}, empty); + await handler(NewRolesGroup, 'get')(e.ctx); + assert.equal(e.replies[0].content, 'No new-member roles configured'); +}); + +test('newroles clear nulls roles and audits', async () => { + const env = fakeOnboarding({ config: { guestRoleIds: 'a' } }); + const { ctx } = fakeCommandContext({}, env); + await handler(NewRolesGroup, 'clear')(ctx); + assert.equal(env.config.guestRoleIds, null); + assert.equal(env.auditSends.length, 1); +}); + +test('welcome validators', () => { + assert.equal(validateImageUrl('https://example.com/a.png'), null); + assert.equal(validateImageUrl('http://example.com/a.png'), null); + assert.ok(validateImageUrl('ftp://example.com/a.png')); + assert.ok(validateImageUrl('file:///etc/passwd')); + assert.ok(validateImageUrl('not a url')); + assert.equal(validateWelcomeMessage('hello'), null); + assert.ok(validateWelcomeMessage('x'.repeat(MAX_WELCOME_MESSAGE_LENGTH + 1))); + assert.ok(validateWelcomeMessage(' ')); +}); + +test('welcome set-image rejects non-http URLs without saving', async () => { + const env = fakeOnboarding(); + const { ctx, replies } = fakeCommandContext({ url: 'ftp://example.com/a.png' }, env); + await handler(WelcomeGroup as never, 'set-image')(ctx); + assert.match(replies[0].content, /http/); + assert.equal(env.config.welcomeMessageBackgroundUrl, null); +}); + +test('welcome set-image rejects an unreachable image and does not save', async () => { + const env = fakeOnboarding(); + const { ctx, replies } = fakeCommandContext({ url: 'http://127.0.0.1:1/bg.png' }, env); + await handler(WelcomeGroup as never, 'set-image')(ctx); + assert.match(replies[0].content, /could not be used/); + assert.equal(env.config.welcomeMessageBackgroundUrl, null); +}); + +test('welcome set-message, get and clear', async () => { + const env = fakeOnboarding(); + const set = fakeCommandContext({ text: 'Hi {member}' }, env); + await handler(WelcomeGroup as never, 'set-message')(set.ctx); + assert.equal(env.config.welcomeMessage, 'Hi {member}'); + + const get = fakeCommandContext({}, env); + await handler(WelcomeGroup as never, 'get')(get.ctx); + assert.match(get.replies[0].content, /Hi \{member\}/); + assert.match(get.replies[0].content, /Welcome image: Not set/); + + const clear = fakeCommandContext({ which: 'all' }, env); + await handler(WelcomeGroup as never, 'clear')(clear.ctx); + assert.equal(env.config.welcomeMessage, null); +}); + +test('welcome preview explains why nothing would be sent', async () => { + const env = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); + const { ctx, replies } = fakeCommandContext({}, env); + await handler(WelcomeGroup as never, 'preview')(ctx); + assert.match(replies[0].content, /disabled/); + assert.equal(env.systemSends.length, 0); +}); + +test('welcome preview shows the text without sending', async () => { + const env = fakeOnboarding({ config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true } }); + const { ctx, replies } = fakeCommandContext({}, env); + await handler(WelcomeGroup as never, 'preview')(ctx); + assert.match(replies[0].content, /hi <@member-1> <@member-1>/); + assert.equal(env.systemSends.length, 0); +}); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts new file mode 100644 index 00000000..1524fe0b --- /dev/null +++ b/tests/onboarding/events.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import type { Sequelize } from 'sequelize-typescript'; +import { ServerConfig } from '../../src/client/models/ServerConfig'; +import guildMemberAdd from '../../src/events/guildMemberAdd'; +import guildMemberUpdate from '../../src/events/guildMemberUpdate'; +import { createTestDb } from '../helpers/db'; +import { fakeOnboarding, role } from '../fakes/onboarding'; + +let db: Sequelize; + +before(async () => { + db = await createTestDb(); +}); + +after(async () => { + await db.close(); +}); + +beforeEach(async () => { + await ServerConfig.destroy({ where: {} }); + await ServerConfig.create({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); +}); + +const update = async (oldState: Record, newPending: boolean | null) => { + const env = fakeOnboarding({ roles: [role('r1')] }); + const newMember = Object.assign(env.member, { pending: newPending }); + const oldMember = { guild: env.guild, id: 'member-1', ...oldState } as any; + await guildMemberUpdate.run(env.client as any, oldMember, newMember); + return env.roleAdds.length; +}; + +test('guildMemberUpdate onboards only on pending true to false', async () => { + assert.equal(await update({ pending: true }, false), 1); + assert.equal(await update({ pending: false }, false), 0); + assert.equal(await update({ pending: true }, true), 0); + assert.equal(await update({ pending: false }, true), 0); +}); + +test('guildMemberUpdate skips when the old member is partial', async () => { + assert.equal(await update({ partial: true, pending: null }, false), 0); + assert.equal(await update({ partial: true, pending: true }, false), 0); +}); + +test('guildMemberAdd audits the join and onboards immediately when not pending', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(env.member, { pending: false }); + await guildMemberAdd.run(env.client as any, env.member); + assert.equal(env.roleAdds.length, 1); + assert.match(JSON.stringify(env.auditSends[0].embeds[0]), /New member joined/); +}); + +test('guildMemberAdd waits for screening when the member is pending', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(env.member, { pending: true }); + await guildMemberAdd.run(env.client as any, env.member); + assert.equal(env.roleAdds.length, 0); + assert.equal(env.auditSends.length, 1); +}); From 7a286660d783bd8a7075ea8f51501a1a6746c652 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:48:17 +0100 Subject: [PATCH 12/77] Improve /about output Co-Authored-By: Claude Haiku 4.5 --- src/slash-commands/Info/about.ts | 36 +++++++++++-------- src/utilities/format.ts | 38 ++++++++++++++++++++ tests/about.test.ts | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 src/utilities/format.ts create mode 100644 tests/about.test.ts diff --git a/src/slash-commands/Info/about.ts b/src/slash-commands/Info/about.ts index 5be236f4..3d0cedc5 100644 --- a/src/slash-commands/Info/about.ts +++ b/src/slash-commands/Info/about.ts @@ -1,27 +1,33 @@ +import { EmbedBuilder } from 'discord.js'; import { defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; +import { toRepoUrl, formatUptime } from '../../utilities/format'; import * as packageJson from '../../../package.json'; export default defineCommand({ name: 'about', - description: 'Returns info about the bot and server', + description: 'Show information about Dingir and this server', + defer: 'ephemeral', run: async (ctx) => { - const cmd = ctx.interaction; const version = packageJson.version; - const projectUrl = packageJson.repository; + const projectUrl = packageJson.repository.url; + const repoUrl = toRepoUrl(projectUrl); + const serverCount = ctx.interaction.client.guilds.cache.size; + const guildName = ctx.guild.name; + const memberCount = ctx.guild.memberCount; + const uptime = formatUptime(ctx.interaction.client.uptime); - const embed = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: cmd.client.user.tag, - iconURL: cmd.client.user.displayAvatarURL(), - }) - .setDescription('Dingir Discord Bot') - .addField('Version', version.length > 0 ? version : 'Unknown') - .addField('Project URL', projectUrl.url) - .addField('Servers', cmd.client.guilds.cache.size.toString()) - .setTimestamp(); + const embed = new EmbedBuilder() + .setColor(EmbedColours.info) + .setTitle('About Dingir') + .setDescription('Show information about Dingir and this server') + .addFields( + { name: 'Version', value: version, inline: false }, + { name: 'Source', value: repoUrl, inline: false }, + { name: 'Servers', value: serverCount.toString(), inline: false }, + { name: 'This server', value: `${guildName} (${memberCount} members)`, inline: false }, + { name: 'Uptime', value: uptime, inline: false } + ); await ctx.reply({ embeds: [embed] }); }, diff --git a/src/utilities/format.ts b/src/utilities/format.ts new file mode 100644 index 00000000..aebb0ba0 --- /dev/null +++ b/src/utilities/format.ts @@ -0,0 +1,38 @@ +export function toRepoUrl(url: string): string { + let result = url; + if (result.startsWith('git+')) { + result = result.slice(4); + } + if (result.endsWith('.git')) { + result = result.slice(0, -4); + } + return result; +} + +export function formatUptime(ms: number | null): string { + if (ms === null) { + return 'unknown'; + } + + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + const remainingMinutes = minutes % 60; + const remainingHours = hours % 24; + + if (days > 0) { + return `${days}d ${remainingHours}h ${remainingMinutes}m`; + } + + if (hours > 0) { + return `${hours}h ${remainingMinutes}m`; + } + + if (minutes > 0) { + return `${minutes}m`; + } + + return '0m'; +} diff --git a/tests/about.test.ts b/tests/about.test.ts new file mode 100644 index 00000000..f3f2a415 --- /dev/null +++ b/tests/about.test.ts @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toRepoUrl, formatUptime } from '../src/utilities/format'; + +test('toRepoUrl', async (t) => { + await t.test('removes git+ prefix', () => { + assert.equal( + toRepoUrl('git+https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir' + ); + }); + + await t.test('removes .git suffix', () => { + assert.equal( + toRepoUrl('https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir' + ); + }); + + await t.test('leaves URL unchanged if no prefix or suffix', () => { + assert.equal( + toRepoUrl('https://github.com/BrentOates/dingir'), + 'https://github.com/BrentOates/dingir' + ); + }); + + await t.test('removes both git+ prefix and .git suffix', () => { + assert.equal( + toRepoUrl('git+https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir' + ); + }); +}); + +test('formatUptime', async (t) => { + await t.test('returns "unknown" for null', () => { + assert.equal(formatUptime(null), 'unknown'); + }); + + await t.test('returns "0m" for 0', () => { + assert.equal(formatUptime(0), '0m'); + }); + + await t.test('returns "0m" for 59 seconds', () => { + assert.equal(formatUptime(59000), '0m'); + }); + + await t.test('returns "1m" for 1 minute', () => { + assert.equal(formatUptime(60000), '1m'); + }); + + await t.test('returns "1h 0m" for 1 hour', () => { + assert.equal(formatUptime(3600000), '1h 0m'); + }); + + await t.test('returns "3d 4h 12m" for 3 days 4 hours 12 minutes', () => { + const ms = (3 * 24 * 60 * 60 * 1000) + (4 * 60 * 60 * 1000) + (12 * 60 * 1000); + assert.equal(formatUptime(ms), '3d 4h 12m'); + }); +}); From c876fcf4840c61919ee7204aca01db34246ddcd3 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:49:22 +0100 Subject: [PATCH 13/77] Remove legacy files; move domain services to src/services and CommandRegistrar to src/framework Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildCreate.ts | 2 +- src/events/guildDelete.ts | 2 +- src/events/guildMemberAdd.ts | 2 +- src/events/guildMemberRemove.ts | 6 +++--- src/events/guildMemberUpdate.ts | 2 +- src/events/interactionCreate.ts | 2 +- src/events/messageCreate.ts | 6 +++--- src/events/messageDelete.ts | 4 ++-- src/events/messageUpdate.ts | 2 +- src/events/ready.ts | 2 +- src/{utilities => framework}/CommandRegistrar.ts | 4 ++-- src/services/BirthdayService.ts | 4 ++-- src/{utilities => services}/ConfigService.ts | 0 src/services/DataCheckService.ts | 4 ++-- .../HoneyPotEnforcementService.ts | 0 src/{utilities => services}/UserProfileService.ts | 0 src/slash-commands/Info/mybirthday.ts | 2 +- src/slash-commands/Info/profile.ts | 2 +- src/types/EmbedCompatLayer.ts | 3 --- src/utilities/ChannelService.ts | 15 --------------- tests/database/purgeGuild.test.ts | 2 +- tests/moderation/events.test.ts | 6 +++--- tests/moderation/post.test.ts | 2 +- 23 files changed, 28 insertions(+), 46 deletions(-) rename src/{utilities => framework}/CommandRegistrar.ts (91%) rename src/{utilities => services}/ConfigService.ts (100%) rename src/{utilities => services}/HoneyPotEnforcementService.ts (100%) rename src/{utilities => services}/UserProfileService.ts (100%) delete mode 100644 src/types/EmbedCompatLayer.ts delete mode 100644 src/utilities/ChannelService.ts diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 4711ea84..a590d111 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,5 +1,5 @@ import { defineEvent } from '../framework/event'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; export default defineEvent({ diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 0d637de1..5a388e25 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,5 +1,5 @@ import { defineEvent } from '../framework/event'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; export default defineEvent({ diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index a30b94a3..af329af4 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,6 +1,6 @@ import { defineEvent } from '../framework/event'; import { auditJoin, complete } from '../services/OnboardingService'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; export default defineEvent({ name: 'guildMemberAdd', diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 66b5604f..4a08d011 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -2,9 +2,9 @@ import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { AuditEmbed } from '../services/AuditEmbed'; import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../utilities/ConfigService'; -import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; -import { UserProfileService } from '../utilities/UserProfileService'; +import { ConfigService } from '../services/ConfigService'; +import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService'; +import { UserProfileService } from '../services/UserProfileService'; export default defineEvent({ name: 'guildMemberRemove', diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 681ae0c0..eee0912d 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,6 +1,6 @@ import { defineEvent } from '../framework/event'; import { complete, completedScreening } from '../services/OnboardingService'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; export default defineEvent({ diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 6c25b6c5..6bc76827 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,7 +1,7 @@ import { MessageFlags } from 'discord.js'; import { CommandContext, createReply } from '../framework/command'; import { defineEvent } from '../framework/event'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; export default defineEvent({ diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index b78a6172..2c3f0e55 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -5,10 +5,10 @@ import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { AuditEmbed } from '../services/AuditEmbed'; import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../utilities/ConfigService'; -import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; +import { ConfigService } from '../services/ConfigService'; +import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService'; import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; +import { UserProfileService } from '../services/UserProfileService'; const DELETE_MESSAGE_SECONDS = 7 * 24 * 60 * 60; diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index cf6e9cc9..015c00a8 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -2,8 +2,8 @@ import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { AuditEmbed } from '../services/AuditEmbed'; import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../utilities/ConfigService'; -import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; +import { ConfigService } from '../services/ConfigService'; +import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService'; export default defineEvent({ name: 'messageDelete', diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 898bd845..7bde7d48 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -2,7 +2,7 @@ import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { AuditEmbed } from '../services/AuditEmbed'; import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; export default defineEvent({ name: 'messageUpdate', diff --git a/src/events/ready.ts b/src/events/ready.ts index 53b61d5d..0bd86f42 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -4,7 +4,7 @@ import { registerShutdownHook } from '../framework/shutdown'; import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService'; import { run as runDataCheck } from '../services/DataCheckService'; import { Scheduler } from '../services/Scheduler'; -import { CommandRegistrar } from '../utilities/CommandRegistrar'; +import { CommandRegistrar } from '../framework/CommandRegistrar'; import { Logger } from '../utilities/Logger'; let schedulerInstalled = false; diff --git a/src/utilities/CommandRegistrar.ts b/src/framework/CommandRegistrar.ts similarity index 91% rename from src/utilities/CommandRegistrar.ts rename to src/framework/CommandRegistrar.ts index 6548704d..246458ab 100644 --- a/src/utilities/CommandRegistrar.ts +++ b/src/framework/CommandRegistrar.ts @@ -1,7 +1,7 @@ import { env } from '../config/env'; import { REST, Routes } from 'discord.js'; -import { Command } from '../framework/command'; -import { Logger } from './Logger'; +import { Command } from './command'; +import { Logger } from '../utilities/Logger'; export class CommandRegistrar { public static async register(commands: Command[]): Promise { diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 67bd617c..49dfef38 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -2,9 +2,9 @@ import { Client } from 'discord.js'; import { DateTime } from 'luxon'; import { ServerConfig } from '../client/models/ServerConfig'; import { env } from '../config/env'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; +import { UserProfileService } from '../services/UserProfileService'; import { BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates'; import { resolveMember, resolveTextChannel } from './MemberResolver'; diff --git a/src/utilities/ConfigService.ts b/src/services/ConfigService.ts similarity index 100% rename from src/utilities/ConfigService.ts rename to src/services/ConfigService.ts diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index a2d3d242..4a26df00 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,8 +1,8 @@ import { Client } from 'discord.js'; import { env } from '../config/env'; -import { ConfigService } from '../utilities/ConfigService'; +import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; +import { UserProfileService } from '../services/UserProfileService'; import { AccessOutcome, RetentionPolicyConfig, diff --git a/src/utilities/HoneyPotEnforcementService.ts b/src/services/HoneyPotEnforcementService.ts similarity index 100% rename from src/utilities/HoneyPotEnforcementService.ts rename to src/services/HoneyPotEnforcementService.ts diff --git a/src/utilities/UserProfileService.ts b/src/services/UserProfileService.ts similarity index 100% rename from src/utilities/UserProfileService.ts rename to src/services/UserProfileService.ts diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index 6fa82125..93cb014d 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -4,7 +4,7 @@ import { CommandContext, defineCommand } from '../../framework/command'; import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates'; import { refreshCalendar } from '../../services/BirthdayService'; import { Logger } from '../../utilities/Logger'; -import { UserProfileService } from '../../utilities/UserProfileService'; +import { UserProfileService } from '../../services/UserProfileService'; const refresh = async (ctx: CommandContext): Promise => { try { diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index 8a5a6e8f..8bfac562 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -3,7 +3,7 @@ import { DateTime } from 'luxon'; import { defineCommand } from '../../framework/command'; import { EmbedColours } from '../../resources/EmbedColours'; import { resolveMember } from '../../services/MemberResolver'; -import { UserProfileService } from '../../utilities/UserProfileService'; +import { UserProfileService } from '../../services/UserProfileService'; export default defineCommand({ name: 'profile', diff --git a/src/types/EmbedCompatLayer.ts b/src/types/EmbedCompatLayer.ts deleted file mode 100644 index 7a7426ba..00000000 --- a/src/types/EmbedCompatLayer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { AuditEmbed } from '../services/AuditEmbed'; - -export class EmbedCompatLayer extends AuditEmbed {} diff --git a/src/utilities/ChannelService.ts b/src/utilities/ChannelService.ts deleted file mode 100644 index c6a7c0ae..00000000 --- a/src/utilities/ChannelService.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Attachment, Client, EmbedBuilder } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { NovaClient } from '../client/NovaClient'; -import { sendAudit } from '../services/AuditService'; - -export class ChannelService { - public static async sendAuditMessage( - client: NovaClient | Client, - serverConfig: ServerConfig, - embed: EmbedBuilder, - attachment?: Attachment - ): Promise { - return sendAudit(client, serverConfig, embed, attachment ? [attachment] : undefined); - } -} diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index 46b3fea8..856c74e0 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -3,7 +3,7 @@ import { after, before, beforeEach, test } from 'node:test'; import type { Sequelize } from 'sequelize-typescript'; import { ServerConfig } from '../../src/client/models/ServerConfig'; import { UserProfile } from '../../src/client/models/UserProfile'; -import { ConfigService } from '../../src/utilities/ConfigService'; +import { ConfigService } from '../../src/services/ConfigService'; import { createTestDb } from '../helpers/db'; let db: Sequelize; diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index f8a322df..6cdaba54 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -7,9 +7,9 @@ import guildMemberRemove from '../../src/events/guildMemberRemove'; import messageCreate from '../../src/events/messageCreate'; import messageDelete from '../../src/events/messageDelete'; import messageUpdate from '../../src/events/messageUpdate'; -import { ConfigService } from '../../src/utilities/ConfigService'; -import { HoneyPotEnforcementService } from '../../src/utilities/HoneyPotEnforcementService'; -import { UserProfileService } from '../../src/utilities/UserProfileService'; +import { ConfigService } from '../../src/services/ConfigService'; +import { HoneyPotEnforcementService } from '../../src/services/HoneyPotEnforcementService'; +import { UserProfileService } from '../../src/services/UserProfileService'; import { createTestDb } from '../helpers/db'; import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages'; diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index bdbc5db7..eb2fb160 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -3,7 +3,7 @@ import { after, before, beforeEach, test } from 'node:test'; import { Collection, PermissionFlagsBits } from 'discord.js'; import type { Sequelize } from 'sequelize-typescript'; import post from '../../src/slash-commands/Admin/post'; -import { ConfigService } from '../../src/utilities/ConfigService'; +import { ConfigService } from '../../src/services/ConfigService'; import { createTestDb } from '../helpers/db'; import { fakeInteraction } from '../fakes/interaction'; import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages'; From 3a987a8fa5eabc81dd0ea28f6b1aa496b54a2469 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:50:25 +0100 Subject: [PATCH 14/77] Normalise wording and style, fix about embed, drop redundant @types/validator, bump to 3.0.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 5 +- package.json | 3 +- src/slash-commands/Admin/noroles.ts | 2 +- src/slash-commands/Admin/post.ts | 2 +- .../Config/Subcommands/birthdays.ts | 6 +- .../Config/Subcommands/newroles.ts | 2 +- src/slash-commands/Info/about.ts | 2 +- src/slash-commands/Info/mybirthday.ts | 2 +- src/slash-commands/Info/ping.ts | 2 +- src/slash-commands/Info/profile.ts | 2 +- src/utilities/format.ts | 54 +++++----- tests/about.test.ts | 102 +++++++++--------- tests/birthdays/commands.test.ts | 2 +- tests/onboarding/commands.test.ts | 2 +- 14 files changed, 93 insertions(+), 95 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3132549..711bb0c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dingir", - "version": "2.6.2", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dingir", - "version": "2.6.2", + "version": "3.0.0", "dependencies": { "@napi-rs/canvas": "^1.0.2", "discord.js": "^14.26.5", @@ -25,7 +25,6 @@ "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "@types/node-schedule": "^2.1.8", - "@types/validator": "^13.15.10", "copyfiles": "^2.4.1", "eslint": "^10.6.0", "rimraf": "^6.1.3", diff --git a/package.json b/package.json index 16f0e99e..23556a78 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "git", "url": "https://github.com/BrentOates/dingir.git" }, - "version": "2.6.2", + "version": "3.0.0", "engines": { "node": ">=24" }, @@ -37,7 +37,6 @@ "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "@types/node-schedule": "^2.1.8", - "@types/validator": "^13.15.10", "copyfiles": "^2.4.1", "eslint": "^10.6.0", "rimraf": "^6.1.3", diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts index f6cb2bb7..3c0aed9d 100644 --- a/src/slash-commands/Admin/noroles.ts +++ b/src/slash-commands/Admin/noroles.ts @@ -3,7 +3,7 @@ import { buildMemberListing } from '../../services/MemberListing'; export default defineCommand({ name: 'noroles', - description: 'Returns members of this guild with no roles assigned', + description: 'List members of this guild with no roles assigned', adminOnly: true, defer: 'ephemeral', run: async (ctx) => { diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts index 4e8206f7..cbf0cc5e 100644 --- a/src/slash-commands/Admin/post.ts +++ b/src/slash-commands/Admin/post.ts @@ -8,7 +8,7 @@ import { Logger } from '../../utilities/Logger'; export default defineCommand({ name: 'post', - description: 'Posts a simple message and/or attachment to the given channel', + description: 'Post a simple message and/or attachment to the given channel', adminOnly: true, defer: 'ephemeral', options: (b) => diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index 5d3bfdda..fbcd45cf 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -13,7 +13,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ subcommands: [ { name: 'create', - description: 'Creates or recreates a birthday calendar for this server', + description: 'Create or recreate a birthday calendar for this server', defer: 'ephemeral', options: (sub) => sub.addChannelOption((opt) => @@ -62,7 +62,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ }, { name: 'sync', - description: 'Syncs the birthday calendar for this server', + description: 'Sync the birthday calendar for this server', defer: 'ephemeral', run: async (ctx) => { const status = await refreshCalendar(ctx.interaction.client, ctx.config); @@ -81,7 +81,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ }, { name: 'remove', - description: 'Removes the birthday calendar for this server', + description: 'Remove the birthday calendar for this server', defer: 'ephemeral', run: async (ctx) => { if (!ctx.config.birthdayCalendarMessagePath) { diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index 798fea21..5a7cec62 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -19,7 +19,7 @@ const auditChange = (ctx: CommandContext, description: string, roleIds: string[] const get = async (ctx: CommandContext) => { const ids = parseRoleIds(ctx.config.guestRoleIds); if (ids.length === 0) { - await ctx.reply('No new-member roles configured'); + await ctx.reply('No new-member roles are configured.'); return; } const lines = ids.map((id) => diff --git a/src/slash-commands/Info/about.ts b/src/slash-commands/Info/about.ts index 3d0cedc5..e78f0a67 100644 --- a/src/slash-commands/Info/about.ts +++ b/src/slash-commands/Info/about.ts @@ -20,7 +20,7 @@ export default defineCommand({ const embed = new EmbedBuilder() .setColor(EmbedColours.info) .setTitle('About Dingir') - .setDescription('Show information about Dingir and this server') + .setDescription('A Discord bot for the Irkallu server') .addFields( { name: 'Version', value: version, inline: false }, { name: 'Source', value: repoUrl, inline: false }, diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index 93cb014d..018e7531 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -59,7 +59,7 @@ export default defineCommand({ const text = isToday ? 'today 🎉' : next.setLocale('en-GB').toLocaleString(DateTime.DATE_FULL); - await ctx.reply(`Saved! Your next birthday is ${text}`); + await ctx.reply(`Saved. Your next birthday is ${text}`); await refresh(ctx); }, }, diff --git a/src/slash-commands/Info/ping.ts b/src/slash-commands/Info/ping.ts index 80250284..52c4b629 100644 --- a/src/slash-commands/Info/ping.ts +++ b/src/slash-commands/Info/ping.ts @@ -2,7 +2,7 @@ import { defineCommand } from '../../framework/command'; export default defineCommand({ name: 'ping', - description: 'Pings Dingir', + description: 'Ping Dingir', defer: 'ephemeral', run: async (ctx) => { const roundTrip = Date.now() - ctx.interaction.createdTimestamp; diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index 8bfac562..b7498662 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -7,7 +7,7 @@ import { UserProfileService } from '../../services/UserProfileService'; export default defineCommand({ name: 'profile', - description: 'Fetches profiles for server members', + description: 'Fetch profiles for server members', adminOnly: true, options: (b) => b.addUserOption((opt) => diff --git a/src/utilities/format.ts b/src/utilities/format.ts index aebb0ba0..35e6366f 100644 --- a/src/utilities/format.ts +++ b/src/utilities/format.ts @@ -1,38 +1,38 @@ export function toRepoUrl(url: string): string { - let result = url; - if (result.startsWith('git+')) { - result = result.slice(4); - } - if (result.endsWith('.git')) { - result = result.slice(0, -4); - } - return result; + let result = url; + if (result.startsWith('git+')) { + result = result.slice(4); + } + if (result.endsWith('.git')) { + result = result.slice(0, -4); + } + return result; } export function formatUptime(ms: number | null): string { - if (ms === null) { - return 'unknown'; - } + if (ms === null) { + return 'unknown'; + } - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); - const remainingMinutes = minutes % 60; - const remainingHours = hours % 24; + const remainingMinutes = minutes % 60; + const remainingHours = hours % 24; - if (days > 0) { - return `${days}d ${remainingHours}h ${remainingMinutes}m`; - } + if (days > 0) { + return `${days}d ${remainingHours}h ${remainingMinutes}m`; + } - if (hours > 0) { - return `${hours}h ${remainingMinutes}m`; - } + if (hours > 0) { + return `${hours}h ${remainingMinutes}m`; + } - if (minutes > 0) { - return `${minutes}m`; - } + if (minutes > 0) { + return `${minutes}m`; + } - return '0m'; + return '0m'; } diff --git a/tests/about.test.ts b/tests/about.test.ts index f3f2a415..73f15436 100644 --- a/tests/about.test.ts +++ b/tests/about.test.ts @@ -3,58 +3,58 @@ import assert from 'node:assert/strict'; import { toRepoUrl, formatUptime } from '../src/utilities/format'; test('toRepoUrl', async (t) => { - await t.test('removes git+ prefix', () => { - assert.equal( - toRepoUrl('git+https://github.com/BrentOates/dingir.git'), - 'https://github.com/BrentOates/dingir' - ); - }); - - await t.test('removes .git suffix', () => { - assert.equal( - toRepoUrl('https://github.com/BrentOates/dingir.git'), - 'https://github.com/BrentOates/dingir' - ); - }); - - await t.test('leaves URL unchanged if no prefix or suffix', () => { - assert.equal( - toRepoUrl('https://github.com/BrentOates/dingir'), - 'https://github.com/BrentOates/dingir' - ); - }); - - await t.test('removes both git+ prefix and .git suffix', () => { - assert.equal( - toRepoUrl('git+https://github.com/BrentOates/dingir.git'), - 'https://github.com/BrentOates/dingir' - ); - }); + await t.test('removes git+ prefix', () => { + assert.equal( + toRepoUrl('git+https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir' + ); + }); + + await t.test('removes .git suffix', () => { + assert.equal( + toRepoUrl('https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir' + ); + }); + + await t.test('leaves URL unchanged if no prefix or suffix', () => { + assert.equal( + toRepoUrl('https://github.com/BrentOates/dingir'), + 'https://github.com/BrentOates/dingir' + ); + }); + + await t.test('removes both git+ prefix and .git suffix', () => { + assert.equal( + toRepoUrl('git+https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir' + ); + }); }); test('formatUptime', async (t) => { - await t.test('returns "unknown" for null', () => { - assert.equal(formatUptime(null), 'unknown'); - }); - - await t.test('returns "0m" for 0', () => { - assert.equal(formatUptime(0), '0m'); - }); - - await t.test('returns "0m" for 59 seconds', () => { - assert.equal(formatUptime(59000), '0m'); - }); - - await t.test('returns "1m" for 1 minute', () => { - assert.equal(formatUptime(60000), '1m'); - }); - - await t.test('returns "1h 0m" for 1 hour', () => { - assert.equal(formatUptime(3600000), '1h 0m'); - }); - - await t.test('returns "3d 4h 12m" for 3 days 4 hours 12 minutes', () => { - const ms = (3 * 24 * 60 * 60 * 1000) + (4 * 60 * 60 * 1000) + (12 * 60 * 1000); - assert.equal(formatUptime(ms), '3d 4h 12m'); - }); + await t.test('returns "unknown" for null', () => { + assert.equal(formatUptime(null), 'unknown'); + }); + + await t.test('returns "0m" for 0', () => { + assert.equal(formatUptime(0), '0m'); + }); + + await t.test('returns "0m" for 59 seconds', () => { + assert.equal(formatUptime(59000), '0m'); + }); + + await t.test('returns "1m" for 1 minute', () => { + assert.equal(formatUptime(60000), '1m'); + }); + + await t.test('returns "1h 0m" for 1 hour', () => { + assert.equal(formatUptime(3600000), '1h 0m'); + }); + + await t.test('returns "3d 4h 12m" for 3 days 4 hours 12 minutes', () => { + const ms = (3 * 24 * 60 * 60 * 1000) + (4 * 60 * 60 * 1000) + (12 * 60 * 1000); + assert.equal(formatUptime(ms), '3d 4h 12m'); + }); }); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index f3e469fc..55503233 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -62,7 +62,7 @@ test('mybirthday set stores Feb 29 as 29', async () => { const stored = await UserProfile.findOne({ where: { userId: 'user-1' } }); assert.equal(stored?.birthdayDay, 29); assert.equal(stored?.birthdayMonth, 2); - assert.match(lastContent(calls), /^Saved! Your next birthday is /); + assert.match(lastContent(calls), /^Saved. Your next birthday is /); }); test('mybirthday set rejects invalid dates', async () => { diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 72735e15..623e48ed 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -61,7 +61,7 @@ test('newroles get is read-only: no audit, marks missing roles', async () => { const empty = fakeOnboarding(); const e = fakeCommandContext({}, empty); await handler(NewRolesGroup, 'get')(e.ctx); - assert.equal(e.replies[0].content, 'No new-member roles configured'); + assert.equal(e.replies[0].content, 'No new-member roles are configured.'); }); test('newroles clear nulls roles and audits', async () => { From 04b76893cfe47e4059e2878aadeebf69667be114 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:52:32 +0100 Subject: [PATCH 15/77] Rewrite README for v3 Comprehensive guide based on actual code. Covers setup (local & Docker), environment configuration (all vars with defaults), Discord setup (intents and permissions), all commands organized by category with descriptions, behaviour (onboarding flow, audit logs, honeypot, activity score, birthdays with timezone and Feb 29 handling, scheduled job order), data storage and retention policy, and contributing guidelines with code examples. Co-Authored-By: Claude Haiku 4.5 --- README.md | 396 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 384 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4e439c83..8af65cf1 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,387 @@ # Dingir -Dingir is a Discord.js bot written in TypeScript, with some of the basic functions that we use in the Irkallu discord server. -Requires Node 24 or newer to run. -## Getting started -To run bot, all you need to do is... +A Discord bot for the Irkallu server, built with discord.js and TypeScript. Provides member onboarding, birthday management, activity tracking, and moderation tools. Requires Node 24 or newer. + +## Getting Started + +### Local Development + 1. Clone the repository -2. Install dependencies using `npm install` or `yarn` -3. Copy the `.env.example` file to `.env`, and provide a Discord Bot token ([Discord Developer Portal](https://discord.com/developers/docs/topics/oauth2#bots)) -4. Build the project with `npm run build` or `yarn run build` -5. Run the build project with `npm start` or `yarn start` - -## Persistence -Data is currently stored locally in a SQLite database, placed in a `data` folder at the root of the repository. -In the future, I want to implement functionality to be able to use a SQL Server purely by configuration. +2. Install dependencies: `npm ci` +3. Copy `.env.example` to `.env` and fill in `TOKEN` and `CLIENT_ID` from the [Discord Developer Portal](https://discord.com/developers/applications) +4. Build: `npm run build` +5. Start: `npm start` + +For watch mode during development: +```bash +npm run dev +``` + +Test and lint: +```bash +npm test # Run the test suite +npm run lint # Check code style and errors +``` + +### Docker + +Build and run the bot in a container with a persistent database volume: + +```bash +docker build -t dingir . +docker run -d \ + --env-file .env \ + -v dingir-data:/usr/src/app/data \ + --name dingir \ + dingir +``` + +The database is stored in `/usr/src/app/data` inside the container; mount a volume there to persist it between runs. + +## Configuration + +All configuration is managed through environment variables in `.env`. See `.env.example` for defaults. + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `TOKEN` | Yes | — | Discord bot token from the Developer Portal | +| `CLIENT_ID` | Yes | — | Discord application (client) ID from the Developer Portal | +| `JOB_SCHEDULE` | No | `0 9 * * *` | Cron schedule (5 or 6 fields) for daily cleanup, birthday notifications, and calendar updates. Example: `0 9 * * *` = 9:00 AM daily | +| `BOT_TIMEZONE` | No | `Europe/London` | IANA time zone used for scheduling and birthday calculations (e.g. `Europe/London`, `America/New_York`) | +| `DB_PATH` | No | `data/dingir.sqlite` | Path to SQLite database file. Use `:memory:` for throwaway runs (no persistence) | +| `PURGE_MIN_FAILURES` | No | `3` | Minimum number of failed guild access checks before purging orphaned data (see Data & Retention) | +| `PURGE_GRACE_DAYS` | No | `7` | Grace period (days) after first failure before purging. Data is purged only after both thresholds are met | +| `DEV_GUILD_ID` | No | — | Guild ID for instant slash command registration during development (commands update immediately instead of within 1 hour). Omit in production | + +## Discord Setup + +### Required Intents + +The bot declares these gateway intents. The first two are **privileged** and must be enabled in the Developer Portal under "Bot" → "Privileged Gateway Intents": + +- **Guilds** (unprivileged) +- **Guild Members** (privileged) +- **Guild Messages** (unprivileged) +- **Message Content** (privileged) + +### Bot Permissions + +The bot requires specific permissions on a per-feature basis. When inviting the bot, ensure it has: + +- **View Channels** — required to see and access channels for all operations +- **Send Messages** — required for welcome messages, announcements, audit logs +- **Embed Links** — required to send embeds (profile, about, audit logs) +- **Attach Files** — required to send welcome images +- **Read Message History** — required to edit birthday calendar messages +- **Manage Roles** — required to assign roles during onboarding +- **Ban Members** — required for honeypot enforcement + +The invite URL should include all these permissions. You can generate one in the Developer Portal under "OAuth2" → "URL Generator". + +Use `DEV_GUILD_ID` in `.env` to test commands instantly in a single guild instead of waiting 1 hour for global registration. + +## Commands + +Commands are organized into categories. Only **admin-only** commands require the Administrator permission; all others are available to any member. + +### Info Commands + +| Command | Who Can Use | Description | +|---------|------------|-------------| +| `/ping` | Everyone | Show WebSocket latency and round-trip time | +| `/about` | Everyone | Display Dingir version, source code link, server info, and uptime | +| `/profile ` | Admin | Fetch a member's profile: nickname, username, join date, onboarding status, activity score, and birthday if set | +| `/mybirthday set ` | Everyone | Set your birthday for this server | +| `/mybirthday clear` | Everyone | Remove your birthday from this server | + +### Admin Commands + +| Command | Description | +|---------|-------------| +| `/simulate join [member]` | Audit only: send the "member joined" audit for a member (no roles or welcome message). Defaults to you | +| `/simulate onboard [member]` | Dry run: show what onboarding would do (guest roles, welcome) without applying changes. Defaults to you | +| `/rolesince [days]` | List members in a role who joined the server at least N days ago | +| `/noroles` | List all members with no roles assigned (excluding bots) | +| `/post [content] [attachment]` | Post a simple message and/or file to a channel. Requires at least one of content or attachment | + +### Config Commands + +Use `/config ` to manage server settings. All are admin-only. + +#### `/config announcements` — Announcements Channel + +| Subcommand | Description | +|------------|-------------| +| `set ` | Set the announcements channel | +| `get` | Show the currently configured announcements channel | +| `clear` | Clear the announcements channel | + +#### `/config audit` — Audit Log Channel + +| Subcommand | Description | +|------------|-------------| +| `set ` | Set the audit log channel (where member events are posted) | +| `get` | Show the currently configured audit channel | +| `clear` | Clear the audit channel | + +#### `/config sysmsgs` — Bot System Messages + +Toggle whether the bot uses Discord's system channel for welcome messages: + +| Subcommand | Description | +|------------|-------------| +| `enable` | Enable bot system messages in the server's system channel | +| `disable` | Disable bot system messages in the server's system channel | +| `status` | Show the current status | + +#### `/config honeypot` — Honeypot Channel + +Configure a channel that automatically bans members who post in it: + +| Subcommand | Description | +|------------|-------------| +| `set ` | Set the honeypot channel | +| `get` | Show the currently configured honeypot channel | +| `clear` | Clear the honeypot channel | + +#### `/config welcome` — Welcome Messages & Images + +| Subcommand | Description | +|------------|-------------| +| `set-message ` | Set the welcome message (max 1500 characters). Use `{member}` to mention the new member | +| `set-image ` | Set the background image for welcome images (http/https URL) | +| `get` | Show the welcome message and image URL | +| `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | +| `preview` | Preview the welcome message as it would be sent for you | + +#### `/config newroles` — New Member Roles + +Roles given to members when they complete onboarding: + +| Subcommand | Description | +|------------|-------------| +| `get` | Show the current new-member roles | +| `set [role-two] [role-three]` | Set up to three roles to give on onboarding | +| `clear` | Clear the new-member roles | + +#### `/config birthdays` — Birthday Calendar + +| Subcommand | Description | +|------------|-------------| +| `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays) | +| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | +| `remove` | Remove the birthday calendar message | + +#### `/config debug` — Onboarding Diagnostics + +Toggle diagnostic audits for onboarding runs: + +| Subcommand | Description | +|------------|-------------| +| `enable` | Post a summary of each onboarding run to the audit channel | +| `disable` | Do not post onboarding summaries | +| `status` | Show the current status | + +## Behaviour + +### Onboarding + +When a member joins without screening enabled, or completes server screening: + +1. **Audit**: A "member joined" audit is posted to the configured audit channel (if set) +2. **Guest Roles**: Configured new-member roles are assigned (if any are set) +3. **Welcome**: A welcome message is posted to the system channel or audit channel, optionally with a background image +4. **Debug**: If debug mode is enabled, a diagnostic summary is posted to the audit channel showing what succeeded, failed, or was skipped + +Roles are only assigned if: +- They are below the bot's highest role in the role hierarchy +- They are not managed by an integration +- They are not `@everyone` + +If a role cannot be assigned, the error is logged and the onboarding continues for other roles and steps. + +### Audit Log + +Member events are posted to the configured audit channel: +- Member joined (join audit) +- Member completed onboarding +- Member left (with member data cleanup status) +- Role changes (new-member roles updated/cleared) +- Messages posted via `/post` +- Any failed or unusual events (role assignment failures, onboarding failures, honeypot triggers) + +### Honeypot + +If a honeypot channel is configured, any non-administrator member who posts in it is: +1. Immediately banned (with messages from the past 7 days deleted) +2. Removed from the database +3. Logged to the audit channel + +### Activity Score + +Each message posted by a human (non-bot, non-webhook) in any channel increments the member's activity score by 1. This is used to rank member engagement and is visible in `/profile`. + +### Birthdays + +Members can set their birthday using `/mybirthday set `. Birthdays are stored per-server. + +**Birthday Announcements**: On the configured schedule (default 9:00 AM daily), the bot checks all servers and posts birthday announcements in the announcements channel for members with birthdays today. If a member's birthday is Feb 29 and it's not a leap year, they are celebrated on Feb 28 instead. + +**Birthday Calendar**: If configured, a pinned calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job. Members can create a new calendar with `/config birthdays create`, and it is recreated if the message or channel is deleted. + +**Timezones**: All birthday calculations use the `BOT_TIMEZONE` setting. Members' birthdays are stored as month/day only (no year), and the next occurrence is calculated relative to the bot's configured time zone. + +### Scheduled Daily Job + +On the schedule defined by `JOB_SCHEDULE` (default: 9:00 AM UTC), three tasks run in order: + +1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. +2. **Birthday Notifications** — Posts birthday announcements in the announcements channel for members with birthdays today +3. **Birthday Calendars** — Updates all configured birthday calendar messages + +## Data & Retention + +### Storage + +All data is persisted in SQLite at the path specified by `DB_PATH` (default: `data/dingir.sqlite`). The database directory is created automatically if it doesn't exist. + +Migrations run automatically on startup. Current migrations: + +- **001-baseline** — Initial schema (ServerConfigs and UserProfiles tables) +- **002-userprofile-unique** — Adds a unique constraint on (serverId, userId). If duplicates exist, they are merged: the profile with the most recent birthday is kept, and activity scores are summed. **Backups are strongly recommended before upgrading from v2 to v3** because this migration is permanent and irreversible. +- **003-serverconfig-access-tracking** — Adds access failure tracking for orphaned guild detection + +### What Gets Deleted + +| Event | Data Deleted | +|-------|--------------| +| Member leaves server | User profile (birthday, activity score, settings) for that server | +| Bot removed from server | All configuration and user profiles for that server (immediate) | +| Guild unreachable for `PURGE_MIN_FAILURES` checks spanning `PURGE_GRACE_DAYS` days | All configuration and user profiles for that guild (purge) | + +**Note**: Transient errors (network timeouts, API rate limiting, temporary outages) never count toward the purge threshold. Only permanent failures (guild deleted, bot kicked) count. + +## Contributing + +### Project Layout + +``` +src/ + client/ # Discord.js client setup and models + database/ # SQLite schema, migrations, sequelize config + models/ # ServerConfig, UserProfile ORM models + NovaClient.ts # Main client class (intents, startup) + config/ + env.ts # Environment loading and validation + events/ # Discord event handlers + framework/ + command.ts # Command definition and resolution + event.ts # Event definition + settings.ts # Reusable /config setting groups (channel, boolean) + services/ # Business logic (onboarding, birthdays, audit, etc.) + slash-commands/ # Slash command definitions, organized by category + Info/ + Admin/ + Config/Subcommands/ + utilities/ # Logging, formatting + index.ts # Entry point +``` + +### Adding a Command + +Slash commands are defined in `src/slash-commands/`. Here's a minimal example: + +```typescript +// src/slash-commands/Info/hello.ts +import { defineCommand } from '../../framework/command'; + +export default defineCommand({ + name: 'hello', + description: 'Greet a member', + options: (b) => + b.addUserOption((opt) => + opt.setName('member').setDescription('Member to greet').setRequired(true) + ), + run: async (ctx) => { + const user = ctx.interaction.options.getUser('member', true); + await ctx.reply(`Hello ${user.toString()}!`); + }, +}); +``` + +Commands are auto-loaded from the directory. Set `adminOnly: true` to require Administrator permission. Use `defer: 'ephemeral'` for private replies. + +### Adding a Config Setting + +Use the helpers in `src/framework/settings.ts`. For a channel setting: + +```typescript +// src/slash-commands/Config/config.ts (add to groups array) +channelSetting({ + name: 'myfeature', + description: 'Set the my feature channel', + field: 'myFeatureChannelId', // Must exist in ServerConfig + label: 'My feature channel', + channelTypes: [ChannelType.GuildText], +}) +``` + +Then ensure the field is defined in the ServerConfig model and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. + +**Migrations**: If adding a new config field, create a migration file in `src/client/database/migrations/`: + +```typescript +// src/client/database/migrations/004-add-myfeature.ts +import { DataTypes } from 'sequelize'; +import type { MigrationParams } from './types'; + +export async function up({ context }: MigrationParams): Promise { + const columns = await context.describeTable('ServerConfigs'); + if (!('myFeatureChannelId' in columns)) { + await context.addColumn('ServerConfigs', 'myFeatureChannelId', { + type: DataTypes.STRING, + allowNull: true, + }); + } +} + +export async function down({ context }: MigrationParams): Promise { + await context.removeColumn('ServerConfigs', 'myFeatureChannelId'); +} +``` + +### Adding an Event + +Events are defined in `src/events/`. Discord.js events are mapped automatically. Example: + +```typescript +// src/events/myevent.ts +import { defineEvent } from '../framework/event'; + +export default defineEvent({ + name: 'messageCreate', // discord.js event name + run: async (client, message) => { + // Handle the event + }, +}); +``` + +Set `once: true` to only listen once (useful for `clientReady`). + +### Adding a Migration + +Migrations use Umzug and Sequelize's QueryInterface. Create a new file in `src/client/database/migrations/`: + +```typescript +import type { MigrationParams } from './types'; + +export async function up({ context }: MigrationParams): Promise { + // Apply the change +} + +export async function down({ context }: MigrationParams): Promise { + // Revert the change (for development; not used in production) +} +``` + +Migrations run on startup in order. See `001-baseline.ts` and `002-userprofile-unique.ts` for examples. From 9a00cfb4f79dddcec3fd1cbe867fb06eec248c34 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:00:55 +0100 Subject: [PATCH 16/77] Replace Sequelize schema and umzug with Drizzle schema and a sync SQLite migrator Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 1358 ++--------------- package.json | 10 +- src/client/NovaClient.ts | 9 +- src/client/database/createSequelize.ts | 14 - src/client/database/db.ts | 34 + .../database/migrations/001-baseline.ts | 85 +- .../migrations/002-userprofile-unique.ts | 101 +- .../003-serverconfig-access-tracking.ts | 29 +- src/client/database/migrations/index.ts | 16 - src/client/database/migrations/types.ts | 3 - src/client/database/migrator.ts | 49 +- src/client/database/schema.ts | 64 + src/client/database/sequelize.ts | 4 - src/client/models/ServerConfig.ts | 45 - src/client/models/UserProfile.ts | 25 - src/events/messageCreate.ts | 2 +- src/framework/command.ts | 2 +- src/framework/settings.ts | 12 +- src/services/AuditService.ts | 2 +- src/services/BirthdayService.ts | 2 +- src/services/ConfigService.ts | 66 +- src/services/DataCheckService.ts | 7 +- src/services/OnboardingService.ts | 2 +- src/services/UserProfileService.ts | 124 +- .../Config/Subcommands/birthdays.ts | 11 +- .../Config/Subcommands/newroles.ts | 9 +- .../Config/Subcommands/welcome.ts | 16 +- tsconfig.json | 2 - 28 files changed, 538 insertions(+), 1565 deletions(-) delete mode 100644 src/client/database/createSequelize.ts create mode 100644 src/client/database/db.ts delete mode 100644 src/client/database/migrations/index.ts delete mode 100644 src/client/database/migrations/types.ts create mode 100644 src/client/database/schema.ts delete mode 100644 src/client/database/sequelize.ts delete mode 100644 src/client/models/ServerConfig.ts delete mode 100644 src/client/models/UserProfile.ts diff --git a/package-lock.json b/package-lock.json index 711bb0c4..7c0a1b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,17 @@ "version": "3.0.0", "dependencies": { "@napi-rs/canvas": "^1.0.2", + "better-sqlite3": "^13.0.3", "discord.js": "^14.26.5", "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.3", "luxon": "^3.7.2", "node-schedule": "^2.1.1", - "reflect-metadata": "^0.2.2", - "sequelize": "^6.37.8", - "sequelize-typescript": "^2.1.6", - "sqlite3": "^6.0.1", - "tslib": "^2.8.1", - "umzug": "^3.8.3" + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "@types/node-schedule": "^2.1.8", @@ -409,18 +407,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@napi-rs/canvas": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", @@ -685,91 +671,6 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@rushstack/node-core-library": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", - "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", - "license": "MIT", - "dependencies": { - "fs-extra": "~7.0.1", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", - "resolve": "~1.22.1", - "semver": "~7.5.4", - "z-schema": "~5.0.2" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rushstack/node-core-library/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, - "node_modules/@rushstack/terminal": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", - "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", - "license": "MIT", - "dependencies": { - "@rushstack/node-core-library": "4.0.2", - "supports-color": "~8.1.1" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@rushstack/ts-command-line": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", - "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", - "license": "MIT", - "dependencies": { - "@rushstack/terminal": "0.10.0", - "@types/argparse": "1.0.38", - "argparse": "~1.0.9", - "string-argv": "~0.3.1" - } - }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -803,19 +704,14 @@ "npm": ">=7.0.0" } }, - "node_modules/@types/argparse": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", - "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", - "license": "MIT" - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/ms": "*" + "@types/node": "*" } }, "node_modules/@types/esrecurse": { @@ -846,12 +742,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -871,12 +761,6 @@ "@types/node": "*" } }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1165,16 +1049,6 @@ "npm": ">=7.0.0" } }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1241,127 +1115,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/bl/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" + "node": ">=22" } }, "node_modules/brace-expansion": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -1394,20 +1177,11 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/copyfiles": { @@ -1490,6 +1264,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1503,30 +1278,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1534,15 +1285,6 @@ "dev": true, "license": "MIT" }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/discord-api-types": { "version": "0.38.49", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", @@ -1591,12 +1333,134 @@ "url": "https://dotenvx.com" } }, - "node_modules/dottie": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", - "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } }, "node_modules/duplexer": { "version": "0.1.2", @@ -1605,18 +1469,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1624,34 +1476,6 @@ "dev": true, "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1885,22 +1709,6 @@ "through": "~2.3.1" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0", - "optional": true - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1925,6 +1733,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1951,12 +1760,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2002,41 +1805,13 @@ "dev": true, "license": "MIT" }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, "license": "ISC" }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2047,12 +1822,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -2123,53 +1892,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2180,15 +1902,6 @@ "node": ">= 4" } }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2199,20 +1912,12 @@ "node": ">=0.8.19" } }, - "node_modules/inflection": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", - "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", - "engines": [ - "node >= 0.4.0" - ], - "license": "MIT" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -2223,29 +1928,9 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/is-core-module": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", - "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2293,12 +1978,6 @@ "dev": true, "license": "ISC" }, - "node_modules/jju": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", - "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", - "license": "MIT" - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2320,15 +1999,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2375,20 +2045,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -2432,22 +2088,11 @@ "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", "dev": true }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2456,36 +2101,16 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -2499,43 +2124,11 @@ "node": ">=10" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, "license": "MIT" }, "node_modules/natural-compare": { @@ -2545,22 +2138,10 @@ "dev": true, "license": "MIT" }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", - "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -2573,57 +2154,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/node-schedule": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", @@ -2649,26 +2179,11 @@ "readable-stream": "~1.0.31" } }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2745,6 +2260,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2760,12 +2276,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -2796,16 +2306,11 @@ "through": "~2.3" } }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2814,42 +2319,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pony-cause": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-2.1.11.tgz", - "integrity": "sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==", - "license": "0BSD", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2860,16 +2329,6 @@ "node": ">= 0.8.0" } }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -2893,16 +2352,6 @@ "node": ">= 0.10" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2913,21 +2362,6 @@ "node": ">=6" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", @@ -2941,12 +2375,6 @@ "string_decoder": "~0.10.x" } }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2957,33 +2385,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/retry-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", - "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", - "license": "MIT" - }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -3004,30 +2405,11 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3036,116 +2418,6 @@ "node": ">=10" } }, - "node_modules/sequelize": { - "version": "6.37.8", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", - "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/sequelize" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.8", - "@types/validator": "^13.7.17", - "debug": "^4.3.4", - "dottie": "^2.0.6", - "inflection": "^1.13.4", - "lodash": "^4.17.21", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "pg-connection-string": "^2.6.1", - "retry-as-promised": "^7.0.4", - "semver": "^7.5.4", - "sequelize-pool": "^7.1.0", - "toposort-class": "^1.0.1", - "uuid": "^8.3.2", - "validator": "^13.9.0", - "wkx": "^0.5.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependenciesMeta": { - "ibm_db": { - "optional": true - }, - "mariadb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "oracledb": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-hstore": { - "optional": true - }, - "snowflake-sdk": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } - } - }, - "node_modules/sequelize-pool": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", - "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/sequelize-typescript": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-2.1.6.tgz", - "integrity": "sha512-Vc2N++3en346RsbGjL3h7tgAl2Y7V+2liYTAOZ8XL0KTw3ahFHsyAUzOwct51n+g70I1TOUDgs06Oh6+XGcFkQ==", - "license": "MIT", - "dependencies": { - "glob": "7.2.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "@types/validator": "*", - "reflect-metadata": "*", - "sequelize": ">=6.20.1" - } - }, - "node_modules/sequelize-typescript/node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3169,51 +2441,6 @@ "node": ">=8" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/sorted-array-functions": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", @@ -3233,39 +2460,6 @@ "node": "*" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/sqlite3": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", - "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^8.0.0", - "prebuild-install": "^7.1.3", - "tar": "^7.5.10" - }, - "engines": { - "node": ">=20.17.0" - }, - "optionalDependencies": { - "node-gyp": "12.x" - }, - "peerDependencies": { - "node-gyp": "12.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, "node_modules/stream-combiner": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", @@ -3287,6 +2481,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6.19" @@ -3320,115 +2515,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tar-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -3491,6 +2577,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3503,12 +2590,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/toposort-class": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", - "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3556,18 +2637,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3581,18 +2650,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3631,22 +2688,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/umzug": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/umzug/-/umzug-3.8.3.tgz", - "integrity": "sha512-U9SRJI6LJvV0XwrqGMVPBkE26WHJklHZjtscJ2sEjUp7f+h4NH/25YGjPBernWLroVJvMnTkCAGC0bT0dd63qA==", - "license": "MIT", - "dependencies": { - "@rushstack/ts-command-line": "4.19.1", - "emittery": "^0.13.0", - "pony-cause": "^2.1.4", - "tinyglobby": "^0.2.16", - "type-fest": "^4.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -3662,15 +2703,6 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -3695,30 +2727,9 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3735,15 +2746,6 @@ "node": ">= 8" } }, - "node_modules/wkx": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", - "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3776,6 +2778,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -3819,15 +2822,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/yargs": { "version": "16.2.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", @@ -3869,26 +2863,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } } } } diff --git a/package.json b/package.json index 23556a78..fa2dc0e7 100644 --- a/package.json +++ b/package.json @@ -21,19 +21,17 @@ }, "dependencies": { "@napi-rs/canvas": "^1.0.2", + "better-sqlite3": "^13.0.3", "discord.js": "^14.26.5", "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.3", "luxon": "^3.7.2", "node-schedule": "^2.1.1", - "reflect-metadata": "^0.2.2", - "sequelize": "^6.37.8", - "sequelize-typescript": "^2.1.6", - "sqlite3": "^6.0.1", - "tslib": "^2.8.1", - "umzug": "^3.8.3" + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "@types/node-schedule": "^2.1.8", diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts index 1d4e06fe..933698ea 100644 --- a/src/client/NovaClient.ts +++ b/src/client/NovaClient.ts @@ -6,8 +6,8 @@ import { bindEvent } from '../framework/event'; import { loadCommands, loadEvents } from '../framework/loader'; import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown'; import { Logger } from '../utilities/Logger'; +import { getDatabase } from './database/db'; import { migrate } from './database/migrator'; -import { sequelize } from './database/sequelize'; class NovaClient extends Client { public slashCommands: Collection = new Collection(); @@ -26,9 +26,12 @@ class NovaClient extends Client { } public async start(): Promise { - const applied = await migrate(sequelize); + const { sqlite } = getDatabase(); + const applied = migrate(sqlite); Logger.info('Database migrations complete', { applied: applied.length ? applied : 'none' }); - registerShutdownHook(() => sequelize.close()); + registerShutdownHook(() => { + sqlite.close(); + }); const [commands, events] = await Promise.all([ loadCommands(path.join(__dirname, '..', 'slash-commands')), diff --git a/src/client/database/createSequelize.ts b/src/client/database/createSequelize.ts deleted file mode 100644 index cd6b7d01..00000000 --- a/src/client/database/createSequelize.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Sequelize } from 'sequelize-typescript'; -import { Logger } from '../../utilities/Logger'; - -import { ServerConfig } from '../models/ServerConfig'; -import { UserProfile } from '../models/UserProfile'; - -export function createSequelize(storage: string): Sequelize { - return new Sequelize({ - dialect: 'sqlite', - storage, - models: [ServerConfig, UserProfile], - logging: (msg) => Logger.writeLog(msg), - }); -} diff --git a/src/client/database/db.ts b/src/client/database/db.ts new file mode 100644 index 00000000..5b12b35d --- /dev/null +++ b/src/client/database/db.ts @@ -0,0 +1,34 @@ +import Database from 'better-sqlite3'; +import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { env } from '../../config/env'; +import * as schema from './schema'; + +export type AppDatabase = BetterSQLite3Database; + +export interface DatabaseHandle { + sqlite: Database.Database; + db: AppDatabase; +} + +export function createDatabase(path: string): DatabaseHandle { + const sqlite = new Database(path); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + sqlite.pragma('busy_timeout = 5000'); + return { sqlite, db: drizzle(sqlite, { schema }) }; +} + +let current: DatabaseHandle | undefined; + +/** The shared database; opened lazily from env.dbPath unless a test installed one. */ +export function getDatabase(): DatabaseHandle { + current ??= createDatabase(env.dbPath); + return current; +} + +export const getDb = (): AppDatabase => getDatabase().db; + +/** Test seam: point the services at another database. */ +export function setDatabase(handle: DatabaseHandle | undefined): void { + current = handle; +} diff --git a/src/client/database/migrations/001-baseline.ts b/src/client/database/migrations/001-baseline.ts index 70388650..d04020f4 100644 --- a/src/client/database/migrations/001-baseline.ts +++ b/src/client/database/migrations/001-baseline.ts @@ -1,63 +1,56 @@ -import { DataTypes, ModelAttributeColumnOptions, QueryInterface } from 'sequelize'; -import type { MigrationParams } from './types'; +import type { Database } from 'better-sqlite3'; -type Columns = Record; +type Columns = Record; const serverConfigColumns: Columns = { - serverId: { type: DataTypes.STRING, primaryKey: true }, - prefix: { type: DataTypes.STRING, defaultValue: '^' }, - rulesMessagePath: { type: DataTypes.STRING }, - rulesMessage: { type: DataTypes.STRING }, - guestRoleIds: { type: DataTypes.STRING }, - adminRoleId: { type: DataTypes.STRING }, - welcomeMessage: { type: DataTypes.STRING }, - debug: { type: DataTypes.BOOLEAN, defaultValue: false }, - auditChannelId: { type: DataTypes.STRING }, - welcomeMessageBackgroundUrl: { type: DataTypes.STRING }, - systemMessagesEnabled: { type: DataTypes.BOOLEAN, defaultValue: false }, - announcementsChannelId: { type: DataTypes.STRING }, - birthdayCalendarMessagePath: { type: DataTypes.STRING }, - honeyPotChannelId: { type: DataTypes.STRING }, - createdAt: { type: DataTypes.DATE, allowNull: false }, - updatedAt: { type: DataTypes.DATE, allowNull: false }, + serverId: 'VARCHAR(255) PRIMARY KEY', + prefix: "VARCHAR(255) DEFAULT '^'", + rulesMessagePath: 'VARCHAR(255)', + rulesMessage: 'VARCHAR(255)', + guestRoleIds: 'VARCHAR(255)', + adminRoleId: 'VARCHAR(255)', + welcomeMessage: 'VARCHAR(255)', + debug: 'TINYINT(1) DEFAULT 0', + auditChannelId: 'VARCHAR(255)', + welcomeMessageBackgroundUrl: 'VARCHAR(255)', + systemMessagesEnabled: 'TINYINT(1) DEFAULT 0', + announcementsChannelId: 'VARCHAR(255)', + birthdayCalendarMessagePath: 'VARCHAR(255)', + honeyPotChannelId: 'VARCHAR(255)', + createdAt: 'DATETIME NOT NULL', + updatedAt: 'DATETIME NOT NULL', }; const userProfileColumns: Columns = { - id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, - serverId: { type: DataTypes.STRING }, - userId: { type: DataTypes.STRING }, - birthdayYear: { type: DataTypes.INTEGER }, - birthdayMonth: { type: DataTypes.INTEGER }, - birthdayDay: { type: DataTypes.INTEGER }, - activityScore: { type: DataTypes.INTEGER, defaultValue: 0 }, - createdAt: { type: DataTypes.DATE, allowNull: false }, - updatedAt: { type: DataTypes.DATE, allowNull: false }, + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + serverId: 'VARCHAR(255)', + userId: 'VARCHAR(255)', + birthdayYear: 'INTEGER', + birthdayMonth: 'INTEGER', + birthdayDay: 'INTEGER', + activityScore: 'INTEGER DEFAULT 0', + createdAt: 'DATETIME NOT NULL', + updatedAt: 'DATETIME NOT NULL', }; -async function ensureTable( - queryInterface: QueryInterface, - table: string, - columns: Columns -): Promise { - if (!(await queryInterface.tableExists(table))) { - await queryInterface.createTable(table, columns); +function ensureTable(db: Database, table: string, columns: Columns): void { + const existing = db.pragma(`table_info(\`${table}\`)`) as { name: string }[]; + if (existing.length === 0) { + const definitions = Object.entries(columns).map(([name, type]) => `\`${name}\` ${type}`); + db.exec(`CREATE TABLE \`${table}\` (${definitions.join(', ')})`); return; } // Tables created by the old sync() may predate some columns. - const existing = await queryInterface.describeTable(table); - for (const [name, definition] of Object.entries(columns)) { - if (!(name in existing) && !definition.primaryKey && definition.allowNull !== false) { - await queryInterface.addColumn(table, name, definition); + const present = new Set(existing.map((column) => column.name)); + for (const [name, type] of Object.entries(columns)) { + if (!present.has(name) && !/PRIMARY KEY|NOT NULL/.test(type)) { + db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${name}\` ${type}`); } } } -export async function up({ context }: MigrationParams): Promise { - await ensureTable(context, 'ServerConfigs', serverConfigColumns); - await ensureTable(context, 'UserProfiles', userProfileColumns); -} - -export async function down(): Promise { - // Intentionally a no-op: the baseline tables hold production data and are never dropped. +export function up(db: Database): void { + ensureTable(db, 'ServerConfigs', serverConfigColumns); + ensureTable(db, 'UserProfiles', userProfileColumns); } diff --git a/src/client/database/migrations/002-userprofile-unique.ts b/src/client/database/migrations/002-userprofile-unique.ts index 25bfb280..70e77d6d 100644 --- a/src/client/database/migrations/002-userprofile-unique.ts +++ b/src/client/database/migrations/002-userprofile-unique.ts @@ -1,6 +1,5 @@ -import { QueryTypes } from 'sequelize'; +import type { Database } from 'better-sqlite3'; import { Logger } from '../../../utilities/Logger'; -import type { MigrationParams } from './types'; const INDEX_NAME = 'user_profiles_server_user_unique'; @@ -12,13 +11,10 @@ interface ProfileRow { birthdayMonth: number | null; birthdayDay: number | null; activityScore: number | null; - updatedAt: string | Date | null; + updatedAt: string | null; } -const toTime = (value: string | Date | null): number => { - if (value instanceof Date) { - return value.getTime(); - } +const toTime = (value: string | null): number => { const parsed = Date.parse(String(value ?? '').replace(' ', 'T').replace(/ ([+-])/, '$1')); return Number.isNaN(parsed) ? 0 : parsed; }; @@ -32,64 +28,53 @@ const birthdayKey = (row: ProfileRow): string => const newestFirst = (a: ProfileRow, b: ProfileRow): number => toTime(b.updatedAt) - toTime(a.updatedAt) || b.id - a.id; -export async function up({ context }: MigrationParams): Promise { - const sequelize = context.sequelize; - - await sequelize.transaction(async (transaction) => { - const rows = await sequelize.query( - 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL', - { type: QueryTypes.SELECT, transaction } - ); - - const groups = new Map(); - for (const row of rows) { - const key = JSON.stringify([row.serverId, row.userId]); - const group = groups.get(key); - if (group) { - group.push(row); - } else { - groups.set(key, [row]); - } +export function up(db: Database): void { + const rows = db + .prepare( + 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL' + ) + .all() as ProfileRow[]; + + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.serverId, row.userId]); + const group = groups.get(key); + if (group) { + group.push(row); + } else { + groups.set(key, [row]); } + } - for (const group of groups.values()) { - if (group.length < 2) { - continue; - } - - const withBirthday = group.filter(hasBirthday).sort(newestFirst); - const keep = withBirthday.length > 0 ? withBirthday[0] : [...group].sort(newestFirst)[0]; + const setScore = db.prepare('UPDATE `UserProfiles` SET activityScore = ? WHERE id = ?'); + const remove = db.prepare('DELETE FROM `UserProfiles` WHERE id = ?'); - if (new Set(withBirthday.map(birthdayKey)).size > 1) { - Logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { - serverId: keep.serverId, - userId: keep.userId, - keptId: keep.id, - birthdays: withBirthday.map((row) => ({ id: row.id, birthday: birthdayKey(row) })), - }); - } + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } - const maxScore = Math.max(...group.map((row) => row.activityScore ?? 0)); - const removeIds = group.filter((row) => row.id !== keep.id).map((row) => row.id); + const withBirthday = group.filter(hasBirthday).sort(newestFirst); + const keep = withBirthday.length > 0 ? withBirthday[0] : [...group].sort(newestFirst)[0]; - await sequelize.query('UPDATE `UserProfiles` SET activityScore = :maxScore WHERE id = :id', { - replacements: { maxScore, id: keep.id }, - transaction, - }); - await sequelize.query('DELETE FROM `UserProfiles` WHERE id IN (:removeIds)', { - replacements: { removeIds }, - transaction, + if (new Set(withBirthday.map(birthdayKey)).size > 1) { + Logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { + serverId: keep.serverId, + userId: keep.userId, + keptId: keep.id, + birthdays: withBirthday.map((row) => ({ id: row.id, birthday: birthdayKey(row) })), }); } - await context.addIndex('UserProfiles', ['serverId', 'userId'], { - name: INDEX_NAME, - unique: true, - transaction, - }); - }); -} + setScore.run(Math.max(...group.map((row) => row.activityScore ?? 0)), keep.id); + for (const row of group) { + if (row.id !== keep.id) { + remove.run(row.id); + } + } + } -export async function down({ context }: MigrationParams): Promise { - await context.removeIndex('UserProfiles', INDEX_NAME); + db.exec( + `CREATE UNIQUE INDEX \`${INDEX_NAME}\` ON \`UserProfiles\` (\`serverId\`, \`userId\`)` + ); } diff --git a/src/client/database/migrations/003-serverconfig-access-tracking.ts b/src/client/database/migrations/003-serverconfig-access-tracking.ts index 1c4a8f10..dd8332e2 100644 --- a/src/client/database/migrations/003-serverconfig-access-tracking.ts +++ b/src/client/database/migrations/003-serverconfig-access-tracking.ts @@ -1,25 +1,14 @@ -import { DataTypes } from 'sequelize'; -import type { MigrationParams } from './types'; +import type { Database } from 'better-sqlite3'; -export async function up({ context }: MigrationParams): Promise { - const columns = await context.describeTable('ServerConfigs'); +export function up(db: Database): void { + const columns = new Set( + (db.pragma('table_info(`ServerConfigs`)') as { name: string }[]).map((column) => column.name) + ); - if (!('accessFailureCount' in columns)) { - await context.addColumn('ServerConfigs', 'accessFailureCount', { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - }); + if (!columns.has('accessFailureCount')) { + db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `accessFailureCount` INTEGER NOT NULL DEFAULT 0'); } - if (!('firstAccessFailureAt' in columns)) { - await context.addColumn('ServerConfigs', 'firstAccessFailureAt', { - type: DataTypes.DATE, - allowNull: true, - }); + if (!columns.has('firstAccessFailureAt')) { + db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `firstAccessFailureAt` DATETIME'); } } - -export async function down({ context }: MigrationParams): Promise { - await context.removeColumn('ServerConfigs', 'firstAccessFailureAt'); - await context.removeColumn('ServerConfigs', 'accessFailureCount'); -} diff --git a/src/client/database/migrations/index.ts b/src/client/database/migrations/index.ts deleted file mode 100644 index 167c8f97..00000000 --- a/src/client/database/migrations/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as baseline from './001-baseline'; -import * as userProfileUnique from './002-userprofile-unique'; -import * as serverConfigAccessTracking from './003-serverconfig-access-tracking'; -import type { MigrationParams } from './types'; - -export interface MigrationModule { - name: string; - up: (params: MigrationParams) => Promise; - down: (params: MigrationParams) => Promise; -} - -export const migrations: MigrationModule[] = [ - { name: '001-baseline', ...baseline }, - { name: '002-userprofile-unique', ...userProfileUnique }, - { name: '003-serverconfig-access-tracking', ...serverConfigAccessTracking }, -]; diff --git a/src/client/database/migrations/types.ts b/src/client/database/migrations/types.ts deleted file mode 100644 index 2145debd..00000000 --- a/src/client/database/migrations/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { QueryInterface } from 'sequelize'; - -export type MigrationParams = { context: QueryInterface }; diff --git a/src/client/database/migrator.ts b/src/client/database/migrator.ts index 8f2161db..c183ca4e 100644 --- a/src/client/database/migrator.ts +++ b/src/client/database/migrator.ts @@ -1,18 +1,39 @@ -import { QueryInterface } from 'sequelize'; -import type { Sequelize } from 'sequelize-typescript'; -import { SequelizeStorage, Umzug } from 'umzug'; -import { migrations } from './migrations'; +import type { Database } from 'better-sqlite3'; +import * as baseline from './migrations/001-baseline'; +import * as userProfileUnique from './migrations/002-userprofile-unique'; +import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking'; -export function createMigrator(sequelize: Sequelize): Umzug { - return new Umzug({ - migrations, - context: sequelize.getQueryInterface(), - storage: new SequelizeStorage({ sequelize, tableName: 'SequelizeMeta' }), - logger: undefined, - }); +export interface Migration { + name: string; + up: (db: Database) => void; } -export async function migrate(sequelize: Sequelize): Promise { - const applied = await createMigrator(sequelize).up(); - return applied.map((migration) => migration.name); +export const migrations: Migration[] = [ + { name: '001-baseline', up: baseline.up }, + { name: '002-userprofile-unique', up: userProfileUnique.up }, + { name: '003-serverconfig-access-tracking', up: serverConfigAccessTracking.up }, +]; + +/** Applies pending migrations in order, each in its own transaction, and returns their names. */ +export function migrate(db: Database, list: Migration[] = migrations): string[] { + db.exec('CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) PRIMARY KEY)'); + + const done = new Set( + (db.prepare('SELECT name FROM `SequelizeMeta`').all() as { name: string }[]).map((r) => r.name) + ); + const record = db.prepare('INSERT INTO `SequelizeMeta` (name) VALUES (?)'); + const applied: string[] = []; + + for (const migration of list) { + if (done.has(migration.name)) { + continue; + } + db.transaction(() => { + migration.up(db); + record.run(migration.name); + })(); + applied.push(migration.name); + } + + return applied; } diff --git a/src/client/database/schema.ts b/src/client/database/schema.ts new file mode 100644 index 00000000..2d6c0f3b --- /dev/null +++ b/src/client/database/schema.ts @@ -0,0 +1,64 @@ +import { customType, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +const SEQUELIZE_DATE = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2}:\d{2})$/; + +/** Formats like Sequelize did: `2024-01-01 12:00:00.000 +00:00` (always UTC). */ +export const formatDate = (date: Date): string => + `${date.toISOString().replace('T', ' ').replace('Z', '')} +00:00`; + +export const parseDate = (value: string): Date => { + const match = SEQUELIZE_DATE.exec(value); + return new Date(match ? `${match[1]}T${match[2]}${match[3]}` : value); +}; + +const datetime = customType<{ data: Date; driverData: string }>({ + dataType: () => 'DATETIME', + toDriver: formatDate, + fromDriver: parseDate, +}); + +export const serverConfigs = sqliteTable('ServerConfigs', { + serverId: text('serverId', { length: 255 }).primaryKey(), + guestRoleIds: text('guestRoleIds', { length: 255 }), + welcomeMessage: text('welcomeMessage', { length: 255 }), + debug: integer('debug', { mode: 'boolean' }).notNull().default(false), + auditChannelId: text('auditChannelId', { length: 255 }), + welcomeMessageBackgroundUrl: text('welcomeMessageBackgroundUrl', { length: 255 }), + systemMessagesEnabled: integer('systemMessagesEnabled', { mode: 'boolean' }) + .notNull() + .default(false), + announcementsChannelId: text('announcementsChannelId', { length: 255 }), + birthdayCalendarMessagePath: text('birthdayCalendarMessagePath', { length: 255 }), + honeyPotChannelId: text('honeyPotChannelId', { length: 255 }), + accessFailureCount: integer('accessFailureCount').notNull().default(0), + firstAccessFailureAt: datetime('firstAccessFailureAt'), + createdAt: datetime('createdAt') + .notNull() + .$defaultFn(() => new Date()), + updatedAt: datetime('updatedAt') + .notNull() + .$defaultFn(() => new Date()), +}); + +export const userProfiles = sqliteTable( + 'UserProfiles', + { + id: integer('id').primaryKey({ autoIncrement: true }), + serverId: text('serverId', { length: 255 }).notNull(), + userId: text('userId', { length: 255 }).notNull(), + birthdayYear: integer('birthdayYear'), + birthdayMonth: integer('birthdayMonth'), + birthdayDay: integer('birthdayDay'), + activityScore: integer('activityScore').notNull().default(0), + createdAt: datetime('createdAt') + .notNull() + .$defaultFn(() => new Date()), + updatedAt: datetime('updatedAt') + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [uniqueIndex('user_profiles_server_user_unique').on(table.serverId, table.userId)] +); + +export type ServerConfig = typeof serverConfigs.$inferSelect; +export type UserProfile = typeof userProfiles.$inferSelect; diff --git a/src/client/database/sequelize.ts b/src/client/database/sequelize.ts deleted file mode 100644 index ded915a6..00000000 --- a/src/client/database/sequelize.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { env } from '../../config/env'; -import { createSequelize } from './createSequelize'; - -export const sequelize = createSequelize(env.dbPath); diff --git a/src/client/models/ServerConfig.ts b/src/client/models/ServerConfig.ts deleted file mode 100644 index 708cde7b..00000000 --- a/src/client/models/ServerConfig.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Table, Column, DataType, Model, PrimaryKey, Default, AllowNull } from 'sequelize-typescript'; - -@Table -export class ServerConfig extends Model { - @PrimaryKey - @Column - declare serverId: string; - - @Column(DataType.STRING) - declare guestRoleIds: string | null; - - @Column(DataType.STRING) - declare welcomeMessage: string | null; - - @Default(false) - @Column - declare debug: boolean; - - @Column(DataType.STRING) - declare auditChannelId: string | null; - - @Column(DataType.STRING) - declare welcomeMessageBackgroundUrl: string | null; - - @Default(false) - @Column - declare systemMessagesEnabled: boolean; - - @Column(DataType.STRING) - declare announcementsChannelId: string | null; - - @Column(DataType.STRING) - declare birthdayCalendarMessagePath: string | null; - - @Column(DataType.STRING) - declare honeyPotChannelId: string | null; - - @Default(0) - @AllowNull(false) - @Column(DataType.INTEGER) - declare accessFailureCount: number; - - @Column(DataType.DATE) - declare firstAccessFailureAt: Date | null; -} diff --git a/src/client/models/UserProfile.ts b/src/client/models/UserProfile.ts deleted file mode 100644 index 0398055b..00000000 --- a/src/client/models/UserProfile.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Table, Column, DataType, Model, Default } from 'sequelize-typescript'; - -@Table({ - indexes: [{ name: 'user_profiles_server_user_unique', unique: true, fields: ['serverId', 'userId'] }], -}) -export class UserProfile extends Model { - @Column - declare serverId: string; - - @Column - declare userId: string; - - @Column(DataType.INTEGER) - declare birthdayYear: number | null; - - @Column(DataType.INTEGER) - declare birthdayMonth: number | null; - - @Column(DataType.INTEGER) - declare birthdayDay: number | null; - - @Default(0) - @Column - declare activityScore: number; -} diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 2c3f0e55..0e0995bd 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,6 +1,6 @@ import { GuildMember, Message, PermissionFlagsBits } from 'discord.js'; import type { NovaClient } from '../client/NovaClient'; -import type { ServerConfig } from '../client/models/ServerConfig'; +import type { ServerConfig } from '../client/database/schema'; import { defineEvent } from '../framework/event'; import { EmbedColours } from '../resources/EmbedColours'; import { AuditEmbed } from '../services/AuditEmbed'; diff --git a/src/framework/command.ts b/src/framework/command.ts index b5bf9875..a9996cc9 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -19,7 +19,7 @@ import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, } from 'discord.js'; -import type { ServerConfig } from '../client/models/ServerConfig'; +import type { ServerConfig } from '../client/database/schema'; export type DeferMode = 'ephemeral' | 'public' | false; diff --git a/src/framework/settings.ts b/src/framework/settings.ts index 90246dbe..01b690ca 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -1,5 +1,6 @@ import { ApplicationCommandOptionAllowedChannelTypes, Guild } from 'discord.js'; import { defineSubcommandGroup, SubcommandGroupDefinition } from './command'; +import { ConfigService } from '../services/ConfigService'; export type ChannelField = 'auditChannelId' | 'announcementsChannelId' | 'honeyPotChannelId'; export type BooleanField = 'debug' | 'systemMessagesEnabled'; @@ -67,8 +68,9 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi ), run: async (ctx) => { const channel = ctx.interaction.options.getChannel('channel', true); - ctx.config[field] = channel.id; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + [field]: channel.id, + }); await ctx.reply(channelSetText(label, channel.id)); }, }, @@ -85,8 +87,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi name: 'clear', description: `Clear the ${label.toLowerCase()}`, run: async (ctx) => { - ctx.config[field] = null; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { [field]: null }); await ctx.reply(channelClearText(label)); }, }, @@ -109,8 +110,7 @@ export function booleanSetting(opts: BooleanSettingOptions): SubcommandGroupDefi ), run: async (ctx) => { const enabled = ctx.interaction.options.getBoolean('enabled', true); - ctx.config[field] = enabled; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { [field]: enabled }); await ctx.reply(booleanText(label, enabled)); }, }, diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index de64360d..29413539 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -1,5 +1,5 @@ import { Attachment, Client, EmbedBuilder } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; +import type { ServerConfig } from '../client/database/schema'; import { Logger } from '../utilities/Logger'; export const sendAudit = async ( diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 49dfef38..5b5070db 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,6 +1,6 @@ import { Client } from 'discord.js'; import { DateTime } from 'luxon'; -import { ServerConfig } from '../client/models/ServerConfig'; +import type { ServerConfig } from '../client/database/schema'; import { env } from '../config/env'; import { ConfigService } from '../services/ConfigService'; import { Logger } from '../utilities/Logger'; diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index c61eff21..d614cc09 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -1,54 +1,56 @@ -import type { Sequelize } from 'sequelize-typescript'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { UserProfile } from '../client/models/UserProfile'; +import { eq } from 'drizzle-orm'; +import { getDb, type AppDatabase } from '../client/database/db'; +import { serverConfigs, userProfiles, type ServerConfig } from '../client/database/schema'; + +export type ServerConfigPatch = Partial>; export class ConfigService { public static async getConfig(serverId: string): Promise { - const [config] = await ServerConfig.findOrCreate({ - where: { - serverId: serverId, - }, - }); - - return config; + const db = getDb(); + db.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); + return db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get()!; } public static async getConfigs(): Promise { - return ServerConfig.findAll(); + return getDb().select().from(serverConfigs).all(); } - public static async deleteConfig(serverId: string): Promise { - const recordsDeleted = await ServerConfig.destroy({ - where: { - serverId: serverId, - }, + public static async updateConfig( + serverId: string, + patch: ServerConfigPatch + ): Promise { + const db = getDb(); + return db.transaction((tx) => { + tx.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); + return tx + .update(serverConfigs) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(serverConfigs.serverId, serverId)) + .returning() + .get(); }); + } - return recordsDeleted > 0; + public static async deleteConfig(serverId: string): Promise { + const result = getDb().delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); + return result.changes > 0; } - public static async resetAccessFailures(config: ServerConfig): Promise { + public static async resetAccessFailures(config: ServerConfig): Promise { if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { - return; + return config; } - config.accessFailureCount = 0; - config.firstAccessFailureAt = null; - await config.save(); + return this.updateConfig(config.serverId, { accessFailureCount: 0, firstAccessFailureAt: null }); } public static async purgeGuild( serverId: string, - sequelize?: Sequelize + database: AppDatabase = getDb() ): Promise<{ config: boolean; profiles: number }> { - const db = sequelize ?? ServerConfig.sequelize; - if (!db) { - throw new Error('ServerConfig is not bound to a Sequelize instance'); - } - - return db.transaction(async (transaction) => { - const profiles = await UserProfile.destroy({ where: { serverId }, transaction }); - const configs = await ServerConfig.destroy({ where: { serverId }, transaction }); - return { config: configs > 0, profiles }; + return database.transaction((tx) => { + const profiles = tx.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); + const configs = tx.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); + return { config: configs.changes > 0, profiles: profiles.changes }; }); } } diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index 4a26df00..458ec3d0 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -74,9 +74,10 @@ export const run = async ( next.accessFailureCount !== config.accessFailureCount || next.firstAccessFailureAt?.getTime() !== config.firstAccessFailureAt?.getTime() ) { - config.accessFailureCount = next.accessFailureCount; - config.firstAccessFailureAt = next.firstAccessFailureAt; - await config.save(); + await ConfigService.updateConfig(config.serverId, { + accessFailureCount: next.accessFailureCount, + firstAccessFailureAt: next.firstAccessFailureAt, + }); } if (outcome === 'ok') { diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 0b0de5f4..4ce0f5ed 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,5 +1,5 @@ import { AttachmentBuilder, Client, Guild, GuildMember, PartialGuildMember, Role } from 'discord.js'; -import type { ServerConfig } from '../client/models/ServerConfig'; +import type { ServerConfig } from '../client/database/schema'; import { EmbedColours } from '../resources/EmbedColours'; import { Logger } from '../utilities/Logger'; import { AuditEmbed } from './AuditEmbed'; diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 7cc7b17c..8ea07b15 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,23 +1,24 @@ -import { Op } from 'sequelize'; -import { UserProfile } from '../client/models/UserProfile'; +import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; +import { getDb } from '../client/database/db'; +import { userProfiles, type UserProfile } from '../client/database/schema'; export class UserProfileService { public static async getServerBirthdays(serverId: string): Promise { - return UserProfile.findAll({ - where: { - birthdayDay: { - [Op.not]: null, - }, - birthdayMonth: { - [Op.not]: null, - }, - serverId: serverId, - }, - }); + return getDb() + .select() + .from(userProfiles) + .where( + and( + eq(userProfiles.serverId, serverId), + isNotNull(userProfiles.birthdayDay), + isNotNull(userProfiles.birthdayMonth) + ) + ) + .all(); } public static async getServerProfiles(serverId: string): Promise { - return UserProfile.findAll({ where: { serverId } }); + return getDb().select().from(userProfiles).where(eq(userProfiles.serverId, serverId)).all(); } public static async setBirthday( @@ -26,75 +27,86 @@ export class UserProfileService { month: number, day: number ): Promise { - const profile = await this.getUserProfile(serverId, userId); - profile.birthdayMonth = month; - profile.birthdayDay = day; - await profile.save(); + getDb() + .insert(userProfiles) + .values({ serverId, userId, birthdayMonth: month, birthdayDay: day }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { birthdayMonth: month, birthdayDay: day, updatedAt: new Date() }, + }) + .run(); } public static async clearBirthday(serverId: string, userId: string): Promise { - const profile = await this.findUserProfile(serverId, userId); - if (!profile) { - return false; - } - profile.birthdayYear = null; - profile.birthdayMonth = null; - profile.birthdayDay = null; - await profile.save(); - return true; + const result = getDb() + .update(userProfiles) + .set({ birthdayYear: null, birthdayMonth: null, birthdayDay: null, updatedAt: new Date() }) + .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) + .run(); + return result.changes > 0; } public static async deleteUsers(serverId: string, userIds: string[]): Promise { + const db = getDb(); let removed = 0; for (let i = 0; i < userIds.length; i += 500) { - removed += await UserProfile.destroy({ - where: { serverId, userId: { [Op.in]: userIds.slice(i, i + 500) } }, - }); + removed += db + .delete(userProfiles) + .where( + and(eq(userProfiles.serverId, serverId), inArray(userProfiles.userId, userIds.slice(i, i + 500))) + ) + .run().changes; } return removed; } public static async getUserProfile(serverId: string, userId: string): Promise { - const [profiles] = await UserProfile.findOrCreate({ - where: { - serverId: serverId, - userId: userId, - }, - }); - - return profiles; + const db = getDb(); + db.insert(userProfiles).values({ serverId, userId }).onConflictDoNothing().run(); + return db + .select() + .from(userProfiles) + .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) + .get()!; } public static async findUserProfile( serverId: string, userId: string ): Promise { - return UserProfile.findOne({ where: { serverId, userId } }); + return ( + getDb() + .select() + .from(userProfiles) + .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) + .get() ?? null + ); } public static async incrementActivityScore(serverId: string, userId: string): Promise { - await this.getUserProfile(serverId, userId); - await UserProfile.increment({ activityScore: 1 }, { where: { serverId, userId } }); + getDb() + .insert(userProfiles) + .values({ serverId, userId, activityScore: 1 }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { + activityScore: sql`coalesce(${userProfiles.activityScore}, 0) + 1`, + updatedAt: new Date(), + }, + }) + .run(); } public static async deleteUser(serverId: string, userId: string): Promise { - const recordsDeleted = await UserProfile.destroy({ - where: { - serverId: serverId, - userId: userId, - }, - }); - - return recordsDeleted > 0; + const result = getDb() + .delete(userProfiles) + .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) + .run(); + return result.changes > 0; } public static async deleteUsersByServer(serverId: string): Promise { - const recordsDeleted = await UserProfile.destroy({ - where: { - serverId: serverId, - }, - }); - - return recordsDeleted > 0; + const result = getDb().delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); + return result.changes > 0; } } diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index fbcd45cf..6c128420 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -5,6 +5,7 @@ import { deleteCalendarMessage, refreshCalendar, } from '../../../services/BirthdayService'; +import { ConfigService } from '../../../services/ConfigService'; import { resolveTextChannel } from '../../../services/MemberResolver'; export const BirthdaysGroup = defineSubcommandGroup({ @@ -48,8 +49,9 @@ export const BirthdaysGroup = defineSubcommandGroup({ allowedMentions: { parse: [] }, }); const path = `${message.channelId}/${message.id}`; - ctx.config.birthdayCalendarMessagePath = path; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + birthdayCalendarMessagePath: path, + }); const status = await refreshCalendar(client, ctx.config); const link = calendarMessageUrl(ctx.guild.id, path); @@ -89,8 +91,9 @@ export const BirthdaysGroup = defineSubcommandGroup({ return; } await deleteCalendarMessage(ctx.interaction.client, ctx.config.birthdayCalendarMessagePath); - ctx.config.birthdayCalendarMessagePath = null; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + birthdayCalendarMessagePath: null, + }); await ctx.reply('Birthday calendar removed.'); }, }, diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index 5a7cec62..e1c50570 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -3,6 +3,7 @@ import { CommandContext, defineSubcommandGroup } from '../../../framework/comman import { EmbedColours } from '../../../resources/EmbedColours'; import { AuditEmbed } from '../../../services/AuditEmbed'; import { sendAudit } from '../../../services/AuditService'; +import { ConfigService } from '../../../services/ConfigService'; import { parseRoleIds, roleProblem } from '../../../services/OnboardingService'; const ROLE_OPTIONS = ['role-one', 'role-two', 'role-three'] as const; @@ -79,8 +80,9 @@ export const NewRolesGroup = defineSubcommandGroup({ } const ids = [...roles.keys()]; - ctx.config.guestRoleIds = ids.join(','); - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + guestRoleIds: ids.join(','), + }); await auditChange(ctx, 'New member roles updated', ids); await ctx.reply({ content: `New-member roles set to ${ids.map((id) => `<@&${id}>`).join(', ')}.`, @@ -92,8 +94,7 @@ export const NewRolesGroup = defineSubcommandGroup({ name: 'clear', description: 'Clear the roles given to members when they complete onboarding', run: async (ctx) => { - ctx.config.guestRoleIds = null; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { guestRoleIds: null }); await auditChange(ctx, 'New member roles cleared', []); await ctx.reply('New-member roles cleared.'); }, diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts index 4023c4ab..5e269674 100644 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ b/src/slash-commands/Config/Subcommands/welcome.ts @@ -1,4 +1,5 @@ import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; +import { ConfigService, type ServerConfigPatch } from '../../../services/ConfigService'; import { complete } from '../../../services/OnboardingService'; import { isHttpUrl, WelcomeImage } from '../../../services/WelcomeImage'; @@ -23,8 +24,7 @@ const setMessage = async (ctx: CommandContext) => { await ctx.reply(problem); return; } - ctx.config.welcomeMessage = text; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { welcomeMessage: text }); await ctx.reply({ content: 'Welcome message saved. Use `{member}` in the text to mention the new member.', allowedMentions: NO_MENTIONS, @@ -46,8 +46,9 @@ const setImage = async (ctx: CommandContext) => { ); return; } - ctx.config.welcomeMessageBackgroundUrl = url; - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + welcomeMessageBackgroundUrl: url, + }); await ctx.reply('Welcome image saved. Use /config welcome preview to see how it looks.'); }; @@ -64,13 +65,14 @@ const get = async (ctx: CommandContext) => { const clear = async (ctx: CommandContext) => { const which = ctx.interaction.options.getString('which', true); + const patch: ServerConfigPatch = {}; if (which === 'message' || which === 'all') { - ctx.config.welcomeMessage = null; + patch.welcomeMessage = null; } if (which === 'image' || which === 'all') { - ctx.config.welcomeMessageBackgroundUrl = null; + patch.welcomeMessageBackgroundUrl = null; } - await ctx.config.save(); + ctx.config = await ConfigService.updateConfig(ctx.config.serverId, patch); await ctx.reply(`Cleared welcome ${which === 'all' ? 'message and image' : which}.`); }; diff --git a/tsconfig.json b/tsconfig.json index 5d675952..1d275fbf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,8 +9,6 @@ "outDir": "dist", "sourceMap": true, "esModuleInterop": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, "allowSyntheticDefaultImports": true, "skipLibCheck": true, "skipDefaultLibCheck": true, From 24d72636d056ac9f7e8f247d22b5bd432f6c9250 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:03:38 +0100 Subject: [PATCH 17/77] Update tests, service seam and README for Drizzle; prune stale overrides Co-Authored-By: Claude Sonnet 5.5 --- README.md | 42 +--- package.json | 5 +- tests/birthdays/BirthdayService.test.ts | 29 ++- tests/birthdays/commands.test.ts | 47 ++--- tests/database/migrations.test.ts | 240 ++++++++++++----------- tests/database/purgeGuild.test.ts | 71 ++++--- tests/database/schema.test.ts | 51 +++-- tests/database/services.test.ts | 70 +++++++ tests/datacheck/DataCheckService.test.ts | 55 +++--- tests/dispatcher.test.ts | 10 +- tests/fakes/onboarding.ts | 3 +- tests/helpers/db.ts | 58 +++++- tests/moderation/events.test.ts | 18 +- tests/moderation/memberListing.test.ts | 10 +- tests/moderation/post.test.ts | 13 +- tests/onboarding/commands.test.ts | 40 ++-- tests/onboarding/events.test.ts | 15 +- tests/services/AuditService.test.ts | 2 +- tests/settings.test.ts | 41 ++-- 19 files changed, 486 insertions(+), 334 deletions(-) create mode 100644 tests/database/services.test.ts diff --git a/README.md b/README.md index 8af65cf1..a3f7f59d 100644 --- a/README.md +++ b/README.md @@ -268,8 +268,7 @@ Migrations run automatically on startup. Current migrations: ``` src/ client/ # Discord.js client setup and models - database/ # SQLite schema, migrations, sequelize config - models/ # ServerConfig, UserProfile ORM models + database/ # Drizzle schema, better-sqlite3 setup, migrations NovaClient.ts # Main client class (intents, startup) config/ env.ts # Environment loading and validation @@ -326,29 +325,9 @@ channelSetting({ }) ``` -Then ensure the field is defined in the ServerConfig model and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. +Then ensure the field is defined in `src/client/database/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. -**Migrations**: If adding a new config field, create a migration file in `src/client/database/migrations/`: - -```typescript -// src/client/database/migrations/004-add-myfeature.ts -import { DataTypes } from 'sequelize'; -import type { MigrationParams } from './types'; - -export async function up({ context }: MigrationParams): Promise { - const columns = await context.describeTable('ServerConfigs'); - if (!('myFeatureChannelId' in columns)) { - await context.addColumn('ServerConfigs', 'myFeatureChannelId', { - type: DataTypes.STRING, - allowNull: true, - }); - } -} - -export async function down({ context }: MigrationParams): Promise { - await context.removeColumn('ServerConfigs', 'myFeatureChannelId'); -} -``` +**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/client/database/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `ConfigService.updateConfig(serverId, { field: value })`. ### Adding an Event @@ -370,18 +349,15 @@ Set `once: true` to only listen once (useful for `clientReady`). ### Adding a Migration -Migrations use Umzug and Sequelize's QueryInterface. Create a new file in `src/client/database/migrations/`: +Migrations are plain synchronous functions over the better-sqlite3 handle. Create a new file in `src/client/database/migrations/` and register it in `src/client/database/migrator.ts`: ```typescript -import type { MigrationParams } from './types'; - -export async function up({ context }: MigrationParams): Promise { - // Apply the change -} +// src/client/database/migrations/004-add-myfeature.ts +import type { Database } from 'better-sqlite3'; -export async function down({ context }: MigrationParams): Promise { - // Revert the change (for development; not used in production) +export function up(db: Database): void { + db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `myFeatureChannelId` VARCHAR(255)'); } ``` -Migrations run on startup in order. See `001-baseline.ts` and `002-userprofile-unique.ts` for examples. +Migrations run on startup in order, each in its own transaction, and applied names are recorded in the `SequelizeMeta` table (kept from earlier versions). See `001-baseline.ts` and `002-userprofile-unique.ts` for examples. diff --git a/package.json b/package.json index fa2dc0e7..61383e4f 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,8 @@ }, "overrides": { "brace-expansion@<=1.1.12": "1.1.17", - "dottie@<=2.0.6": "2.0.7", "lodash@<=4.17.23": "4.18.1", "minimatch@<=3.1.3": "3.1.5", - "undici@<=6.26.0": "6.27.0", - "uuid@<11.1.1": "11.1.1", - "validator@<=13.15.20": "13.15.35" + "undici@<=6.26.0": "6.27.0" } } diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index 49fe5808..f5272362 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -1,9 +1,8 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { DateTime } from 'luxon'; -import type { Sequelize } from 'sequelize-typescript'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; -import { UserProfile } from '../../src/client/models/UserProfile'; +import type { DatabaseHandle } from '../../src/client/database/db'; +import type { ServerConfig } from '../../src/client/database/schema'; import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService'; import { apiError, @@ -12,7 +11,7 @@ import { fakeMessage, fakeTextChannel, } from '../fakes/guild'; -import { createTestDb } from '../helpers/db'; +import { clearConfigs, clearProfiles, closeTestDb, createConfig, createProfiles, createTestDb } from '../helpers/db'; console.warn = (): void => undefined; console.error = (): void => undefined; @@ -21,23 +20,23 @@ console.log = (): void => undefined; const zone = 'Europe/London'; const at = (iso: string): DateTime => DateTime.fromISO(iso, { zone }); -let db: Sequelize; +let db: DatabaseHandle; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(async () => { - await UserProfile.destroy({ where: {} }); - await ServerConfig.destroy({ where: {} }); + clearProfiles(); + clearConfigs(); }); -const makeConfig = (fields: Record = {}): Promise => - ServerConfig.create({ serverId: 'g1', ...fields }); +const makeConfig = (fields: Record = {}): ServerConfig => + createConfig({ serverId: 'g1', ...fields }); test('refreshCalendar: not configured', async () => { const config = await makeConfig(); @@ -69,7 +68,7 @@ test('refreshCalendar: edit failure reports failed', async () => { test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', async () => { const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - await UserProfile.bulkCreate([ + await createProfiles([ { serverId: 'g1', userId: 'leap', birthdayMonth: 2, birthdayDay: 29 }, { serverId: 'g1', userId: 'today', birthdayMonth: 2, birthdayDay: 27 }, { serverId: 'g1', userId: 'nobday' }, @@ -99,7 +98,7 @@ test('refreshCalendar: empty state', async () => { test('notifyBirthdays: skips departed members and mentions only present ones', async () => { await makeConfig({ announcementsChannelId: 'ann' }); - await UserProfile.bulkCreate([ + await createProfiles([ { serverId: 'g1', userId: 'here', birthdayMonth: 6, birthdayDay: 1 }, { serverId: 'g1', userId: 'gone', birthdayMonth: 6, birthdayDay: 1 }, { serverId: 'g1', userId: 'other', birthdayMonth: 6, birthdayDay: 2 }, @@ -116,7 +115,7 @@ test('notifyBirthdays: skips departed members and mentions only present ones', a test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async () => { await makeConfig({ announcementsChannelId: 'ann' }); - await UserProfile.bulkCreate([ + await createProfiles([ { serverId: 'g1', userId: 'a', birthdayMonth: 2, birthdayDay: 29 }, { serverId: 'g1', userId: 'b', birthdayMonth: 2, birthdayDay: 28 }, ]); @@ -136,7 +135,7 @@ test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async test('notifyBirthdays: no announcements channel sends nothing', async () => { await makeConfig(); - await UserProfile.create({ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }); + await createProfiles([{ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }]); const channel = fakeTextChannel('ann'); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'], channels: [channel] }); await notifyBirthdays(fakeClient({ guilds: { g1: guild } }), { zone, now: at('2027-06-01T09:00') }); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 55503233..3554c9d9 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -1,15 +1,22 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { Sequelize } from 'sequelize-typescript'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; -import { UserProfile } from '../../src/client/models/UserProfile'; +import type { DatabaseHandle } from '../../src/client/database/db'; import { initEnv } from '../../src/config/env'; import { CommandContext, createReply } from '../../src/framework/command'; import mybirthday from '../../src/slash-commands/Info/mybirthday'; import profile from '../../src/slash-commands/Info/profile'; import { fakeClient, fakeGuildWithMembers } from '../fakes/guild'; import { fakeInteraction } from '../fakes/interaction'; -import { createTestDb } from '../helpers/db'; +import { + clearConfigs, + clearProfiles, + closeTestDb, + countProfiles, + createConfig, + createProfiles, + createTestDb, + findProfile, +} from '../helpers/db'; initEnv({ TOKEN: 't', CLIENT_ID: 'c', DB_PATH: ':memory:', BOT_TIMEZONE: 'Europe/London' }); @@ -17,26 +24,26 @@ console.warn = (): void => undefined; console.error = (): void => undefined; console.log = (): void => undefined; -let db: Sequelize; +let db: DatabaseHandle; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(async () => { - await UserProfile.destroy({ where: {} }); - await ServerConfig.destroy({ where: {} }); + clearProfiles(); + clearConfigs(); }); const exec = async ( command: typeof mybirthday, opts: { subcommand?: string; options?: Record; guild?: unknown } ) => { - const config = await ServerConfig.create({ serverId: 'guild-1' }); + const config = await createConfig({ serverId: 'guild-1' }); const fake = fakeInteraction({ subcommand: opts.subcommand, options: opts.options }); const raw = fake.interaction as any; raw.options.getInteger = raw.options.getNumber; @@ -59,7 +66,7 @@ const lastContent = (calls: { payload: any }[]): string => calls[calls.length - test('mybirthday set stores Feb 29 as 29', async () => { const calls = await exec(mybirthday, { subcommand: 'set', options: { day: 29, month: 2 } }); - const stored = await UserProfile.findOne({ where: { userId: 'user-1' } }); + const stored = findProfile('user-1'); assert.equal(stored?.birthdayDay, 29); assert.equal(stored?.birthdayMonth, 2); assert.match(lastContent(calls), /^Saved. Your next birthday is /); @@ -68,19 +75,15 @@ test('mybirthday set stores Feb 29 as 29', async () => { test('mybirthday set rejects invalid dates', async () => { const calls = await exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }); assert.match(lastContent(calls), /invalid/); - assert.equal(await UserProfile.count(), 0); + assert.equal(await countProfiles(), 0); }); test('mybirthday clear removes the birthday but keeps the profile', async () => { - await UserProfile.create({ - serverId: 'guild-1', - userId: 'user-1', - birthdayMonth: 5, - birthdayDay: 5, - activityScore: 7, - }); + createProfiles([ + { serverId: 'guild-1', userId: 'user-1', birthdayMonth: 5, birthdayDay: 5, activityScore: 7 }, + ]); await exec(mybirthday, { subcommand: 'clear' }); - const stored = await UserProfile.findOne({ where: { userId: 'user-1' } }); + const stored = findProfile('user-1'); assert.equal(stored?.birthdayDay, null); assert.equal(stored?.birthdayMonth, null); assert.equal(stored?.activityScore, 7); @@ -90,7 +93,7 @@ test('profile replies for a non-member without creating a profile', async () => const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: [] }); const calls = await exec(profile as any, { options: { member: { id: 'ghost' } }, guild }); assert.equal(lastContent(calls), "That user isn't a member of this server."); - assert.equal(await UserProfile.count(), 0); + assert.equal(await countProfiles(), 0); }); test('profile for a member without a profile does not create one', async () => { @@ -108,5 +111,5 @@ test('profile for a member without a profile does not create one', async () => { const field = (name: string): string => embed.fields.find((f: any) => f.name === name).value; assert.equal(field('Onboarding'), 'Not completed'); assert.equal(field('Activity Score'), '0'); - assert.equal(await UserProfile.count(), 0); + assert.equal(await countProfiles(), 0); }); diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index c9a4fb34..32e07771 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -1,13 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { QueryTypes } from 'sequelize'; -import type { Sequelize } from 'sequelize-typescript'; -import { createSequelize } from '../../src/client/database/createSequelize'; -import { createMigrator, migrate } from '../../src/client/database/migrator'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; -import { UserProfile } from '../../src/client/models/UserProfile'; +import Database from 'better-sqlite3'; +import { migrate, migrations, type Migration } from '../../src/client/database/migrator'; import { Logger } from '../../src/utilities/Logger'; +type Db = Database.Database; + const INDEX = 'user_profiles_server_user_unique'; const ALL = ['001-baseline', '002-userprofile-unique', '003-serverconfig-access-tracking']; @@ -16,17 +14,20 @@ const LEGACY_SERVER_CONFIGS = const LEGACY_USER_PROFILES = 'CREATE TABLE `UserProfiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serverId` VARCHAR(255), `userId` VARCHAR(255), `birthdayYear` INTEGER, `birthdayMonth` INTEGER, `birthdayDay` INTEGER, `activityScore` INTEGER DEFAULT 0, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)'; -const withDb = async (fn: (db: Sequelize) => Promise): Promise => { - const db = createSequelize(':memory:'); +const withDb = (fn: (db: Db) => void): void => { + const db = new Database(':memory:'); try { - await fn(db); + fn(db); } finally { - await db.close(); + db.close(); } }; -const columnsOf = async (db: Sequelize, table: string): Promise => - Object.keys(await db.getQueryInterface().describeTable(table)); +const columnsOf = (db: Db, table: string): string[] => + (db.pragma(`table_info(\`${table}\`)`) as { name: string }[]).map((c) => c.name); + +const indexesOf = (db: Db, table: string): { name: string; unique: number }[] => + db.pragma(`index_list(\`${table}\`)`) as { name: string; unique: number }[]; const profileRow = ( serverId: string, @@ -37,27 +38,25 @@ const profileRow = ( updatedAt: string ) => ({ serverId, userId, month, day, score, updatedAt }); -const insertProfiles = async ( - db: Sequelize, - rows: ReturnType[] -): Promise => { +const insertProfiles = (db: Db, rows: ReturnType[]): void => { + const insert = db.prepare( + 'INSERT INTO `UserProfiles` (serverId, userId, birthdayMonth, birthdayDay, activityScore, createdAt, updatedAt) VALUES (@serverId, @userId, @month, @day, @score, @updatedAt, @updatedAt)' + ); for (const r of rows) { - await db.query( - 'INSERT INTO `UserProfiles` (serverId, userId, birthdayMonth, birthdayDay, activityScore, createdAt, updatedAt) VALUES (:serverId, :userId, :month, :day, :score, :updatedAt, :updatedAt)', - { replacements: r } - ); + insert.run(r); } }; -const runUpTo = async (db: Sequelize, to: string): Promise => { - await createMigrator(db).up({ to }); -}; +const runUpTo = (db: Db, to: string): string[] => + migrate(db, migrations.slice(0, migrations.findIndex((m) => m.name === to) + 1)); + +const allRows = (db: Db, sql: string): unknown[] => db.prepare(sql).all(); -test('migrations on an empty database create the expected schema', async () => { - await withDb(async (db) => { - assert.deepEqual(await migrate(db), ALL); +test('migrations on an empty database create the expected schema', () => { + withDb((db) => { + assert.deepEqual(migrate(db), ALL); - const serverColumns = await columnsOf(db, 'ServerConfigs'); + const serverColumns = columnsOf(db, 'ServerConfigs'); for (const column of [ 'serverId', 'prefix', @@ -71,7 +70,7 @@ test('migrations on an empty database create the expected schema', async () => { ]) { assert.ok(serverColumns.includes(column), `ServerConfigs.${column}`); } - assert.deepEqual(await columnsOf(db, 'UserProfiles'), [ + assert.deepEqual(columnsOf(db, 'UserProfiles'), [ 'id', 'serverId', 'userId', @@ -83,13 +82,9 @@ test('migrations on an empty database create the expected schema', async () => { 'updatedAt', ]); - const indexes = await db.getQueryInterface().showIndex('UserProfiles'); - const unique = (indexes as { name: string; unique: boolean }[]).find((i) => i.name === INDEX); - assert.ok(unique?.unique); + assert.ok(indexesOf(db, 'UserProfiles').find((i) => i.name === INDEX)?.unique); - const meta = await db.query<{ name: string }>('SELECT name FROM `SequelizeMeta` ORDER BY name', { - type: QueryTypes.SELECT, - }); + const meta = allRows(db, 'SELECT name FROM `SequelizeMeta` ORDER BY name') as { name: string }[]; assert.deepEqual( meta.map((m) => m.name), ALL @@ -97,75 +92,98 @@ test('migrations on an empty database create the expected schema', async () => { }); }); -test('running migrations twice is a no-op', async () => { - await withDb(async (db) => { - await migrate(db); - await UserProfile.create({ serverId: 's1', userId: 'u1', activityScore: 4 }); - assert.deepEqual(await migrate(db), []); - assert.equal(await UserProfile.count(), 1); +test('running migrations twice is a no-op', () => { + withDb((db) => { + migrate(db); + insertProfiles(db, [profileRow('s1', 'u1', null, null, 4, '2024-01-01 00:00:00.000 +00:00')]); + assert.deepEqual(migrate(db), []); + assert.equal(allRows(db, 'SELECT * FROM `UserProfiles`').length, 1); }); }); -test('baseline is a no-op on a legacy sync() database and later migrations preserve data', async () => { - await withDb(async (db) => { - await db.query(LEGACY_SERVER_CONFIGS); - await db.query(LEGACY_USER_PROFILES); - await db.query( +test('baseline is a no-op on a legacy sync() database and later migrations preserve data', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + db.exec( "INSERT INTO `ServerConfigs` (serverId, prefix, adminRoleId, auditChannelId, createdAt, updatedAt) VALUES ('s1', '!', 'admin', 'chan', '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')" ); - await insertProfiles(db, [ + insertProfiles(db, [ profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00'), profileRow('s1', 'u2', null, null, 9, '2024-01-01 00:00:00.000 +00:00'), ]); - const before = await db.query('SELECT * FROM `UserProfiles` ORDER BY id', { - type: QueryTypes.SELECT, - }); + const before = allRows(db, 'SELECT * FROM `UserProfiles` ORDER BY id'); + + assert.deepEqual(runUpTo(db, '001-baseline'), ['001-baseline']); + assert.deepEqual(allRows(db, 'SELECT * FROM `UserProfiles` ORDER BY id'), before); + assert.ok(!columnsOf(db, 'ServerConfigs').includes('accessFailureCount')); + + assert.deepEqual(migrate(db), ALL.slice(1)); + + const config = db.prepare('SELECT * FROM `ServerConfigs` WHERE serverId = ?').get('s1') as Record< + string, + unknown + >; + assert.equal(config.auditChannelId, 'chan'); + assert.equal(config.accessFailureCount, 0); + assert.equal(config.firstAccessFailureAt, null); + assert.deepEqual(allRows(db, 'SELECT prefix, adminRoleId FROM `ServerConfigs`'), [ + { prefix: '!', adminRoleId: 'admin' }, + ]); - await runUpTo(db, '001-baseline'); - assert.deepEqual( - await db.query('SELECT * FROM `UserProfiles` ORDER BY id', { type: QueryTypes.SELECT }), - before + const profiles = allRows( + db, + 'SELECT userId, birthdayMonth, activityScore FROM `UserProfiles` ORDER BY id' ); - assert.ok(!(await columnsOf(db, 'ServerConfigs')).includes('accessFailureCount')); - - assert.deepEqual(await migrate(db), ALL.slice(1)); + assert.deepEqual(profiles, [ + { userId: 'u1', birthdayMonth: 5, activityScore: 3 }, + { userId: 'u2', birthdayMonth: null, activityScore: 9 }, + ]); + }); +}); - const config = await ServerConfig.findByPk('s1'); - assert.equal(config?.auditChannelId, 'chan'); - assert.equal(config?.accessFailureCount, 0); - assert.equal(config?.firstAccessFailureAt, null); - const legacy = await db.query<{ prefix: string; adminRoleId: string }>( - 'SELECT prefix, adminRoleId FROM `ServerConfigs`', - { type: QueryTypes.SELECT } +test('a database already migrated by the previous umzug setup runs nothing and keeps its data', () => { + withDb((db) => { + migrate(db); + insertProfiles(db, [profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00')]); + db.exec( + "INSERT INTO `ServerConfigs` (serverId, accessFailureCount, createdAt, updatedAt) VALUES ('s1', 2, '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')" ); - assert.deepEqual(legacy, [{ prefix: '!', adminRoleId: 'admin' }]); - const profiles = await UserProfile.findAll({ order: [['id', 'ASC']] }); - assert.deepEqual( - profiles.map((p) => [p.userId, p.birthdayMonth, p.activityScore]), - [ - ['u1', 5, 3], - ['u2', null, 9], - ] - ); + const ran: string[] = []; + const spies: Migration[] = migrations.map((m) => ({ + name: m.name, + up: () => { + ran.push(m.name); + }, + })); + + assert.deepEqual(migrate(db, spies), []); + assert.deepEqual(ran, []); + assert.deepEqual(allRows(db, 'SELECT userId, activityScore FROM `UserProfiles`'), [ + { userId: 'u1', activityScore: 3 }, + ]); + assert.deepEqual(allRows(db, 'SELECT serverId, accessFailureCount FROM `ServerConfigs`'), [ + { serverId: 's1', accessFailureCount: 2 }, + ]); }); }); -test('baseline adds missing nullable columns to an older schema', async () => { - await withDb(async (db) => { - await db.query( +test('baseline adds missing nullable columns to an older schema', () => { + withDb((db) => { + db.exec( 'CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)' ); - await migrate(db); - assert.ok((await columnsOf(db, 'ServerConfigs')).includes('honeyPotChannelId')); - assert.ok((await columnsOf(db, 'UserProfiles')).includes('activityScore')); + migrate(db); + assert.ok(columnsOf(db, 'ServerConfigs').includes('honeyPotChannelId')); + assert.ok(columnsOf(db, 'UserProfiles').includes('activityScore')); }); }); -test('002 dedupes UserProfiles and enforces uniqueness afterwards', async () => { - await withDb(async (db) => { - await runUpTo(db, '001-baseline'); - await insertProfiles(db, [ +test('002 dedupes UserProfiles and enforces uniqueness afterwards', () => { + withDb((db) => { + runUpTo(db, '001-baseline'); + insertProfiles(db, [ // a: only one has a birthday; it is older and lower score profileRow('s1', 'a', null, null, 10, '2024-03-01 00:00:00.000 +00:00'), profileRow('s1', 'a', 2, 14, 1, '2024-01-01 00:00:00.000 +00:00'), @@ -187,7 +205,7 @@ test('002 dedupes UserProfiles and enforces uniqueness afterwards', async () => warnings.push(message); }; try { - await runUpTo(db, '002-userprofile-unique'); + runUpTo(db, '002-userprofile-unique'); } finally { Logger.warn = originalWarn; } @@ -195,45 +213,37 @@ test('002 dedupes UserProfiles and enforces uniqueness afterwards', async () => assert.equal(warnings.length, 1); assert.match(warnings[0], /Conflicting birthdays/); - const rows = await UserProfile.findAll({ order: [['serverId', 'ASC'], ['userId', 'ASC']] }); - assert.deepEqual( - rows.map((r) => [r.serverId, r.userId, r.birthdayMonth, r.birthdayDay, r.activityScore]), - [ - ['s1', 'a', 2, 14, 10], - ['s1', 'b', 7, 7, 8], - ['s1', 'c', null, null, 7], - ['s1', 'd', 4, 4, 0], - ['s2', 'a', 3, 3, 1], - ] - ); + const rows = db + .prepare( + 'SELECT serverId, userId, birthdayMonth, birthdayDay, activityScore FROM `UserProfiles` ORDER BY serverId, userId' + ) + .raw() + .all(); + assert.deepEqual(rows, [ + ['s1', 'a', 2, 14, 10], + ['s1', 'b', 7, 7, 8], + ['s1', 'c', null, null, 7], + ['s1', 'd', 4, 4, 0], + ['s2', 'a', 3, 3, 1], + ]); - await assert.rejects(UserProfile.create({ serverId: 's1', userId: 'a' })); + assert.throws(() => + insertProfiles(db, [profileRow('s1', 'a', null, null, 0, '2024-05-01 00:00:00.000 +00:00')]) + ); }); }); -test('002 rolls back entirely when it fails', async () => { - await withDb(async (db) => { - await runUpTo(db, '001-baseline'); - await insertProfiles(db, [ +test('002 rolls back entirely when it fails', () => { + withDb((db) => { + runUpTo(db, '001-baseline'); + insertProfiles(db, [ profileRow('s1', 'a', null, null, 1, '2024-01-01 00:00:00.000 +00:00'), profileRow('s1', 'a', null, null, 5, '2024-02-01 00:00:00.000 +00:00'), ]); - await db.query('CREATE INDEX `user_profiles_server_user_unique` ON `UserProfiles` (serverId)'); - - await assert.rejects(runUpTo(db, '002-userprofile-unique')); - assert.equal(await UserProfile.count(), 2); - }); -}); - -test('down migrations remove the added index and columns', async () => { - await withDb(async (db) => { - await migrate(db); - await createMigrator(db).down({ to: '001-baseline' }); + db.exec('CREATE INDEX `user_profiles_server_user_unique` ON `UserProfiles` (serverId)'); - const columns = await columnsOf(db, 'ServerConfigs'); - assert.ok(!columns.includes('accessFailureCount')); - assert.ok(columns.includes('serverId')); - const indexes = (await db.getQueryInterface().showIndex('UserProfiles')) as { name: string }[]; - assert.ok(!indexes.some((i) => i.name === INDEX)); + assert.throws(() => runUpTo(db, '002-userprofile-unique')); + assert.equal(allRows(db, 'SELECT * FROM `UserProfiles`').length, 2); + assert.deepEqual(allRows(db, 'SELECT name FROM `SequelizeMeta`'), [{ name: '001-baseline' }]); }); }); diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index 856c74e0..bbc10021 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -1,56 +1,67 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { Sequelize } from 'sequelize-typescript'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; -import { UserProfile } from '../../src/client/models/UserProfile'; +import { count, eq } from 'drizzle-orm'; +import type { DatabaseHandle } from '../../src/client/database/db'; +import { serverConfigs, userProfiles } from '../../src/client/database/schema'; import { ConfigService } from '../../src/services/ConfigService'; -import { createTestDb } from '../helpers/db'; +import { closeTestDb, createTestDb } from '../helpers/db'; -let db: Sequelize; +let handle: DatabaseHandle; -before(async () => { - db = await createTestDb(); +before(() => { + handle = createTestDb(); }); -after(async () => { - await db.close(); +after(() => { + closeTestDb(handle); }); -beforeEach(async () => { - await UserProfile.destroy({ where: {} }); - await ServerConfig.destroy({ where: {} }); - await ServerConfig.bulkCreate([{ serverId: 's1' }, { serverId: 's2' }]); - await UserProfile.bulkCreate([ - { serverId: 's1', userId: 'a' }, - { serverId: 's1', userId: 'b' }, - { serverId: 's2', userId: 'a' }, - ]); +beforeEach(() => { + const { db } = handle; + db.delete(userProfiles).run(); + db.delete(serverConfigs).run(); + db.insert(serverConfigs).values([{ serverId: 's1' }, { serverId: 's2' }]).run(); + db.insert(userProfiles) + .values([ + { serverId: 's1', userId: 'a' }, + { serverId: 's1', userId: 'b' }, + { serverId: 's2', userId: 'a' }, + ]) + .run(); }); +const profileCount = (serverId?: string): number => { + const query = handle.db.select({ n: count() }).from(userProfiles); + return (serverId ? query.where(eq(userProfiles.serverId, serverId)) : query).get()!.n; +}; + +const configCount = (): number => handle.db.select({ n: count() }).from(serverConfigs).get()!.n; + test('purgeGuild deletes only the target server and reports counts', async () => { assert.deepEqual(await ConfigService.purgeGuild('s1'), { config: true, profiles: 2 }); - assert.equal(await ServerConfig.findByPk('s1'), null); - assert.ok(await ServerConfig.findByPk('s2')); - assert.equal(await UserProfile.count({ where: { serverId: 's1' } }), 0); - assert.equal(await UserProfile.count({ where: { serverId: 's2' } }), 1); + assert.equal(handle.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), undefined); + assert.ok(handle.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's2')).get()); + assert.equal(profileCount('s1'), 0); + assert.equal(profileCount('s2'), 1); }); test('purgeGuild on an unknown server deletes nothing', async () => { - assert.deepEqual(await ConfigService.purgeGuild('nope', db), { config: false, profiles: 0 }); - assert.equal(await ServerConfig.count(), 2); - assert.equal(await UserProfile.count(), 3); + assert.deepEqual(await ConfigService.purgeGuild('nope', handle.db), { config: false, profiles: 0 }); + assert.equal(configCount(), 2); + assert.equal(profileCount(), 3); }); test('purgeGuild rolls back the profile delete when the config delete fails', async () => { - const original = ServerConfig.destroy; - ServerConfig.destroy = (() => Promise.reject(new Error('boom'))) as typeof ServerConfig.destroy; + handle.sqlite.exec( + "CREATE TRIGGER block_config_delete BEFORE DELETE ON `ServerConfigs` BEGIN SELECT RAISE(ABORT, 'boom'); END" + ); try { await assert.rejects(ConfigService.purgeGuild('s1'), /boom/); } finally { - ServerConfig.destroy = original; + handle.sqlite.exec('DROP TRIGGER block_config_delete'); } - assert.equal(await UserProfile.count({ where: { serverId: 's1' } }), 2); - assert.ok(await ServerConfig.findByPk('s1')); + assert.equal(profileCount('s1'), 2); + assert.ok(handle.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get()); }); diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts index 692bd8ee..8496cf0c 100644 --- a/tests/database/schema.test.ts +++ b/tests/database/schema.test.ts @@ -1,25 +1,52 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; -import { UserProfile } from '../../src/client/models/UserProfile'; -import { createTestDb } from '../helpers/db'; +import { getTableColumns, getTableName } from 'drizzle-orm'; +import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; +import { serverConfigs, userProfiles } from '../../src/client/database/schema'; +import { closeTestDb, createTestDb } from '../helpers/db'; -test('migrated :memory: database works with both models', async () => { - const db = await createTestDb(); +const columnNames = (table: SQLiteTable): string[] => + Object.values(getTableColumns(table)).map((column) => column.name); + +test('Drizzle schema columns exist in the migrated database', () => { + const handle = createTestDb(); + try { + for (const table of [serverConfigs, userProfiles]) { + const actual = ( + handle.sqlite.pragma(`table_info(\`${getTableName(table)}\`)`) as { name: string }[] + ).map((column) => column.name); + for (const name of columnNames(table)) { + assert.ok(actual.includes(name), `${getTableName(table)}.${name} missing from database`); + } + } + } finally { + closeTestDb(handle); + } +}); + +test('migrated database applies column defaults and round-trips dates', () => { + const handle = createTestDb(); try { - const [config] = await ServerConfig.findOrCreate({ where: { serverId: 's1' } }); - await config.reload(); + const { db } = handle; + db.insert(serverConfigs).values({ serverId: 's1' }).run(); + const config = db.select().from(serverConfigs).get()!; assert.equal(config.systemMessagesEnabled, false); assert.equal(config.auditChannelId, null); assert.equal(config.accessFailureCount, 0); assert.equal(config.firstAccessFailureAt, null); + assert.ok(config.createdAt instanceof Date); - const profile = await UserProfile.create({ serverId: 's1', userId: 'u1' }); - assert.equal(profile.activityScore, 0); + const when = new Date('2024-01-01T12:00:00.000Z'); + db.update(serverConfigs).set({ firstAccessFailureAt: when }).run(); + const raw = handle.sqlite.prepare('SELECT firstAccessFailureAt AS v FROM `ServerConfigs`').get() as { + v: string; + }; + assert.equal(raw.v, '2024-01-01 12:00:00.000 +00:00'); + assert.equal(db.select().from(serverConfigs).get()!.firstAccessFailureAt?.getTime(), when.getTime()); - assert.equal(await ServerConfig.count(), 1); - assert.equal(await UserProfile.count(), 1); + db.insert(userProfiles).values({ serverId: 's1', userId: 'u1' }).run(); + assert.equal(db.select().from(userProfiles).get()!.activityScore, 0); } finally { - await db.close(); + closeTestDb(handle); } }); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts new file mode 100644 index 00000000..01413b2b --- /dev/null +++ b/tests/database/services.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, test } from 'node:test'; +import type { DatabaseHandle } from '../../src/client/database/db'; +import { ConfigService } from '../../src/services/ConfigService'; +import { UserProfileService } from '../../src/services/UserProfileService'; +import { + allProfiles, + clearConfigs, + clearProfiles, + closeTestDb, + createTestDb, +} from '../helpers/db'; + +let db: DatabaseHandle; + +before(() => { + db = createTestDb(); +}); + +after(() => { + closeTestDb(db); +}); + +beforeEach(() => { + clearProfiles(); + clearConfigs(); +}); + +test('getConfig creates the row once and returns the same one afterwards', async () => { + const first = await ConfigService.getConfig('s1'); + const second = await ConfigService.getConfig('s1'); + assert.equal((await ConfigService.getConfigs()).length, 1); + assert.equal(first.createdAt.getTime(), second.createdAt.getTime()); + assert.equal(first.debug, false); +}); + +test('updateConfig patches fields, bumps updatedAt and creates a missing row', async () => { + const created = await ConfigService.updateConfig('s1', { auditChannelId: 'c1' }); + assert.equal(created.auditChannelId, 'c1'); + + const updated = await ConfigService.updateConfig('s1', { debug: true, auditChannelId: null }); + assert.equal(updated.debug, true); + assert.equal(updated.auditChannelId, null); + assert.ok(updated.updatedAt.getTime() >= created.updatedAt.getTime()); + assert.equal((await ConfigService.getConfig('s1')).debug, true); +}); + +test('incrementActivityScore upserts and increments atomically', async () => { + await UserProfileService.incrementActivityScore('s1', 'u1'); + await UserProfileService.incrementActivityScore('s1', 'u1'); + await UserProfileService.incrementActivityScore('s1', 'u2'); + const scores = allProfiles().map((p) => [p.userId, p.activityScore]); + assert.deepEqual(scores, [ + ['u1', 2], + ['u2', 1], + ]); +}); + +test('setBirthday upserts without losing the activity score; clearBirthday reports presence', async () => { + await UserProfileService.incrementActivityScore('s1', 'u1'); + await UserProfileService.setBirthday('s1', 'u1', 3, 4); + await UserProfileService.setBirthday('s1', 'u1', 5, 6); + const [profile] = allProfiles(); + assert.deepEqual([profile.birthdayMonth, profile.birthdayDay, profile.activityScore], [5, 6, 1]); + assert.equal(allProfiles().length, 1); + + assert.equal(await UserProfileService.clearBirthday('s1', 'u1'), true); + assert.equal(await UserProfileService.clearBirthday('s1', 'nobody'), false); + assert.equal((await UserProfileService.getServerBirthdays('s1')).length, 0); +}); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index 4e158dcf..1708f239 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -1,11 +1,10 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { Sequelize } from 'sequelize-typescript'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; -import { UserProfile } from '../../src/client/models/UserProfile'; +import type { DatabaseHandle } from '../../src/client/database/db'; +import type { ServerConfig } from '../../src/client/database/schema'; import { run } from '../../src/services/DataCheckService'; import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild'; -import { createTestDb } from '../helpers/db'; +import { clearConfigs, clearProfiles, closeTestDb, allProfiles, countProfiles, createConfig, createConfigs, createProfiles, createTestDb, findConfig } from '../helpers/db'; console.warn = (): void => undefined; console.error = (): void => undefined; @@ -15,36 +14,36 @@ const policy = { minFailures: 3, graceDays: 7 }; const DAY = 24 * 60 * 60 * 1000; const t0 = new Date('2027-01-01T00:00:00Z'); -let db: Sequelize; +let db: DatabaseHandle; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(async () => { - await UserProfile.destroy({ where: {} }); - await ServerConfig.destroy({ where: {} }); + clearProfiles(); + clearConfigs(); }); -const reload = (id: string): Promise => ServerConfig.findByPk(id); +const reload = (id: string): ServerConfig | null => findConfig(id); test('transient errors never purge or change counters', async () => { - await ServerConfig.create({ serverId: 'g1', accessFailureCount: 5, firstAccessFailureAt: t0 }); - await UserProfile.create({ serverId: 'g1', userId: 'a' }); + await createConfig({ serverId: 'g1', accessFailureCount: 5, firstAccessFailureAt: t0 }); + await createProfiles([{ serverId: 'g1', userId: 'a' }]); const client = fakeClient({ guilds: { g1: apiError(500) } }); await run(client, new Date(t0.getTime() + 30 * DAY), policy); const config = await reload('g1'); assert.equal(config?.accessFailureCount, 5); - assert.equal(await UserProfile.count(), 1); + assert.equal(await countProfiles(), 1); }); test('gone guild is purged only after min failures and grace period', async () => { - await ServerConfig.create({ serverId: 'g1' }); - await UserProfile.create({ serverId: 'g1', userId: 'a' }); + await createConfig({ serverId: 'g1' }); + await createProfiles([{ serverId: 'g1', userId: 'a' }]); const client = fakeClient({ guilds: { g1: apiError(50001) } }); await run(client, t0, policy); @@ -52,21 +51,21 @@ test('gone guild is purged only after min failures and grace period', async () = await run(client, new Date(t0.getTime() + 6 * DAY), policy); const config = await reload('g1'); assert.equal(config?.accessFailureCount, 3); - assert.equal(await UserProfile.count(), 1); + assert.equal(await countProfiles(), 1); await run(client, new Date(t0.getTime() + 7 * DAY), policy); assert.equal(await reload('g1'), null); - assert.equal(await UserProfile.count(), 0); + assert.equal(await countProfiles(), 0); }); test('guild missing from the client entirely counts as gone', async () => { - await ServerConfig.create({ serverId: 'g1' }); + await createConfig({ serverId: 'g1' }); await run(fakeClient(), t0, policy); assert.equal((await reload('g1'))?.accessFailureCount, 1); }); test('a successful fetch resets counters', async () => { - await ServerConfig.create({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); + await createConfig({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); await run(fakeClient({ guilds: { g1: guild } }), new Date(t0.getTime() + DAY), policy); const config = await reload('g1'); @@ -75,8 +74,8 @@ test('a successful fetch resets counters', async () => { }); test('profiles of departed members are deleted, including non-birthday ones', async () => { - await ServerConfig.create({ serverId: 'g1' }); - await UserProfile.bulkCreate([ + await createConfig({ serverId: 'g1' }); + await createProfiles([ { serverId: 'g1', userId: 'here' }, { serverId: 'g1', userId: 'gone-plain' }, { serverId: 'g1', userId: 'gone-bday', birthdayMonth: 1, birthdayDay: 1 }, @@ -84,21 +83,21 @@ test('profiles of departed members are deleted, including non-birthday ones', as ]); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here'] }); await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - const remaining = (await UserProfile.findAll()).map((p) => `${p.serverId}/${p.userId}`).sort(); + const remaining = (await allProfiles()).map((p) => `${p.serverId}/${p.userId}`).sort(); assert.deepEqual(remaining, ['g1/here', 'other/gone-plain']); }); test('an empty member list does not wipe profiles', async () => { - await ServerConfig.create({ serverId: 'g1' }); - await UserProfile.create({ serverId: 'g1', userId: 'a' }); + await createConfig({ serverId: 'g1' }); + await createProfiles([{ serverId: 'g1', userId: 'a' }]); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: [] }); await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - assert.equal(await UserProfile.count(), 1); + assert.equal(await countProfiles(), 1); }); test('one guild throwing does not stop the others', async () => { - await ServerConfig.bulkCreate([{ serverId: 'g1' }, { serverId: 'g2' }]); - await UserProfile.bulkCreate([ + await createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + await createProfiles([ { serverId: 'g1', userId: 'x' }, { serverId: 'g2', userId: 'x' }, ]); @@ -109,6 +108,6 @@ test('one guild throwing does not stop the others', async () => { }); const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0, policy); - const remaining = (await UserProfile.findAll()).map((p) => p.serverId); + const remaining = (await allProfiles()).map((p) => p.serverId); assert.deepEqual(remaining, ['g1']); }); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index aebf4210..df4f3076 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -1,23 +1,23 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; -import type { Sequelize } from 'sequelize-typescript'; -import { createTestDb } from './helpers/db'; +import type { DatabaseHandle } from '../src/client/database/db'; +import { closeTestDb, createTestDb } from './helpers/db'; import type { NovaClient } from '../src/client/NovaClient'; import { Command, defineCommand } from '../src/framework/command'; import interactionCreate from '../src/events/interactionCreate'; import { fakeInteraction } from './fakes/interaction'; -let db: Sequelize; +let db: DatabaseHandle; const original = { log: console.log, warn: console.warn, error: console.error }; let errors: string[]; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(() => { diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index 56b248b3..3a2dbadf 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,5 +1,5 @@ import type { Client, GuildMember } from 'discord.js'; -import type { ServerConfig } from '../../src/client/models/ServerConfig'; +import type { ServerConfig } from '../../src/client/database/schema'; import type { CommandContext } from '../../src/framework/command'; import { fakeInteraction } from './interaction'; @@ -90,7 +90,6 @@ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { welcomeMessageBackgroundUrl: null, systemMessagesEnabled: false, debug: false, - save: async () => undefined, ...opts.config, } as unknown as ServerConfig; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 9ae34141..93ac3a2f 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,9 +1,55 @@ -import type { Sequelize } from 'sequelize-typescript'; -import { createSequelize } from '../../src/client/database/createSequelize'; +import { eq } from 'drizzle-orm'; +import { createDatabase, getDb, setDatabase, type DatabaseHandle } from '../../src/client/database/db'; import { migrate } from '../../src/client/database/migrator'; +import { + serverConfigs, + userProfiles, + type ServerConfig, + type UserProfile, +} from '../../src/client/database/schema'; -export async function createTestDb(): Promise { - const db = createSequelize(':memory:'); - await migrate(db); - return db; +/** An in-memory, fully migrated database that the services use until it is closed. */ +export function createTestDb(): DatabaseHandle { + const handle = createDatabase(':memory:'); + migrate(handle.sqlite); + setDatabase(handle); + return handle; } + +export function closeTestDb(handle: DatabaseHandle): void { + setDatabase(undefined); + handle.sqlite.close(); +} + +type ConfigInsert = typeof serverConfigs.$inferInsert; +type ProfileInsert = typeof userProfiles.$inferInsert; + +export const clearConfigs = (): void => { + getDb().delete(serverConfigs).run(); +}; + +export const clearProfiles = (): void => { + getDb().delete(userProfiles).run(); +}; + +export const createConfig = (values: ConfigInsert): ServerConfig => + getDb().insert(serverConfigs).values(values).returning().get(); + +export const createConfigs = (values: ConfigInsert[]): void => { + getDb().insert(serverConfigs).values(values).run(); +}; + +export const findConfig = (serverId: string): ServerConfig | null => + getDb().select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? null; + +export const createProfiles = (values: ProfileInsert[]): void => { + getDb().insert(userProfiles).values(values).run(); +}; + +export const allProfiles = (): UserProfile[] => getDb().select().from(userProfiles).all(); + +export const countProfiles = (): number => allProfiles().length; + +export const findProfile = (userId: string, serverId?: string): UserProfile | null => + allProfiles().find((p) => p.userId === userId && (serverId === undefined || p.serverId === serverId)) ?? + null; diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index 6cdaba54..42600f25 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -1,8 +1,7 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { Sequelize } from 'sequelize-typescript'; -import { UserProfile } from '../../src/client/models/UserProfile'; +import type { DatabaseHandle } from '../../src/client/database/db'; import guildMemberRemove from '../../src/events/guildMemberRemove'; import messageCreate from '../../src/events/messageCreate'; import messageDelete from '../../src/events/messageDelete'; @@ -10,20 +9,19 @@ import messageUpdate from '../../src/events/messageUpdate'; import { ConfigService } from '../../src/services/ConfigService'; import { HoneyPotEnforcementService } from '../../src/services/HoneyPotEnforcementService'; import { UserProfileService } from '../../src/services/UserProfileService'; -import { createTestDb } from '../helpers/db'; +import { clearProfiles, closeTestDb, createTestDb, findProfile } from '../helpers/db'; import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages'; -let db: Sequelize; +let db: DatabaseHandle; const original = { log: console.log, warn: console.warn, error: console.error }; before(async () => { - db = await createTestDb(); - const config = await ConfigService.getConfig('guild-1'); - await config.update({ auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); + db = createTestDb(); + await ConfigService.updateConfig('guild-1', { auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(() => { @@ -35,11 +33,11 @@ beforeEach(() => { afterEach(async () => { Object.assign(console, original); HoneyPotEnforcementService.cancel('guild-1', 'u1'); - await UserProfile.destroy({ where: {} }); + clearProfiles(); }); const score = async (): Promise => - (await UserProfile.findOne({ where: { serverId: 'guild-1', userId: 'u1' } }))?.activityScore ?? null; + findProfile('u1', 'guild-1')?.activityScore ?? null; test('honeypot message bans the member and does not count activity', async () => { const sink = fakeAuditClient(); diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 9567e039..4db68b90 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -1,20 +1,20 @@ import assert from 'node:assert/strict'; import { after, before, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { Sequelize } from 'sequelize-typescript'; +import type { DatabaseHandle } from '../../src/client/database/db'; import noroles from '../../src/slash-commands/Admin/noroles'; import rolesince from '../../src/slash-commands/Admin/rolesince'; import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing'; -import { createTestDb } from '../helpers/db'; +import { closeTestDb, createTestDb } from '../helpers/db'; import { fakeInteraction } from '../fakes/interaction'; import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages'; -let db: Sequelize; +let db: DatabaseHandle; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); const DAY = 24 * 60 * 60 * 1000; diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index eb2fb160..cca8fa56 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -1,24 +1,23 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { Collection, PermissionFlagsBits } from 'discord.js'; -import type { Sequelize } from 'sequelize-typescript'; +import type { DatabaseHandle } from '../../src/client/database/db'; import post from '../../src/slash-commands/Admin/post'; import { ConfigService } from '../../src/services/ConfigService'; -import { createTestDb } from '../helpers/db'; +import { closeTestDb, createTestDb } from '../helpers/db'; import { fakeInteraction } from '../fakes/interaction'; import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages'; -let db: Sequelize; +let db: DatabaseHandle; const original = { log: console.log, warn: console.warn, error: console.error }; before(async () => { - db = await createTestDb(); - const config = await ConfigService.getConfig('guild-1'); - await config.update({ auditChannelId: 'audit-1' }); + db = createTestDb(); + await ConfigService.updateConfig('guild-1', { auditChannelId: 'audit-1' }); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(() => { diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 623e48ed..590b8e3a 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { test } from 'node:test'; +import { after, before, beforeEach, test } from 'node:test'; +import type { DatabaseHandle } from '../../src/client/database/db'; import { Handler } from '../../src/framework/command'; import NewRolesGroup from '../../src/slash-commands/Config/Subcommands/newroles'; import WelcomeGroup, { @@ -8,16 +9,27 @@ import WelcomeGroup, { validateWelcomeMessage, } from '../../src/slash-commands/Config/Subcommands/welcome'; import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding'; +import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db'; + +let db: DatabaseHandle; + +before(() => { + db = createTestDb(); +}); + +after(() => { + closeTestDb(db); +}); + +beforeEach(() => { + clearConfigs(); +}); const handler = (group: typeof NewRolesGroup, name: string): Handler => group.subcommands.find((s) => s.name === name)!.run; test('newroles set rejects @everyone, managed and too-high roles and saves nothing', async () => { const env = fakeOnboarding({ roles: [role('ok', 1), role('m', 1, true), role('hi', 10)] }); - let saved = false; - env.config.save = (async () => { - saved = true; - }) as never; const { ctx, replies } = fakeCommandContext( { 'role-one': env.guild.roles.cache.get('ok'), @@ -27,25 +39,26 @@ test('newroles set rejects @everyone, managed and too-high roles and saves nothi env ); await handler(NewRolesGroup, 'set')(ctx); - assert.equal(saved, false); + assert.equal(ctx.config, env.config); assert.match(replies[0].content, /managed/); assert.match(replies[0].content, /highest role/); const everyone = fakeCommandContext({ 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, env); await handler(NewRolesGroup, 'set')(everyone.ctx); assert.match(everyone.replies[0].content, /everyone/); - assert.equal(saved, false); + assert.equal(everyone.ctx.config, env.config); }); test('newroles set saves valid roles, dedupes, and audits', async () => { const env = fakeOnboarding({ roles: [role('a', 1), role('b', 2)] }); + createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1' }); const a = env.guild.roles.cache.get('a'); const { ctx, replies } = fakeCommandContext( { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, env ); await handler(NewRolesGroup, 'set')(ctx); - assert.equal(env.config.guestRoleIds, 'a,b'); + assert.equal(ctx.config.guestRoleIds, 'a,b'); assert.equal(env.auditSends.length, 1); assert.match(replies[0].content, /set to/); }); @@ -66,9 +79,10 @@ test('newroles get is read-only: no audit, marks missing roles', async () => { test('newroles clear nulls roles and audits', async () => { const env = fakeOnboarding({ config: { guestRoleIds: 'a' } }); + createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1', guestRoleIds: 'a' }); const { ctx } = fakeCommandContext({}, env); await handler(NewRolesGroup, 'clear')(ctx); - assert.equal(env.config.guestRoleIds, null); + assert.equal(ctx.config.guestRoleIds, null); assert.equal(env.auditSends.length, 1); }); @@ -103,16 +117,16 @@ test('welcome set-message, get and clear', async () => { const env = fakeOnboarding(); const set = fakeCommandContext({ text: 'Hi {member}' }, env); await handler(WelcomeGroup as never, 'set-message')(set.ctx); - assert.equal(env.config.welcomeMessage, 'Hi {member}'); + assert.equal(set.ctx.config.welcomeMessage, 'Hi {member}'); - const get = fakeCommandContext({}, env); + const get = fakeCommandContext({}, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'get')(get.ctx); assert.match(get.replies[0].content, /Hi \{member\}/); assert.match(get.replies[0].content, /Welcome image: Not set/); - const clear = fakeCommandContext({ which: 'all' }, env); + const clear = fakeCommandContext({ which: 'all' }, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'clear')(clear.ctx); - assert.equal(env.config.welcomeMessage, null); + assert.equal(clear.ctx.config.welcomeMessage, null); }); test('welcome preview explains why nothing would be sent', async () => { diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 1524fe0b..5f736193 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -1,25 +1,24 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { Sequelize } from 'sequelize-typescript'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; +import type { DatabaseHandle } from '../../src/client/database/db'; import guildMemberAdd from '../../src/events/guildMemberAdd'; import guildMemberUpdate from '../../src/events/guildMemberUpdate'; -import { createTestDb } from '../helpers/db'; +import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db'; import { fakeOnboarding, role } from '../fakes/onboarding'; -let db: Sequelize; +let db: DatabaseHandle; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); beforeEach(async () => { - await ServerConfig.destroy({ where: {} }); - await ServerConfig.create({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); + clearConfigs(); + await createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); }); const update = async (oldState: Record, newPending: boolean | null) => { diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index f21779ef..5897b6c5 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { Client, EmbedBuilder } from 'discord.js'; -import { ServerConfig } from '../../src/client/models/ServerConfig'; +import type { ServerConfig } from '../../src/client/database/schema'; import { sendAudit } from '../../src/services/AuditService'; console.warn = (): void => undefined; diff --git a/tests/settings.test.ts b/tests/settings.test.ts index c879222e..eb7e95ca 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,9 +1,10 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; +import { after, afterEach, before, beforeEach, test } from 'node:test'; import { ChannelType } from 'discord.js'; -import type { Sequelize } from 'sequelize-typescript'; -import { createTestDb } from './helpers/db'; -import { ServerConfig } from '../src/client/models/ServerConfig'; +import type { DatabaseHandle } from '../src/client/database/db'; +import { closeTestDb, clearConfigs, createTestDb } from './helpers/db'; +import type { ServerConfig } from '../src/client/database/schema'; +import { ConfigService } from '../src/services/ConfigService'; import { CommandContext, defineCommand, Handler } from '../src/framework/command'; import { booleanSetting, @@ -15,28 +16,33 @@ import { } from '../src/framework/settings'; import { fakeGuild, fakeInteraction } from './fakes/interaction'; -let db: Sequelize; +let db: DatabaseHandle; let config: ServerConfig; let events: string[]; before(async () => { - db = await createTestDb(); + db = createTestDb(); }); after(async () => { - await db.close(); + closeTestDb(db); }); +const originalUpdate = ConfigService.updateConfig; + beforeEach(async () => { - await ServerConfig.destroy({ where: {} }); - [config] = await ServerConfig.findOrCreate({ where: { serverId: 'guild-1' } }); + clearConfigs(); + config = await ConfigService.getConfig('guild-1'); events = []; - const originalSave = config.save.bind(config); - config.save = (async (...args: Parameters) => { - const result = await originalSave(...args); + ConfigService.updateConfig = (async (...args: Parameters) => { + const result = await originalUpdate.apply(ConfigService, args); events.push('save'); return result; - }) as typeof config.save; + }) as typeof originalUpdate; +}); + +afterEach(() => { + ConfigService.updateConfig = originalUpdate; }); const channelGroup = channelSetting({ @@ -89,7 +95,7 @@ test('channelSetting set saves before replying and persists', async () => { assert.deepEqual(events, ['save', 'reply']); assert.deepEqual(replies, ['Audit channel set to <#123>.']); - await config.reload(); + config = await ConfigService.getConfig('guild-1'); assert.equal(config.auditChannelId, '123'); }); @@ -109,8 +115,7 @@ test('channelSetting get reports set, missing and not-set channels', async () => }); test('channelSetting clear nulls the field, saves, then replies', async () => { - config.auditChannelId = '123'; - await config.save(); + config = await ConfigService.updateConfig('guild-1', { auditChannelId: '123' }); events.length = 0; const { ctx, replies } = makeCtx({}); @@ -118,7 +123,7 @@ test('channelSetting clear nulls the field, saves, then replies', async () => { assert.deepEqual(events, ['save', 'reply']); assert.deepEqual(replies, ['Audit channel cleared.']); - await config.reload(); + config = await ConfigService.getConfig('guild-1'); assert.equal(config.auditChannelId, null); }); @@ -139,7 +144,7 @@ test('booleanSetting set saves before replying; get reads the stored value', asy await run(boolGroup, 'set')(set.ctx); assert.deepEqual(events, ['save', 'reply']); assert.deepEqual(set.replies, ['Bot system messages: enabled']); - await config.reload(); + config = await ConfigService.getConfig('guild-1'); assert.equal(config.systemMessagesEnabled, true); const get = makeCtx({}); From ab9c9187a63cab64596c55675f4eaa16875adb22 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:22 +0100 Subject: [PATCH 18/77] Switch to ESM and native TypeScript execution; static registry; croner - ES modules, run src/*.ts directly with Node type stripping (no build step) - explicit .ts import extensions, import type, erasable syntax only - replace runtime file discovery with static command/event registries and validateRegistry (with tests) - replace node-schedule with croner; Scheduler.stop() is now async and waits for an in-flight run - load .env via --env-file-if-exists; drop dotenv, tslib, copyfiles, rimraf, tsc-watch; single tsconfig covering src and tests Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- eslint.config.mjs | 2 +- package-lock.json | 802 +----------------- package.json | 24 +- src/client/NovaClient.ts | 25 +- src/client/database/db.ts | 4 +- .../migrations/002-userprofile-unique.ts | 2 +- src/client/database/migrator.ts | 6 +- src/events/guildCreate.ts | 6 +- src/events/guildDelete.ts | 6 +- src/events/guildMemberAdd.ts | 6 +- src/events/guildMemberRemove.ts | 14 +- src/events/guildMemberUpdate.ts | 8 +- src/events/index.ts | 25 + src/events/interactionCreate.ts | 8 +- src/events/messageCreate.ts | 20 +- src/events/messageDelete.ts | 12 +- src/events/messageUpdate.ts | 10 +- src/events/ready.ts | 16 +- src/framework/CommandRegistrar.ts | 6 +- src/framework/command.ts | 16 +- src/framework/event.ts | 8 +- src/framework/loader.ts | 87 -- src/framework/registry.ts | 13 + src/framework/settings.ts | 6 +- src/framework/shutdown.ts | 2 +- src/index.ts | 7 +- src/resources/EmbedColours.ts | 2 +- src/services/AuditEmbed.ts | 2 +- src/services/AuditService.ts | 4 +- src/services/BirthdayService.ts | 14 +- src/services/ConfigService.ts | 4 +- src/services/DataCheckService.ts | 14 +- src/services/MemberListing.ts | 2 +- src/services/MemberResolver.ts | 2 +- src/services/OnboardingService.ts | 14 +- src/services/Scheduler.ts | 63 +- src/services/UserProfileService.ts | 4 +- src/services/WelcomeImage.ts | 4 +- src/slash-commands/Admin/noroles.ts | 4 +- src/slash-commands/Admin/post.ts | 12 +- src/slash-commands/Admin/rolesince.ts | 4 +- src/slash-commands/Admin/simulate.ts | 6 +- .../Config/Subcommands/birthdays.ts | 8 +- .../Config/Subcommands/debug.ts | 2 +- .../Config/Subcommands/newroles.ts | 12 +- .../Config/Subcommands/welcome.ts | 8 +- src/slash-commands/Config/config.ts | 12 +- src/slash-commands/Info/about.ts | 8 +- src/slash-commands/Info/mybirthday.ts | 12 +- src/slash-commands/Info/ping.ts | 2 +- src/slash-commands/Info/profile.ts | 8 +- src/slash-commands/index.ts | 13 + tests/about.test.ts | 2 +- tests/birthdays/BirthdayService.test.ts | 10 +- tests/birthdays/commands.test.ts | 16 +- tests/command.test.ts | 6 +- tests/database/migrations.test.ts | 4 +- tests/database/purgeGuild.test.ts | 8 +- tests/database/schema.test.ts | 4 +- tests/database/services.test.ts | 8 +- tests/datacheck/DataCheckService.test.ts | 10 +- tests/dispatcher.test.ts | 12 +- tests/env.test.ts | 2 +- tests/fakes/messages.ts | 8 +- tests/fakes/onboarding.ts | 6 +- tests/helpers/db.ts | 6 +- tests/loader.test.ts | 96 --- tests/logger.test.ts | 2 +- tests/moderation/events.test.ts | 20 +- tests/moderation/memberListing.test.ts | 14 +- tests/moderation/post.test.ts | 12 +- tests/onboarding/OnboardingService.test.ts | 4 +- tests/onboarding/commands.test.ts | 12 +- tests/onboarding/events.test.ts | 10 +- tests/registry.test.ts | 35 + tests/services/AuditEmbed.test.ts | 4 +- tests/services/AuditService.test.ts | 4 +- tests/services/BirthdayDates.test.ts | 2 +- tests/services/MemberResolver.test.ts | 2 +- tests/services/RetentionPolicy.test.ts | 4 +- tests/services/Scheduler.test.ts | 45 +- tests/settings.test.ts | 14 +- tests/tsconfig.json | 7 - tsconfig.json | 20 +- tsconfig.test.json | 7 - 86 files changed, 486 insertions(+), 1315 deletions(-) create mode 100644 src/events/index.ts delete mode 100644 src/framework/loader.ts create mode 100644 src/framework/registry.ts create mode 100644 src/slash-commands/index.ts delete mode 100644 tests/loader.test.ts create mode 100644 tests/registry.test.ts delete mode 100644 tests/tsconfig.json delete mode 100644 tsconfig.test.json diff --git a/README.md b/README.md index a3f7f59d..efe6dcff 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ A Discord bot for the Irkallu server, built with discord.js and TypeScript. Prov 1. Clone the repository 2. Install dependencies: `npm ci` 3. Copy `.env.example` to `.env` and fill in `TOKEN` and `CLIENT_ID` from the [Discord Developer Portal](https://discord.com/developers/applications) -4. Build: `npm run build` -5. Start: `npm start` +4. Start: `npm start` (Node runs the TypeScript source directly; there is no build step) For watch mode during development: ```bash @@ -20,6 +19,7 @@ npm run dev Test and lint: ```bash npm test # Run the test suite +npm run typecheck # Type-check src and tests npm run lint # Check code style and errors ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 7b35c874..db421a2c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import tseslint from 'typescript-eslint'; export default defineConfig([ - globalIgnores(['dist/**', 'node_modules/**', '.claude/**', 'package.json', '.github/**']), + globalIgnores(['node_modules/**', '.claude/**', 'package.json', '.github/**']), js.configs.recommended, tseslint.configs.recommended, { diff --git a/package-lock.json b/package-lock.json index 7c0a1b8d..7fd26773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,23 +10,17 @@ "dependencies": { "@napi-rs/canvas": "^1.0.2", "better-sqlite3": "^13.0.3", + "croner": "^10.0.1", "discord.js": "^14.26.5", - "dotenv": "^17.4.2", "drizzle-orm": "^0.45.3", - "luxon": "^3.7.2", - "node-schedule": "^2.1.1", - "tslib": "^2.8.1" + "luxon": "^3.7.2" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", - "@types/node-schedule": "^2.1.8", - "copyfiles": "^2.4.1", "eslint": "^10.6.0", - "rimraf": "^6.1.3", - "tsc-watch": "^7.2.1", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0" }, @@ -751,16 +745,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/node-schedule": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz", - "integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1089,39 +1073,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/better-sqlite3": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", @@ -1134,115 +1085,23 @@ "node": ">=22" } }, - "node_modules/brace-expansion": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", - "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/copyfiles": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", - "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^7.0.5", - "minimatch": "^3.0.3", - "mkdirp": "^1.0.4", - "noms": "0.0.0", - "through2": "^2.0.1", - "untildify": "^4.0.0", - "yargs": "^16.1.0" - }, - "bin": { - "copyfiles": "copyfiles", - "copyup": "copyfiles" - } - }, - "node_modules/copyfiles/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0" } }, "node_modules/cross-spawn": { @@ -1321,18 +1180,6 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/drizzle-orm": { "version": "0.45.3", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", @@ -1462,30 +1309,6 @@ } } }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1693,22 +1516,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-stream": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", - "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1798,48 +1605,6 @@ "dev": true, "license": "ISC" }, - "node_modules/from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1853,45 +1618,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1912,25 +1638,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1941,16 +1648,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1964,13 +1661,6 @@ "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2051,22 +1741,6 @@ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "license": "MIT" }, - "node_modules/long-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", - "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -2082,48 +1756,6 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, - "node_modules/map-stream": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", - "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", - "dev": true - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2147,48 +1779,6 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/node-cleanup": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", - "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-schedule": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", - "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", - "license": "MIT", - "dependencies": { - "cron-parser": "^4.2.0", - "long-timeout": "0.1.1", - "sorted-array-functions": "^1.3.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/noms": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", - "integrity": "sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==", - "dev": true, - "license": "ISC", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "~1.0.31" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2239,13 +1829,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2256,16 +1839,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2276,36 +1849,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pause-stream": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", - "dev": true, - "license": [ - "MIT", - "Apache2" - ], - "dependencies": { - "through": "~2.3" - } - }, "node_modules/picomatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", @@ -2329,29 +1872,6 @@ "node": ">= 0.8.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/ps-tree": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", - "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-stream": "=3.3.4" - }, - "bin": { - "ps-tree": "bin/ps-tree.js" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2362,49 +1882,6 @@ "node": ">=6" } }, - "node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2441,138 +1918,6 @@ "node": ">=8" } }, - "node_modules/sorted-array-functions": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", - "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", - "license": "MIT" - }, - "node_modules/split": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", - "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/stream-combiner": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", - "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1" - } - }, - "node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/through2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/through2/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2609,28 +1954,6 @@ "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", "license": "MIT" }, - "node_modules/tsc-watch": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-7.2.1.tgz", - "integrity": "sha512-FcntBnz5bXGQ/q265acfDzU7xi6R/sgwMokI+Iu293n14Wu8ovAtcwqNfU9vv/DM1THlnmmxatSSyGOvWsSblg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "node-cleanup": "^2.1.2", - "ps-tree": "^1.2.0", - "string-argv": "^0.3.2" - }, - "bin": { - "tsc-watch": "dist/lib/tsc-watch.js" - }, - "engines": { - "node": ">=12.12.0" - }, - "peerDependencies": { - "typescript": "*" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2703,16 +2026,6 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2723,13 +2036,6 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2756,31 +2062,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -2802,55 +2083,6 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", - "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 61383e4f..e9be7adc 100644 --- a/package.json +++ b/package.json @@ -10,35 +10,26 @@ "node": ">=24" }, "scripts": { - "prebuild": "rimraf dist", - "build": "tsc", - "postbuild": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist/src/", "lint": "eslint .", - "test": "tsc -p tsconfig.test.json && node --test \"dist/tests/**/*.test.js\"", - "dev": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"npm run copy-and-start\"", - "copy-and-start": "npm run postbuild && npm start", - "start": "node dist/src/index.js" + "test": "node --test \"tests/**/*.test.ts\"", + "dev": "node --watch --env-file-if-exists=.env src/index.ts", + "start": "node --env-file-if-exists=.env src/index.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "@napi-rs/canvas": "^1.0.2", "better-sqlite3": "^13.0.3", + "croner": "^10.0.1", "discord.js": "^14.26.5", - "dotenv": "^17.4.2", "drizzle-orm": "^0.45.3", - "luxon": "^3.7.2", - "node-schedule": "^2.1.1", - "tslib": "^2.8.1" + "luxon": "^3.7.2" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", - "@types/node-schedule": "^2.1.8", - "copyfiles": "^2.4.1", "eslint": "^10.6.0", - "rimraf": "^6.1.3", - "tsc-watch": "^7.2.1", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0" }, @@ -47,5 +38,6 @@ "lodash@<=4.17.23": "4.18.1", "minimatch@<=3.1.3": "3.1.5", "undici@<=6.26.0": "6.27.0" - } + }, + "type": "module" } diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts index 933698ea..b436ff43 100644 --- a/src/client/NovaClient.ts +++ b/src/client/NovaClient.ts @@ -1,13 +1,14 @@ -import path from 'node:path'; -import { env } from '../config/env'; +import { env } from '../config/env.ts'; import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; -import { Command } from '../framework/command'; -import { bindEvent } from '../framework/event'; -import { loadCommands, loadEvents } from '../framework/loader'; -import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown'; -import { Logger } from '../utilities/Logger'; -import { getDatabase } from './database/db'; -import { migrate } from './database/migrator'; +import type { Command } from '../framework/command.ts'; +import { bindEvent } from '../framework/event.ts'; +import { validateRegistry } from '../framework/registry.ts'; +import { commands } from '../slash-commands/index.ts'; +import { events } from '../events/index.ts'; +import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown.ts'; +import { Logger } from '../utilities/Logger.ts'; +import { getDatabase } from './database/db.ts'; +import { migrate } from './database/migrator.ts'; class NovaClient extends Client { public slashCommands: Collection = new Collection(); @@ -33,10 +34,8 @@ class NovaClient extends Client { sqlite.close(); }); - const [commands, events] = await Promise.all([ - loadCommands(path.join(__dirname, '..', 'slash-commands')), - loadEvents(path.join(__dirname, '..', 'events')), - ]); + validateRegistry('command', commands); + validateRegistry('event', events); for (const command of commands) { this.slashCommands.set(command.name, command); diff --git a/src/client/database/db.ts b/src/client/database/db.ts index 5b12b35d..1ea4f7b6 100644 --- a/src/client/database/db.ts +++ b/src/client/database/db.ts @@ -1,7 +1,7 @@ import Database from 'better-sqlite3'; import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import { env } from '../../config/env'; -import * as schema from './schema'; +import { env } from '../../config/env.ts'; +import * as schema from './schema.ts'; export type AppDatabase = BetterSQLite3Database; diff --git a/src/client/database/migrations/002-userprofile-unique.ts b/src/client/database/migrations/002-userprofile-unique.ts index 70e77d6d..6d80efd5 100644 --- a/src/client/database/migrations/002-userprofile-unique.ts +++ b/src/client/database/migrations/002-userprofile-unique.ts @@ -1,5 +1,5 @@ import type { Database } from 'better-sqlite3'; -import { Logger } from '../../../utilities/Logger'; +import { Logger } from '../../../utilities/Logger.ts'; const INDEX_NAME = 'user_profiles_server_user_unique'; diff --git a/src/client/database/migrator.ts b/src/client/database/migrator.ts index c183ca4e..5e152398 100644 --- a/src/client/database/migrator.ts +++ b/src/client/database/migrator.ts @@ -1,7 +1,7 @@ import type { Database } from 'better-sqlite3'; -import * as baseline from './migrations/001-baseline'; -import * as userProfileUnique from './migrations/002-userprofile-unique'; -import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking'; +import * as baseline from './migrations/001-baseline.ts'; +import * as userProfileUnique from './migrations/002-userprofile-unique.ts'; +import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking.ts'; export interface Migration { name: string; diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index a590d111..69f72f03 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,6 +1,6 @@ -import { defineEvent } from '../framework/event'; -import { ConfigService } from '../services/ConfigService'; -import { Logger } from '../utilities/Logger'; +import { defineEvent } from '../framework/event.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { Logger } from '../utilities/Logger.ts'; export default defineEvent({ name: 'guildCreate', diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 5a388e25..f213d20a 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,6 +1,6 @@ -import { defineEvent } from '../framework/event'; -import { ConfigService } from '../services/ConfigService'; -import { Logger } from '../utilities/Logger'; +import { defineEvent } from '../framework/event.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { Logger } from '../utilities/Logger.ts'; export default defineEvent({ name: 'guildDelete', diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index af329af4..2c4d5a7d 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,6 +1,6 @@ -import { defineEvent } from '../framework/event'; -import { auditJoin, complete } from '../services/OnboardingService'; -import { ConfigService } from '../services/ConfigService'; +import { defineEvent } from '../framework/event.ts'; +import { auditJoin, complete } from '../services/OnboardingService.ts'; +import { ConfigService } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildMemberAdd', diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 4a08d011..306f8c79 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,10 +1,10 @@ -import { defineEvent } from '../framework/event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { AuditEmbed } from '../services/AuditEmbed'; -import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../services/ConfigService'; -import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService'; -import { UserProfileService } from '../services/UserProfileService'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService.ts'; +import { UserProfileService } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberRemove', diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index eee0912d..b44305a0 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,7 +1,7 @@ -import { defineEvent } from '../framework/event'; -import { complete, completedScreening } from '../services/OnboardingService'; -import { ConfigService } from '../services/ConfigService'; -import { Logger } from '../utilities/Logger'; +import { defineEvent } from '../framework/event.ts'; +import { complete, completedScreening } from '../services/OnboardingService.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { Logger } from '../utilities/Logger.ts'; export default defineEvent({ name: 'guildMemberUpdate', diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 00000000..09fe7ae5 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,25 @@ +import guildCreate from './guildCreate.ts'; +import guildDelete from './guildDelete.ts'; +import guildMemberAdd from './guildMemberAdd.ts'; +import guildMemberRemove from './guildMemberRemove.ts'; +import guildMemberUpdate from './guildMemberUpdate.ts'; +import interactionCreate from './interactionCreate.ts'; +import messageCreate from './messageCreate.ts'; +import messageDelete from './messageDelete.ts'; +import messageUpdate from './messageUpdate.ts'; +import ready from './ready.ts'; +import type { AnyEventDefinition } from '../framework/event.ts'; + +/** Every gateway event the bot listens to. Add new events here. */ +export const events: AnyEventDefinition[] = [ + guildCreate, + guildDelete, + guildMemberAdd, + guildMemberRemove, + guildMemberUpdate, + interactionCreate, + messageCreate, + messageDelete, + messageUpdate, + ready, +]; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 6bc76827..791f339e 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,8 +1,8 @@ import { MessageFlags } from 'discord.js'; -import { CommandContext, createReply } from '../framework/command'; -import { defineEvent } from '../framework/event'; -import { ConfigService } from '../services/ConfigService'; -import { Logger } from '../utilities/Logger'; +import { type CommandContext, createReply } from '../framework/command.ts'; +import { defineEvent } from '../framework/event.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { Logger } from '../utilities/Logger.ts'; export default defineEvent({ name: 'interactionCreate', diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 0e0995bd..92eadf86 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,14 +1,14 @@ import { GuildMember, Message, PermissionFlagsBits } from 'discord.js'; -import type { NovaClient } from '../client/NovaClient'; -import type { ServerConfig } from '../client/database/schema'; -import { defineEvent } from '../framework/event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { AuditEmbed } from '../services/AuditEmbed'; -import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../services/ConfigService'; -import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../services/UserProfileService'; +import type { NovaClient } from '../client/NovaClient.ts'; +import type { ServerConfig } from '../client/database/schema.ts'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService.ts'; +import { Logger } from '../utilities/Logger.ts'; +import { UserProfileService } from '../services/UserProfileService.ts'; const DELETE_MESSAGE_SECONDS = 7 * 24 * 60 * 60; diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 015c00a8..724eb3dc 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,9 +1,9 @@ -import { defineEvent } from '../framework/event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { AuditEmbed } from '../services/AuditEmbed'; -import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../services/ConfigService'; -import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService.ts'; export default defineEvent({ name: 'messageDelete', diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 7bde7d48..67a075b3 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -1,8 +1,8 @@ -import { defineEvent } from '../framework/event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { AuditEmbed } from '../services/AuditEmbed'; -import { sendAudit } from '../services/AuditService'; -import { ConfigService } from '../services/ConfigService'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { ConfigService } from '../services/ConfigService.ts'; export default defineEvent({ name: 'messageUpdate', diff --git a/src/events/ready.ts b/src/events/ready.ts index 0bd86f42..e8352305 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,11 +1,11 @@ -import { env } from '../config/env'; -import { defineEvent } from '../framework/event'; -import { registerShutdownHook } from '../framework/shutdown'; -import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService'; -import { run as runDataCheck } from '../services/DataCheckService'; -import { Scheduler } from '../services/Scheduler'; -import { CommandRegistrar } from '../framework/CommandRegistrar'; -import { Logger } from '../utilities/Logger'; +import { env } from '../config/env.ts'; +import { defineEvent } from '../framework/event.ts'; +import { registerShutdownHook } from '../framework/shutdown.ts'; +import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService.ts'; +import { run as runDataCheck } from '../services/DataCheckService.ts'; +import { Scheduler } from '../services/Scheduler.ts'; +import { CommandRegistrar } from '../framework/CommandRegistrar.ts'; +import { Logger } from '../utilities/Logger.ts'; let schedulerInstalled = false; diff --git a/src/framework/CommandRegistrar.ts b/src/framework/CommandRegistrar.ts index 246458ab..4fcff360 100644 --- a/src/framework/CommandRegistrar.ts +++ b/src/framework/CommandRegistrar.ts @@ -1,7 +1,7 @@ -import { env } from '../config/env'; +import { env } from '../config/env.ts'; import { REST, Routes } from 'discord.js'; -import { Command } from './command'; -import { Logger } from '../utilities/Logger'; +import type { Command } from './command.ts'; +import { Logger } from '../utilities/Logger.ts'; export class CommandRegistrar { public static async register(commands: Command[]): Promise { diff --git a/src/framework/command.ts b/src/framework/command.ts index a9996cc9..e14dde44 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -1,25 +1,25 @@ import { - APIEmbed, + type APIEmbed, Attachment, AttachmentBuilder, - AttachmentPayload, + type AttachmentPayload, ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, InteractionContextType, - InteractionEditReplyOptions, - InteractionReplyOptions, + type InteractionEditReplyOptions, + type InteractionReplyOptions, MessageFlags, - MessageMentionOptions, + type MessageMentionOptions, PermissionFlagsBits, - RESTPostAPIChatInputApplicationCommandsJSONBody, + type RESTPostAPIChatInputApplicationCommandsJSONBody, SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, + type SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, } from 'discord.js'; -import type { ServerConfig } from '../client/database/schema'; +import type { ServerConfig } from '../client/database/schema.ts'; export type DeferMode = 'ephemeral' | 'public' | false; diff --git a/src/framework/event.ts b/src/framework/event.ts index d84c3b0b..b1536217 100644 --- a/src/framework/event.ts +++ b/src/framework/event.ts @@ -1,6 +1,6 @@ -import { Client, ClientEvents } from 'discord.js'; -import type { NovaClient } from '../client/NovaClient'; -import { Logger } from '../utilities/Logger'; +import { Client, type ClientEvents } from 'discord.js'; +import type { NovaClient } from '../client/NovaClient.ts'; +import { Logger } from '../utilities/Logger.ts'; export interface EventDefinition { name: K; @@ -8,6 +8,8 @@ export interface EventDefinition { run(client: NovaClient, ...args: ClientEvents[K]): Promise; } +export type AnyEventDefinition = { [K in keyof ClientEvents]: EventDefinition }[keyof ClientEvents]; + export function defineEvent( def: EventDefinition ): EventDefinition { diff --git a/src/framework/loader.ts b/src/framework/loader.ts deleted file mode 100644 index 1f44bc1b..00000000 --- a/src/framework/loader.ts +++ /dev/null @@ -1,87 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import type { Command } from './command'; -import type { EventDefinition } from './event'; - -const isModuleFile = (name: string): boolean => - (name.endsWith('.js') || name.endsWith('.ts')) && !name.endsWith('.d.ts'); - -async function listModules(dir: string): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isFile() && isModuleFile(entry.name)) - .map((entry) => path.join(dir, entry.name)) - .sort(); -} - -async function importDefault(file: string): Promise { - let mod: any; - try { - mod = await import(pathToFileURL(file).href); - } catch (error) { - throw new Error(`Failed to load ${file}: ${error instanceof Error ? error.message : String(error)}`, { - cause: error, - }); - } - // CommonJS output surfaces as { default: module.exports }, so unwrap the __esModule layer - let value = mod?.default ?? mod; - if (value?.__esModule && 'default' in value) { - value = value.default; - } - return value; -} - -const isCommand = (value: any): value is Command => - typeof value?.name === 'string' && - typeof value.toJSON === 'function' && - typeof value.resolve === 'function'; - -const isEvent = (value: any): value is EventDefinition => - typeof value?.name === 'string' && - typeof value.run === 'function' && - (value.once === undefined || typeof value.once === 'boolean'); - -/** Loads `//*.js`, skipping `Subcommands` directories and nested dirs. */ -export async function loadCommands(root: string): Promise { - const categories = (await fs.readdir(root, { withFileTypes: true })).filter( - (entry) => entry.isDirectory() && entry.name !== 'Subcommands' - ); - - const commands: Command[] = []; - const seen = new Map(); - for (const category of categories.sort((a, b) => a.name.localeCompare(b.name))) { - for (const file of await listModules(path.join(root, category.name))) { - const value = await importDefault(file); - if (!isCommand(value)) { - throw new Error(`${file} must export default a Command created with defineCommand`); - } - const existing = seen.get(value.name); - if (existing) { - throw new Error(`Duplicate command "${value.name}" in ${file} (already defined in ${existing})`); - } - seen.set(value.name, file); - commands.push(value); - } - } - return commands; -} - -/** Loads every top-level module in `dir` as an event definition. */ -export async function loadEvents(dir: string): Promise[]> { - const events: EventDefinition[] = []; - const seen = new Map(); - for (const file of await listModules(dir)) { - const value = await importDefault(file); - if (!isEvent(value)) { - throw new Error(`${file} must export default an event created with defineEvent`); - } - const existing = seen.get(value.name); - if (existing) { - throw new Error(`Duplicate event "${value.name}" in ${file} (already defined in ${existing})`); - } - seen.set(value.name, file); - events.push(value); - } - return events; -} diff --git a/src/framework/registry.ts b/src/framework/registry.ts new file mode 100644 index 00000000..89c15116 --- /dev/null +++ b/src/framework/registry.ts @@ -0,0 +1,13 @@ +/** Throws if two registry entries share a name. `kind` is only used in the error message. */ +export function validateRegistry(kind: string, entries: readonly { name: string }[]): void { + const seen = new Set(); + for (const entry of entries) { + if (typeof entry?.name !== 'string' || entry.name.length === 0) { + throw new Error(`Every ${kind} must have a non-empty name`); + } + if (seen.has(entry.name)) { + throw new Error(`Duplicate ${kind} "${entry.name}"`); + } + seen.add(entry.name); + } +} diff --git a/src/framework/settings.ts b/src/framework/settings.ts index 01b690ca..b9af7292 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -1,6 +1,6 @@ -import { ApplicationCommandOptionAllowedChannelTypes, Guild } from 'discord.js'; -import { defineSubcommandGroup, SubcommandGroupDefinition } from './command'; -import { ConfigService } from '../services/ConfigService'; +import { type ApplicationCommandOptionAllowedChannelTypes, Guild } from 'discord.js'; +import { defineSubcommandGroup, type SubcommandGroupDefinition } from './command.ts'; +import { ConfigService } from '../services/ConfigService.ts'; export type ChannelField = 'auditChannelId' | 'announcementsChannelId' | 'honeyPotChannelId'; export type BooleanField = 'debug' | 'systemMessagesEnabled'; diff --git a/src/framework/shutdown.ts b/src/framework/shutdown.ts index 7fb85b6a..eb0b3b9f 100644 --- a/src/framework/shutdown.ts +++ b/src/framework/shutdown.ts @@ -1,4 +1,4 @@ -import { Logger } from '../utilities/Logger'; +import { Logger } from '../utilities/Logger.ts'; type ShutdownHook = () => Promise | void; diff --git a/src/index.ts b/src/index.ts index 8fcd6d93..0b651c6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ -import 'dotenv/config'; -import { initEnv } from './config/env'; -import { Logger } from './utilities/Logger'; +import { initEnv } from './config/env.ts'; +import { Logger } from './utilities/Logger.ts'; async function main(): Promise { try { @@ -11,7 +10,7 @@ async function main(): Promise { process.exit(1); } - const { NovaClient } = await import('./client/NovaClient.js'); + const { NovaClient } = await import('./client/NovaClient.ts'); const novaClient = new NovaClient(); await novaClient.start(); } diff --git a/src/resources/EmbedColours.ts b/src/resources/EmbedColours.ts index a4a6a7a6..eda6504a 100644 --- a/src/resources/EmbedColours.ts +++ b/src/resources/EmbedColours.ts @@ -1,4 +1,4 @@ -import { ColorResolvable } from 'discord.js'; +import type { ColorResolvable } from 'discord.js'; interface EmbedColors { [key: string]: ColorResolvable; diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts index fd4baf0d..06349cd9 100644 --- a/src/services/AuditEmbed.ts +++ b/src/services/AuditEmbed.ts @@ -1,4 +1,4 @@ -import { ColorResolvable, EmbedBuilder, GuildMember, User } from 'discord.js'; +import { type ColorResolvable, EmbedBuilder, GuildMember, User } from 'discord.js'; const LIMITS = { title: 256, diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index 29413539..67027c40 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -1,6 +1,6 @@ import { Attachment, Client, EmbedBuilder } from 'discord.js'; -import type { ServerConfig } from '../client/database/schema'; -import { Logger } from '../utilities/Logger'; +import type { ServerConfig } from '../client/database/schema.ts'; +import { Logger } from '../utilities/Logger.ts'; export const sendAudit = async ( client: Client, diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 5b5070db..bf0ed1c6 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,12 +1,12 @@ import { Client } from 'discord.js'; import { DateTime } from 'luxon'; -import type { ServerConfig } from '../client/database/schema'; -import { env } from '../config/env'; -import { ConfigService } from '../services/ConfigService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../services/UserProfileService'; -import { BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates'; -import { resolveMember, resolveTextChannel } from './MemberResolver'; +import type { ServerConfig } from '../client/database/schema.ts'; +import { env } from '../config/env.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { Logger } from '../utilities/Logger.ts'; +import { UserProfileService } from '../services/UserProfileService.ts'; +import { type BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates.ts'; +import { resolveMember, resolveTextChannel } from './MemberResolver.ts'; export type CalendarStatus = | 'updated' diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index d614cc09..530c1924 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; -import { getDb, type AppDatabase } from '../client/database/db'; -import { serverConfigs, userProfiles, type ServerConfig } from '../client/database/schema'; +import { getDb, type AppDatabase } from '../client/database/db.ts'; +import { serverConfigs, userProfiles, type ServerConfig } from '../client/database/schema.ts'; export type ServerConfigPatch = Partial>; diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index 458ec3d0..82c6c9b2 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,14 +1,14 @@ import { Client } from 'discord.js'; -import { env } from '../config/env'; -import { ConfigService } from '../services/ConfigService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../services/UserProfileService'; +import { env } from '../config/env.ts'; +import { ConfigService } from '../services/ConfigService.ts'; +import { Logger } from '../utilities/Logger.ts'; +import { UserProfileService } from '../services/UserProfileService.ts'; import { - AccessOutcome, - RetentionPolicyConfig, + type AccessOutcome, + type RetentionPolicyConfig, classifyGuildFetchError, nextAccessState, -} from './RetentionPolicy'; +} from './RetentionPolicy.ts'; const cleanMembers = async ( client: Client, diff --git a/src/services/MemberListing.ts b/src/services/MemberListing.ts index 95ac7334..f1041a55 100644 --- a/src/services/MemberListing.ts +++ b/src/services/MemberListing.ts @@ -1,6 +1,6 @@ import { AttachmentBuilder, GuildMember } from 'discord.js'; import { DateTime } from 'luxon'; -import type { ReplyOptions } from '../framework/command'; +import type { ReplyOptions } from '../framework/command.ts'; export const INLINE_LIMIT = 2000; const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts index b412e81a..895a21bb 100644 --- a/src/services/MemberResolver.ts +++ b/src/services/MemberResolver.ts @@ -1,4 +1,4 @@ -import { Guild, GuildMember, GuildTextBasedChannel, User } from 'discord.js'; +import { Guild, GuildMember, type GuildTextBasedChannel, User } from 'discord.js'; const UNKNOWN_MEMBER = 10007; const UNKNOWN_USER = 10013; diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 4ce0f5ed..16ad34ed 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,10 +1,10 @@ -import { AttachmentBuilder, Client, Guild, GuildMember, PartialGuildMember, Role } from 'discord.js'; -import type { ServerConfig } from '../client/database/schema'; -import { EmbedColours } from '../resources/EmbedColours'; -import { Logger } from '../utilities/Logger'; -import { AuditEmbed } from './AuditEmbed'; -import { sendAudit } from './AuditService'; -import { WelcomeImage, WelcomeImageRenderer } from './WelcomeImage'; +import { AttachmentBuilder, Client, Guild, GuildMember, type PartialGuildMember, Role } from 'discord.js'; +import type { ServerConfig } from '../client/database/schema.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { Logger } from '../utilities/Logger.ts'; +import { AuditEmbed } from './AuditEmbed.ts'; +import { sendAudit } from './AuditService.ts'; +import { WelcomeImage, type WelcomeImageRenderer } from './WelcomeImage.ts'; /** 'done' | 'skipped:' | 'failed:' */ export type StepResult = string; diff --git a/src/services/Scheduler.ts b/src/services/Scheduler.ts index 7b5c6293..945ffcf0 100644 --- a/src/services/Scheduler.ts +++ b/src/services/Scheduler.ts @@ -1,5 +1,5 @@ -import { scheduleJob } from 'node-schedule'; -import { Logger } from '../utilities/Logger'; +import { Cron } from 'croner'; +import { Logger } from '../utilities/Logger.ts'; export interface SchedulerTask { name: string; @@ -16,18 +16,34 @@ export type ScheduleFn = ( callback: () => void ) => ScheduledJobLike | null; -const defaultScheduleFn: ScheduleFn = (spec, callback) => scheduleJob(spec, callback); +const defaultScheduleFn: ScheduleFn = (spec, callback) => { + try { + const job = new Cron(spec.rule, { timezone: spec.tz, protect: true }, callback); + return { cancel: () => job.stop(), nextInvocation: () => job.nextRun() }; + } catch { + return null; + } +}; export class Scheduler { private job: ScheduledJobLike | null = null; - private running = false; + private current: Promise | null = null; + private readonly cron: string; + private readonly timezone: string; + private readonly tasks: SchedulerTask[]; + private readonly scheduleFn: ScheduleFn; public constructor( - private readonly cron: string, - private readonly timezone: string, - private readonly tasks: SchedulerTask[], - private readonly scheduleFn: ScheduleFn = defaultScheduleFn - ) {} + cron: string, + timezone: string, + tasks: SchedulerTask[], + scheduleFn: ScheduleFn = defaultScheduleFn + ) { + this.cron = cron; + this.timezone = timezone; + this.tasks = tasks; + this.scheduleFn = scheduleFn; + } public start(): void { if (this.job) { @@ -43,27 +59,34 @@ export class Scheduler { this.job = job; } - public stop(): void { + /** Cancels future runs and waits for any in-flight run to finish. */ + public async stop(): Promise { this.job?.cancel(); this.job = null; + await this.current; } public async runNow(): Promise { - if (this.running) { + if (this.current) { Logger.warn('Scheduled run skipped; previous run still in progress'); return; } - this.running = true; + const run = this.runTasks(); + this.current = run; try { - for (const task of this.tasks) { - try { - await task.run(); - } catch (error) { - Logger.error('Scheduled task failed', { task: task.name }, error); - } - } + await run; } finally { - this.running = false; + this.current = null; + } + } + + private async runTasks(): Promise { + for (const task of this.tasks) { + try { + await task.run(); + } catch (error) { + Logger.error('Scheduled task failed', { task: task.name }, error); + } } } diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 8ea07b15..b7352c66 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,6 +1,6 @@ import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; -import { getDb } from '../client/database/db'; -import { userProfiles, type UserProfile } from '../client/database/schema'; +import { getDb } from '../client/database/db.ts'; +import { userProfiles, type UserProfile } from '../client/database/schema.ts'; export class UserProfileService { public static async getServerBirthdays(serverId: string): Promise { diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts index 668c5180..39ed8ef0 100644 --- a/src/services/WelcomeImage.ts +++ b/src/services/WelcomeImage.ts @@ -2,10 +2,10 @@ import path from 'node:path'; import { AttachmentBuilder, GuildMember } from 'discord.js'; import { DateTime } from 'luxon'; import { Canvas, createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; -import { env } from '../config/env'; +import { env } from '../config/env.ts'; const FONT_FAMILY = 'Roboto'; -const FONT_PATH = path.join(__dirname, '..', 'resources', 'fonts', 'Roboto-Regular.ttf'); +const FONT_PATH = path.join(import.meta.dirname, '..', 'resources', 'fonts', 'Roboto-Regular.ttf'); const DOWNLOAD_TIMEOUT_MS = 10_000; const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024; const MIN_FONT_SIZE = 8; diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts index 3c0aed9d..2baede2f 100644 --- a/src/slash-commands/Admin/noroles.ts +++ b/src/slash-commands/Admin/noroles.ts @@ -1,5 +1,5 @@ -import { defineCommand } from '../../framework/command'; -import { buildMemberListing } from '../../services/MemberListing'; +import { defineCommand } from '../../framework/command.ts'; +import { buildMemberListing } from '../../services/MemberListing.ts'; export default defineCommand({ name: 'noroles', diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts index cbf0cc5e..6e89104e 100644 --- a/src/slash-commands/Admin/post.ts +++ b/src/slash-commands/Admin/post.ts @@ -1,10 +1,10 @@ import { ChannelType, Message, PermissionFlagsBits } from 'discord.js'; -import { defineCommand } from '../../framework/command'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { AuditEmbed } from '../../services/AuditEmbed'; -import { sendAudit } from '../../services/AuditService'; -import { resolveTextChannel } from '../../services/MemberResolver'; -import { Logger } from '../../utilities/Logger'; +import { defineCommand } from '../../framework/command.ts'; +import { EmbedColours } from '../../resources/EmbedColours.ts'; +import { AuditEmbed } from '../../services/AuditEmbed.ts'; +import { sendAudit } from '../../services/AuditService.ts'; +import { resolveTextChannel } from '../../services/MemberResolver.ts'; +import { Logger } from '../../utilities/Logger.ts'; export default defineCommand({ name: 'post', diff --git a/src/slash-commands/Admin/rolesince.ts b/src/slash-commands/Admin/rolesince.ts index c8269abc..1eabb487 100644 --- a/src/slash-commands/Admin/rolesince.ts +++ b/src/slash-commands/Admin/rolesince.ts @@ -1,5 +1,5 @@ -import { defineCommand } from '../../framework/command'; -import { buildMemberListing, wholeDaysSinceJoin } from '../../services/MemberListing'; +import { defineCommand } from '../../framework/command.ts'; +import { buildMemberListing, wholeDaysSinceJoin } from '../../services/MemberListing.ts'; export default defineCommand({ name: 'rolesince', diff --git a/src/slash-commands/Admin/simulate.ts b/src/slash-commands/Admin/simulate.ts index 81e4d802..f3414db0 100644 --- a/src/slash-commands/Admin/simulate.ts +++ b/src/slash-commands/Admin/simulate.ts @@ -1,7 +1,7 @@ import { GuildMember, SlashCommandSubcommandBuilder } from 'discord.js'; -import { CommandContext, defineCommand } from '../../framework/command'; -import { resolveMember } from '../../services/MemberResolver'; -import { auditJoin, complete, formatOnboardingSummary } from '../../services/OnboardingService'; +import { type CommandContext, defineCommand } from '../../framework/command.ts'; +import { resolveMember } from '../../services/MemberResolver.ts'; +import { auditJoin, complete, formatOnboardingSummary } from '../../services/OnboardingService.ts'; const NO_MENTIONS = { parse: [] }; diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts index 6c128420..eaea07de 100644 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ b/src/slash-commands/Config/Subcommands/birthdays.ts @@ -1,12 +1,12 @@ import { ChannelType, PermissionFlagsBits } from 'discord.js'; -import { defineSubcommandGroup } from '../../../framework/command'; +import { defineSubcommandGroup } from '../../../framework/command.ts'; import { calendarMessageUrl, deleteCalendarMessage, refreshCalendar, -} from '../../../services/BirthdayService'; -import { ConfigService } from '../../../services/ConfigService'; -import { resolveTextChannel } from '../../../services/MemberResolver'; +} from '../../../services/BirthdayService.ts'; +import { ConfigService } from '../../../services/ConfigService.ts'; +import { resolveTextChannel } from '../../../services/MemberResolver.ts'; export const BirthdaysGroup = defineSubcommandGroup({ name: 'birthdays', diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/slash-commands/Config/Subcommands/debug.ts index e07f8f83..d24eeb08 100644 --- a/src/slash-commands/Config/Subcommands/debug.ts +++ b/src/slash-commands/Config/Subcommands/debug.ts @@ -1,4 +1,4 @@ -import { booleanSetting } from '../../../framework/settings'; +import { booleanSetting } from '../../../framework/settings.ts'; export const DebugGroup = booleanSetting({ name: 'debug', diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts index e1c50570..5720a633 100644 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ b/src/slash-commands/Config/Subcommands/newroles.ts @@ -1,10 +1,10 @@ import { Role } from 'discord.js'; -import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; -import { EmbedColours } from '../../../resources/EmbedColours'; -import { AuditEmbed } from '../../../services/AuditEmbed'; -import { sendAudit } from '../../../services/AuditService'; -import { ConfigService } from '../../../services/ConfigService'; -import { parseRoleIds, roleProblem } from '../../../services/OnboardingService'; +import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; +import { EmbedColours } from '../../../resources/EmbedColours.ts'; +import { AuditEmbed } from '../../../services/AuditEmbed.ts'; +import { sendAudit } from '../../../services/AuditService.ts'; +import { ConfigService } from '../../../services/ConfigService.ts'; +import { parseRoleIds, roleProblem } from '../../../services/OnboardingService.ts'; const ROLE_OPTIONS = ['role-one', 'role-two', 'role-three'] as const; const NO_MENTIONS = { parse: [] }; diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts index 5e269674..f848f6e9 100644 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ b/src/slash-commands/Config/Subcommands/welcome.ts @@ -1,7 +1,7 @@ -import { CommandContext, defineSubcommandGroup } from '../../../framework/command'; -import { ConfigService, type ServerConfigPatch } from '../../../services/ConfigService'; -import { complete } from '../../../services/OnboardingService'; -import { isHttpUrl, WelcomeImage } from '../../../services/WelcomeImage'; +import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; +import { ConfigService, type ServerConfigPatch } from '../../../services/ConfigService.ts'; +import { complete } from '../../../services/OnboardingService.ts'; +import { isHttpUrl, WelcomeImage } from '../../../services/WelcomeImage.ts'; export const MAX_WELCOME_MESSAGE_LENGTH = 1500; diff --git a/src/slash-commands/Config/config.ts b/src/slash-commands/Config/config.ts index 59f55532..e9f69315 100644 --- a/src/slash-commands/Config/config.ts +++ b/src/slash-commands/Config/config.ts @@ -1,10 +1,10 @@ import { ChannelType } from 'discord.js'; -import { defineCommand } from '../../framework/command'; -import { booleanSetting, channelSetting } from '../../framework/settings'; -import BirthdaysGroup from './Subcommands/birthdays'; -import DebugGroup from './Subcommands/debug'; -import NewRolesGroup from './Subcommands/newroles'; -import WelcomeGroup from './Subcommands/welcome'; +import { defineCommand } from '../../framework/command.ts'; +import { booleanSetting, channelSetting } from '../../framework/settings.ts'; +import BirthdaysGroup from './Subcommands/birthdays.ts'; +import DebugGroup from './Subcommands/debug.ts'; +import NewRolesGroup from './Subcommands/newroles.ts'; +import WelcomeGroup from './Subcommands/welcome.ts'; const textChannels = [ChannelType.GuildText, ChannelType.GuildAnnouncement] as const; diff --git a/src/slash-commands/Info/about.ts b/src/slash-commands/Info/about.ts index e78f0a67..a2bd8660 100644 --- a/src/slash-commands/Info/about.ts +++ b/src/slash-commands/Info/about.ts @@ -1,8 +1,8 @@ import { EmbedBuilder } from 'discord.js'; -import { defineCommand } from '../../framework/command'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { toRepoUrl, formatUptime } from '../../utilities/format'; -import * as packageJson from '../../../package.json'; +import { defineCommand } from '../../framework/command.ts'; +import { EmbedColours } from '../../resources/EmbedColours.ts'; +import { toRepoUrl, formatUptime } from '../../utilities/format.ts'; +import packageJson from '../../../package.json' with { type: 'json' }; export default defineCommand({ name: 'about', diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts index 018e7531..2ff23596 100644 --- a/src/slash-commands/Info/mybirthday.ts +++ b/src/slash-commands/Info/mybirthday.ts @@ -1,10 +1,10 @@ import { DateTime } from 'luxon'; -import { env } from '../../config/env'; -import { CommandContext, defineCommand } from '../../framework/command'; -import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates'; -import { refreshCalendar } from '../../services/BirthdayService'; -import { Logger } from '../../utilities/Logger'; -import { UserProfileService } from '../../services/UserProfileService'; +import { env } from '../../config/env.ts'; +import { type CommandContext, defineCommand } from '../../framework/command.ts'; +import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates.ts'; +import { refreshCalendar } from '../../services/BirthdayService.ts'; +import { Logger } from '../../utilities/Logger.ts'; +import { UserProfileService } from '../../services/UserProfileService.ts'; const refresh = async (ctx: CommandContext): Promise => { try { diff --git a/src/slash-commands/Info/ping.ts b/src/slash-commands/Info/ping.ts index 52c4b629..13a51f0c 100644 --- a/src/slash-commands/Info/ping.ts +++ b/src/slash-commands/Info/ping.ts @@ -1,4 +1,4 @@ -import { defineCommand } from '../../framework/command'; +import { defineCommand } from '../../framework/command.ts'; export default defineCommand({ name: 'ping', diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts index b7498662..cf864dc6 100644 --- a/src/slash-commands/Info/profile.ts +++ b/src/slash-commands/Info/profile.ts @@ -1,9 +1,9 @@ import { EmbedBuilder } from 'discord.js'; import { DateTime } from 'luxon'; -import { defineCommand } from '../../framework/command'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { resolveMember } from '../../services/MemberResolver'; -import { UserProfileService } from '../../services/UserProfileService'; +import { defineCommand } from '../../framework/command.ts'; +import { EmbedColours } from '../../resources/EmbedColours.ts'; +import { resolveMember } from '../../services/MemberResolver.ts'; +import { UserProfileService } from '../../services/UserProfileService.ts'; export default defineCommand({ name: 'profile', diff --git a/src/slash-commands/index.ts b/src/slash-commands/index.ts new file mode 100644 index 00000000..9e47b47a --- /dev/null +++ b/src/slash-commands/index.ts @@ -0,0 +1,13 @@ +import type { Command } from '../framework/command.ts'; +import noroles from './Admin/noroles.ts'; +import post from './Admin/post.ts'; +import rolesince from './Admin/rolesince.ts'; +import simulate from './Admin/simulate.ts'; +import config from './Config/config.ts'; +import about from './Info/about.ts'; +import mybirthday from './Info/mybirthday.ts'; +import ping from './Info/ping.ts'; +import profile from './Info/profile.ts'; + +/** Every slash command the bot registers. Add new commands here. */ +export const commands: Command[] = [noroles, post, rolesince, simulate, config, about, mybirthday, ping, profile]; diff --git a/tests/about.test.ts b/tests/about.test.ts index 73f15436..c2d995c6 100644 --- a/tests/about.test.ts +++ b/tests/about.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { toRepoUrl, formatUptime } from '../src/utilities/format'; +import { toRepoUrl, formatUptime } from '../src/utilities/format.ts'; test('toRepoUrl', async (t) => { await t.test('removes git+ prefix', () => { diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index f5272362..1265bf9c 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -1,17 +1,17 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { DateTime } from 'luxon'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import type { ServerConfig } from '../../src/client/database/schema'; -import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { ServerConfig } from '../../src/client/database/schema.ts'; +import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService.ts'; import { apiError, fakeClient, fakeGuildWithMembers, fakeMessage, fakeTextChannel, -} from '../fakes/guild'; -import { clearConfigs, clearProfiles, closeTestDb, createConfig, createProfiles, createTestDb } from '../helpers/db'; +} from '../fakes/guild.ts'; +import { clearConfigs, clearProfiles, closeTestDb, createConfig, createProfiles, createTestDb } from '../helpers/db.ts'; console.warn = (): void => undefined; console.error = (): void => undefined; diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 3554c9d9..111a5ff9 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import { initEnv } from '../../src/config/env'; -import { CommandContext, createReply } from '../../src/framework/command'; -import mybirthday from '../../src/slash-commands/Info/mybirthday'; -import profile from '../../src/slash-commands/Info/profile'; -import { fakeClient, fakeGuildWithMembers } from '../fakes/guild'; -import { fakeInteraction } from '../fakes/interaction'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import { initEnv } from '../../src/config/env.ts'; +import { type CommandContext, createReply } from '../../src/framework/command.ts'; +import mybirthday from '../../src/slash-commands/Info/mybirthday.ts'; +import profile from '../../src/slash-commands/Info/profile.ts'; +import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; import { clearConfigs, clearProfiles, @@ -16,7 +16,7 @@ import { createProfiles, createTestDb, findProfile, -} from '../helpers/db'; +} from '../helpers/db.ts'; initEnv({ TOKEN: 't', CLIENT_ID: 'c', DB_PATH: ':memory:', BOT_TIMEZONE: 'Europe/London' }); diff --git a/tests/command.test.ts b/tests/command.test.ts index f7cb9acd..d74a11a0 100644 --- a/tests/command.test.ts +++ b/tests/command.test.ts @@ -2,12 +2,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { MessageFlags } from 'discord.js'; import { - CommandContext, + type CommandContext, defineCommand, defineSubcommandGroup, createReply, -} from '../src/framework/command'; -import { fakeInteraction } from './fakes/interaction'; +} from '../src/framework/command.ts'; +import { fakeInteraction } from './fakes/interaction.ts'; const noop = async () => {}; diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index 32e07771..53db5ed8 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import Database from 'better-sqlite3'; -import { migrate, migrations, type Migration } from '../../src/client/database/migrator'; -import { Logger } from '../../src/utilities/Logger'; +import { migrate, migrations, type Migration } from '../../src/client/database/migrator.ts'; +import { Logger } from '../../src/utilities/Logger.ts'; type Db = Database.Database; diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index bbc10021..660f9941 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { count, eq } from 'drizzle-orm'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import { serverConfigs, userProfiles } from '../../src/client/database/schema'; -import { ConfigService } from '../../src/services/ConfigService'; -import { closeTestDb, createTestDb } from '../helpers/db'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import { serverConfigs, userProfiles } from '../../src/client/database/schema.ts'; +import { ConfigService } from '../../src/services/ConfigService.ts'; +import { closeTestDb, createTestDb } from '../helpers/db.ts'; let handle: DatabaseHandle; diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts index 8496cf0c..fa495645 100644 --- a/tests/database/schema.test.ts +++ b/tests/database/schema.test.ts @@ -2,8 +2,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { getTableColumns, getTableName } from 'drizzle-orm'; import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; -import { serverConfigs, userProfiles } from '../../src/client/database/schema'; -import { closeTestDb, createTestDb } from '../helpers/db'; +import { serverConfigs, userProfiles } from '../../src/client/database/schema.ts'; +import { closeTestDb, createTestDb } from '../helpers/db.ts'; const columnNames = (table: SQLiteTable): string[] => Object.values(getTableColumns(table)).map((column) => column.name); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index 01413b2b..6673ea0d 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -1,15 +1,15 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import { ConfigService } from '../../src/services/ConfigService'; -import { UserProfileService } from '../../src/services/UserProfileService'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import { ConfigService } from '../../src/services/ConfigService.ts'; +import { UserProfileService } from '../../src/services/UserProfileService.ts'; import { allProfiles, clearConfigs, clearProfiles, closeTestDb, createTestDb, -} from '../helpers/db'; +} from '../helpers/db.ts'; let db: DatabaseHandle; diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index 1708f239..a307c542 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import type { ServerConfig } from '../../src/client/database/schema'; -import { run } from '../../src/services/DataCheckService'; -import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild'; -import { clearConfigs, clearProfiles, closeTestDb, allProfiles, countProfiles, createConfig, createConfigs, createProfiles, createTestDb, findConfig } from '../helpers/db'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { ServerConfig } from '../../src/client/database/schema.ts'; +import { run } from '../../src/services/DataCheckService.ts'; +import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; +import { clearConfigs, clearProfiles, closeTestDb, allProfiles, countProfiles, createConfig, createConfigs, createProfiles, createTestDb, findConfig } from '../helpers/db.ts'; console.warn = (): void => undefined; console.error = (): void => undefined; diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index df4f3076..ead390b6 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; -import type { DatabaseHandle } from '../src/client/database/db'; -import { closeTestDb, createTestDb } from './helpers/db'; -import type { NovaClient } from '../src/client/NovaClient'; -import { Command, defineCommand } from '../src/framework/command'; -import interactionCreate from '../src/events/interactionCreate'; -import { fakeInteraction } from './fakes/interaction'; +import type { DatabaseHandle } from '../src/client/database/db.ts'; +import { closeTestDb, createTestDb } from './helpers/db.ts'; +import type { NovaClient } from '../src/client/NovaClient.ts'; +import { type Command, defineCommand } from '../src/framework/command.ts'; +import interactionCreate from '../src/events/interactionCreate.ts'; +import { fakeInteraction } from './fakes/interaction.ts'; let db: DatabaseHandle; const original = { log: console.log, warn: console.warn, error: console.error }; diff --git a/tests/env.test.ts b/tests/env.test.ts index ca9f2d1f..9dc42139 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { loadEnv } from '../src/config/env'; +import { loadEnv } from '../src/config/env.ts'; const valid = { TOKEN: 'abc', CLIENT_ID: '123' }; diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts index dd236459..eca6f993 100644 --- a/tests/fakes/messages.ts +++ b/tests/fakes/messages.ts @@ -1,8 +1,8 @@ import { Collection, User } from 'discord.js'; -import type { NovaClient } from '../../src/client/NovaClient'; -import type { Command } from '../../src/framework/command'; -import interactionCreate from '../../src/events/interactionCreate'; -import type { FakeInteraction } from './interaction'; +import type { NovaClient } from '../../src/client/NovaClient.ts'; +import type { Command } from '../../src/framework/command.ts'; +import interactionCreate from '../../src/events/interactionCreate.ts'; +import type { FakeInteraction } from './interaction.ts'; export interface AuditSink { client: NovaClient; diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index 3a2dbadf..cecf76c5 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,7 +1,7 @@ import type { Client, GuildMember } from 'discord.js'; -import type { ServerConfig } from '../../src/client/database/schema'; -import type { CommandContext } from '../../src/framework/command'; -import { fakeInteraction } from './interaction'; +import type { ServerConfig } from '../../src/client/database/schema.ts'; +import type { CommandContext } from '../../src/framework/command.ts'; +import { fakeInteraction } from './interaction.ts'; export interface FakeRole { id: string; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 93ac3a2f..e968fe8e 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,12 +1,12 @@ import { eq } from 'drizzle-orm'; -import { createDatabase, getDb, setDatabase, type DatabaseHandle } from '../../src/client/database/db'; -import { migrate } from '../../src/client/database/migrator'; +import { createDatabase, getDb, setDatabase, type DatabaseHandle } from '../../src/client/database/db.ts'; +import { migrate } from '../../src/client/database/migrator.ts'; import { serverConfigs, userProfiles, type ServerConfig, type UserProfile, -} from '../../src/client/database/schema'; +} from '../../src/client/database/schema.ts'; /** An in-memory, fully migrated database that the services use until it is closed. */ export function createTestDb(): DatabaseHandle { diff --git a/tests/loader.test.ts b/tests/loader.test.ts deleted file mode 100644 index 894ba592..00000000 --- a/tests/loader.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, test } from 'node:test'; -import { loadCommands, loadEvents } from '../src/framework/loader'; - -let root: string; - -beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), 'dingir-loader-')); -}); - -afterEach(async () => { - await fs.rm(root, { recursive: true, force: true }); -}); - -const write = async (relative: string, contents: string) => { - const file = path.join(root, relative); - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, contents); - return file; -}; - -const commandModule = (name: string) => - `exports.__esModule = true;\nexports.default = { name: ${JSON.stringify(name)}, toJSON() { return {}; }, resolve() {} };\n`; -const eventModule = (name: string) => - `exports.__esModule = true;\nexports.default = { name: ${JSON.stringify(name)}, run: async () => {} };\n`; - -test('loadCommands reads the top level of each category and skips Subcommands, .d.ts and maps', async () => { - await write('Info/ping.js', commandModule('ping')); - await write('Info/about.js', commandModule('about')); - await write('Info/ping.js.map', '{}'); - await write('Info/types.d.ts', 'export {};'); - await write('Info/Subcommands/ignored.js', 'throw new Error("must not load");'); - await write('Config/Subcommands/nested.js', 'throw new Error("must not load");'); - await write('Config/config.js', commandModule('config')); - await write('Admin/deep/ignored.js', 'throw new Error("must not load");'); - - const commands = await loadCommands(root); - assert.deepEqual(commands.map((c) => c.name).sort(), ['about', 'config', 'ping']); -}); - -test('loadCommands rejects duplicate command names, naming both files', async () => { - const first = await write('A/one.js', commandModule('same')); - const second = await write('B/two.js', commandModule('same')); - await assert.rejects(loadCommands(root), (err: Error) => { - assert.ok(err.message.includes('Duplicate command "same"')); - assert.ok(err.message.includes(second)); - assert.ok(err.message.includes(first)); - return true; - }); -}); - -test('loadCommands rejects modules without a valid default export', async () => { - const file = await write('A/bad.js', 'exports.default = { name: "bad" };'); - await assert.rejects(loadCommands(root), (err: Error) => { - assert.ok(err.message.includes(file)); - assert.match(err.message, /export default a Command/); - return true; - }); -}); - -test('loadCommands rejects legacy named exports', async () => { - const file = await write('A/legacy.js', 'exports.commandData = {}; exports.execute = () => {};'); - await assert.rejects(loadCommands(root), (err: Error) => err.message.includes(file)); -}); - -test('loadCommands reports files that fail to import with their path', async () => { - const file = await write('A/broken.js', 'throw new Error("syntax boom");'); - await assert.rejects(loadCommands(root), (err: Error) => { - assert.ok(err.message.includes(file)); - assert.ok(err.message.includes('syntax boom')); - return true; - }); -}); - -test('loadEvents loads top-level event modules', async () => { - await write('ready.js', eventModule('clientReady')); - await write('messageCreate.js', eventModule('messageCreate')); - await write('helpers/ignored.js', 'throw new Error("must not load");'); - await write('messageCreate.d.ts', ''); - - const events = await loadEvents(root); - assert.deepEqual(events.map((e) => e.name).sort(), ['clientReady', 'messageCreate']); -}); - -test('loadEvents rejects duplicates and bad shapes', async () => { - await write('a.js', eventModule('messageCreate')); - await write('b.js', eventModule('messageCreate')); - await assert.rejects(loadEvents(root), /Duplicate event "messageCreate"/); - - await fs.rm(path.join(root, 'b.js')); - const bad = await write('c.js', 'exports.default = { name: "x", run: 5 };'); - await assert.rejects(loadEvents(root), (err: Error) => err.message.includes(bad)); -}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 5e7503f2..4ce4fda2 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, test } from 'node:test'; -import { Logger, formatContext } from '../src/utilities/Logger'; +import { Logger, formatContext } from '../src/utilities/Logger.ts'; type Method = 'log' | 'warn' | 'error'; const original = { log: console.log, warn: console.warn, error: console.error }; diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index 42600f25..72f01fca 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -1,16 +1,16 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import guildMemberRemove from '../../src/events/guildMemberRemove'; -import messageCreate from '../../src/events/messageCreate'; -import messageDelete from '../../src/events/messageDelete'; -import messageUpdate from '../../src/events/messageUpdate'; -import { ConfigService } from '../../src/services/ConfigService'; -import { HoneyPotEnforcementService } from '../../src/services/HoneyPotEnforcementService'; -import { UserProfileService } from '../../src/services/UserProfileService'; -import { clearProfiles, closeTestDb, createTestDb, findProfile } from '../helpers/db'; -import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; +import messageCreate from '../../src/events/messageCreate.ts'; +import messageDelete from '../../src/events/messageDelete.ts'; +import messageUpdate from '../../src/events/messageUpdate.ts'; +import { ConfigService } from '../../src/services/ConfigService.ts'; +import { HoneyPotEnforcementService } from '../../src/services/HoneyPotEnforcementService.ts'; +import { UserProfileService } from '../../src/services/UserProfileService.ts'; +import { clearProfiles, closeTestDb, createTestDb, findProfile } from '../helpers/db.ts'; +import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages.ts'; let db: DatabaseHandle; const original = { log: console.log, warn: console.warn, error: console.error }; diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 4db68b90..873d1bc6 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import { after, before, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import noroles from '../../src/slash-commands/Admin/noroles'; -import rolesince from '../../src/slash-commands/Admin/rolesince'; -import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing'; -import { closeTestDb, createTestDb } from '../helpers/db'; -import { fakeInteraction } from '../fakes/interaction'; -import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import noroles from '../../src/slash-commands/Admin/noroles.ts'; +import rolesince from '../../src/slash-commands/Admin/rolesince.ts'; +import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing.ts'; +import { closeTestDb, createTestDb } from '../helpers/db.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; +import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages.ts'; let db: DatabaseHandle; before(async () => { diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index cca8fa56..ade82432 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { Collection, PermissionFlagsBits } from 'discord.js'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import post from '../../src/slash-commands/Admin/post'; -import { ConfigService } from '../../src/services/ConfigService'; -import { closeTestDb, createTestDb } from '../helpers/db'; -import { fakeInteraction } from '../fakes/interaction'; -import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import post from '../../src/slash-commands/Admin/post.ts'; +import { ConfigService } from '../../src/services/ConfigService.ts'; +import { closeTestDb, createTestDb } from '../helpers/db.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; +import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages.ts'; let db: DatabaseHandle; const original = { log: console.log, warn: console.warn, error: console.error }; diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts index a5a6895a..9f211eac 100644 --- a/tests/onboarding/OnboardingService.test.ts +++ b/tests/onboarding/OnboardingService.test.ts @@ -6,8 +6,8 @@ import { completedScreening, formatOnboardingSummary, parseRoleIds, -} from '../../src/services/OnboardingService'; -import { fakeOnboarding, role } from '../fakes/onboarding'; +} from '../../src/services/OnboardingService.ts'; +import { fakeOnboarding, role } from '../fakes/onboarding.ts'; const okImage = async () => new AttachmentBuilder(Buffer.from('x')).setName('welcome-image.png'); const failImage = async () => { diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 590b8e3a..50e5aba6 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -1,15 +1,15 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import { Handler } from '../../src/framework/command'; -import NewRolesGroup from '../../src/slash-commands/Config/Subcommands/newroles'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { Handler } from '../../src/framework/command.ts'; +import NewRolesGroup from '../../src/slash-commands/Config/Subcommands/newroles.ts'; import WelcomeGroup, { MAX_WELCOME_MESSAGE_LENGTH, validateImageUrl, validateWelcomeMessage, -} from '../../src/slash-commands/Config/Subcommands/welcome'; -import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding'; -import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db'; +} from '../../src/slash-commands/Config/Subcommands/welcome.ts'; +import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db.ts'; let db: DatabaseHandle; diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 5f736193..27455289 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db'; -import guildMemberAdd from '../../src/events/guildMemberAdd'; -import guildMemberUpdate from '../../src/events/guildMemberUpdate'; -import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db'; -import { fakeOnboarding, role } from '../fakes/onboarding'; +import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; +import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; +import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db.ts'; +import { fakeOnboarding, role } from '../fakes/onboarding.ts'; let db: DatabaseHandle; diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 00000000..243aa566 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { events } from '../src/events/index.ts'; +import { validateRegistry } from '../src/framework/registry.ts'; +import { commands } from '../src/slash-commands/index.ts'; + +test('validateRegistry accepts unique names', () => { + assert.doesNotThrow(() => validateRegistry('command', [{ name: 'a' }, { name: 'b' }])); +}); + +test('validateRegistry rejects duplicate names', () => { + assert.throws(() => validateRegistry('command', [{ name: 'same' }, { name: 'same' }]), /Duplicate command "same"/); + assert.throws(() => validateRegistry('event', [{ name: 'x' }, { name: 'x' }]), /Duplicate event "x"/); +}); + +test('validateRegistry rejects missing names', () => { + assert.throws(() => validateRegistry('command', [{ name: '' }]), /non-empty name/); +}); + +test('the real command registry is valid and shaped correctly', () => { + validateRegistry('command', commands); + assert.ok(commands.length > 0); + for (const command of commands) { + assert.equal(typeof command.toJSON, 'function'); + assert.equal(typeof command.resolve, 'function'); + } +}); + +test('the real event registry is valid and shaped correctly', () => { + validateRegistry('event', events); + assert.ok(events.length > 0); + for (const event of events) { + assert.equal(typeof event.run, 'function'); + } +}); diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts index c137a679..41bf55fb 100644 --- a/tests/services/AuditEmbed.test.ts +++ b/tests/services/AuditEmbed.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { GuildMember } from 'discord.js'; -import { AuditEmbed } from '../../src/services/AuditEmbed'; -import { EmbedColours } from '../../src/resources/EmbedColours'; +import { AuditEmbed } from '../../src/services/AuditEmbed.ts'; +import { EmbedColours } from '../../src/resources/EmbedColours.ts'; const total = (json: ReturnType): number => (json.title?.length ?? 0) + diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index 5897b6c5..7de7210c 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { Client, EmbedBuilder } from 'discord.js'; -import type { ServerConfig } from '../../src/client/database/schema'; -import { sendAudit } from '../../src/services/AuditService'; +import type { ServerConfig } from '../../src/client/database/schema.ts'; +import { sendAudit } from '../../src/services/AuditService.ts'; console.warn = (): void => undefined; diff --git a/tests/services/BirthdayDates.test.ts b/tests/services/BirthdayDates.test.ts index 94090ac3..8b1e32ba 100644 --- a/tests/services/BirthdayDates.test.ts +++ b/tests/services/BirthdayDates.test.ts @@ -7,7 +7,7 @@ import { isValidBirthday, nextOccurrence, upcoming, -} from '../../src/services/BirthdayDates'; +} from '../../src/services/BirthdayDates.ts'; const LONDON = 'Europe/London'; const at = (iso: string, zone = 'utc'): DateTime => DateTime.fromISO(iso, { zone }); diff --git a/tests/services/MemberResolver.test.ts b/tests/services/MemberResolver.test.ts index 787f6664..b17c3507 100644 --- a/tests/services/MemberResolver.test.ts +++ b/tests/services/MemberResolver.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { Guild, GuildMember } from 'discord.js'; -import { resolveMember, resolveTextChannel } from '../../src/services/MemberResolver'; +import { resolveMember, resolveTextChannel } from '../../src/services/MemberResolver.ts'; const guildWith = (cached: Record, fetch: (id: string) => Promise): Guild => ({ diff --git a/tests/services/RetentionPolicy.test.ts b/tests/services/RetentionPolicy.test.ts index c486dc13..e7721721 100644 --- a/tests/services/RetentionPolicy.test.ts +++ b/tests/services/RetentionPolicy.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { - AccessState, + type AccessState, classifyGuildFetchError, nextAccessState, -} from '../../src/services/RetentionPolicy'; +} from '../../src/services/RetentionPolicy.ts'; const policy = { minFailures: 3, graceDays: 7 }; const day = (n: number): Date => new Date(Date.UTC(2026, 0, 1 + n)); diff --git a/tests/services/Scheduler.test.ts b/tests/services/Scheduler.test.ts index b1199501..a209904d 100644 --- a/tests/services/Scheduler.test.ts +++ b/tests/services/Scheduler.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { ScheduleFn, Scheduler } from '../../src/services/Scheduler'; +import { type ScheduleFn, Scheduler } from '../../src/services/Scheduler.ts'; const silence = (): void => { console.warn = (): void => undefined; @@ -85,18 +85,55 @@ test('start throws when the schedule is invalid', () => { assert.throws(() => scheduler.start(), /Invalid cron/); }); -test('stop cancels the job and clears nextInvocation', () => { +test('stop cancels the job and clears nextInvocation', async () => { const fake = fakeSchedule(); const scheduler = new Scheduler('* * * * *', 'utc', [], fake.fn); scheduler.start(); - scheduler.stop(); + await scheduler.stop(); assert.equal(fake.cancelled(), 1); assert.equal(scheduler.nextInvocation(), null); scheduler.start(); assert.equal(fake.calls.length, 2); }); -test('real node-schedule rejects an invalid cron string', () => { +test('real croner schedule rejects an invalid cron string', () => { const scheduler = new Scheduler('not a cron', 'utc', []); assert.throws(() => scheduler.start()); }); + +test('stop during a run waits for it to finish', async () => { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let finished = false; + const scheduler = new Scheduler('* * * * *', 'utc', [ + { + name: 'slow', + run: async () => { + await gate; + finished = true; + }, + }, + ]); + scheduler.start(); + const run = scheduler.runNow(); + let stopped = false; + const stopping = scheduler.stop().then(() => { + stopped = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(stopped, false); + release(); + await stopping; + await run; + assert.equal(finished, true); + assert.equal(stopped, true); +}); + +test('real croner schedule accepts a valid cron and reports the next run', async () => { + const scheduler = new Scheduler('0 9 * * *', 'Europe/London', []); + scheduler.start(); + assert.ok(scheduler.nextInvocation() instanceof Date); + await scheduler.stop(); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index eb7e95ca..45d9efaf 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { ChannelType } from 'discord.js'; -import type { DatabaseHandle } from '../src/client/database/db'; -import { closeTestDb, clearConfigs, createTestDb } from './helpers/db'; -import type { ServerConfig } from '../src/client/database/schema'; -import { ConfigService } from '../src/services/ConfigService'; -import { CommandContext, defineCommand, Handler } from '../src/framework/command'; +import type { DatabaseHandle } from '../src/client/database/db.ts'; +import { closeTestDb, clearConfigs, createTestDb } from './helpers/db.ts'; +import type { ServerConfig } from '../src/client/database/schema.ts'; +import { ConfigService } from '../src/services/ConfigService.ts'; +import { type CommandContext, defineCommand, type Handler } from '../src/framework/command.ts'; import { booleanSetting, booleanText, @@ -13,8 +13,8 @@ import { channelGetText, channelSetText, channelSetting, -} from '../src/framework/settings'; -import { fakeGuild, fakeInteraction } from './fakes/interaction'; +} from '../src/framework/settings.ts'; +import { fakeGuild, fakeInteraction } from './fakes/interaction.ts'; let db: DatabaseHandle; let config: ServerConfig; diff --git a/tests/tsconfig.json b/tests/tsconfig.json deleted file mode 100644 index 8f5f96ce..00000000 --- a/tests/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../tsconfig.test.json", - "compilerOptions": { - "rootDir": ".." - }, - "include": ["../src", "."] -} diff --git a/tsconfig.json b/tsconfig.json index 1d275fbf..79f3a078 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,18 @@ { "compilerOptions": { "lib": ["ESNext"], - "module": "NodeNext", - "moduleResolution": "NodeNext", + "types": ["node"], + "module": "nodenext", + "moduleResolution": "nodenext", "target": "ES2024", + "noEmit": true, "strict": false, "strictNullChecks": true, - "outDir": "dist", - "sourceMap": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "skipLibCheck": true, - "skipDefaultLibCheck": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, "resolveJsonModule": true, - "importHelpers": true, + "skipLibCheck": true }, - "include": ["src"], - "exclude": ["node_modules", "**/*.spec.ts"] + "include": ["src", "tests"] } diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index 06c8e029..00000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "." - }, - "include": ["src", "tests"] -} From cc070b0c3f4ded982f2ef438b1842275438a58b5 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:22 +0100 Subject: [PATCH 19/77] Run node directly in Docker and gate publish on tests Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 8 ++++++-- .github/workflows/build-and-publish.yml | 11 +++++++++-- .github/workflows/lint-and-build.yml | 10 +++++----- Dockerfile | 21 ++++++--------------- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.dockerignore b/.dockerignore index 2bdf188c..bcffa0e8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,10 @@ node_modules -npm-debug.log +dist +data +tests +coverage .env .git .github -docs \ No newline at end of file +.claude +docs diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f374700d..f05de2d4 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -10,8 +10,8 @@ permissions: packages: write jobs: - run-linters: - name: Run ESLint + verify: + name: Lint, Typecheck and Test runs-on: ubuntu-latest steps: @@ -34,8 +34,15 @@ jobs: - name: Run linters run: npm run lint + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + docker: name: Build Docker Image + needs: verify runs-on: ubuntu-latest steps: diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 198bb34c..af96fac5 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -1,4 +1,4 @@ -name: Lint and Build +name: Lint, Typecheck and Test on: # Trigger the workflow on push or pull request, @@ -15,8 +15,8 @@ permissions: checks: write jobs: - lint-and-build: - name: Lint and Build + verify: + name: Lint, Typecheck and Test runs-on: ubuntu-latest steps: @@ -38,8 +38,8 @@ jobs: - name: Run linters run: npm run lint - - name: Build TypeScript - run: npm run build + - name: Type check + run: npm run typecheck - name: Run tests run: npm test diff --git a/Dockerfile b/Dockerfile index 4f07dda3..aa4a5e1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,9 @@ -# Stage 1 - Build - -FROM node:24-trixie-slim AS build +FROM node:24-trixie-slim WORKDIR /usr/src/app COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build - -# Stage 2 - Dist Only - -FROM node:24-trixie-slim AS dist -WORKDIR /usr/src/app -COPY package*.json ./ -RUN npm ci --omit=dev -COPY --from=build /usr/src/app/dist dist +RUN npm ci --omit=dev --ignore-scripts +COPY src ./src +RUN mkdir -p data && chown node:node data +USER node VOLUME /usr/src/app/data -ENTRYPOINT ["npm", "start"] +CMD ["node", "src/index.ts"] From b4f9d704176e8f9130125473c5dda4b099082fba Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:08:57 +0100 Subject: [PATCH 20/77] Rename NovaClient to DingirClient and reorganise commands and db directories Co-Authored-By: Claude Sonnet 5.5 --- README.md | 28 +++++++++---------- src/client/{NovaClient.ts => DingirClient.ts} | 10 +++---- .../Admin => commands/admin}/noroles.ts | 0 .../Admin => commands/admin}/post.ts | 0 .../Admin => commands/admin}/rolesince.ts | 0 .../Admin => commands/admin}/simulate.ts | 0 .../Config => commands/config}/config.ts | 8 +++--- .../config/groups}/birthdays.ts | 0 .../config/groups}/debug.ts | 0 .../config/groups}/newroles.ts | 0 .../config/groups}/welcome.ts | 0 src/commands/index.ts | 13 +++++++++ .../Info => commands/info}/about.ts | 0 .../Info => commands/info}/mybirthday.ts | 0 .../Info => commands/info}/ping.ts | 0 .../Info => commands/info}/profile.ts | 0 src/{client/database => db}/db.ts | 2 +- .../migrations/001-baseline.ts | 0 .../migrations/002-userprofile-unique.ts | 2 +- .../003-serverconfig-access-tracking.ts | 0 src/{client/database => db}/migrator.ts | 0 src/{client/database => db}/schema.ts | 0 src/events/messageCreate.ts | 8 +++--- src/framework/command.ts | 2 +- src/framework/event.ts | 6 ++-- src/index.ts | 6 ++-- src/services/AuditService.ts | 2 +- src/services/BirthdayService.ts | 2 +- src/services/ConfigService.ts | 4 +-- src/services/OnboardingService.ts | 2 +- src/services/UserProfileService.ts | 4 +-- src/slash-commands/index.ts | 13 --------- tests/birthdays/BirthdayService.test.ts | 4 +-- tests/birthdays/commands.test.ts | 6 ++-- tests/database/migrations.test.ts | 2 +- tests/database/purgeGuild.test.ts | 4 +-- tests/database/schema.test.ts | 2 +- tests/database/services.test.ts | 2 +- tests/datacheck/DataCheckService.test.ts | 4 +-- tests/dispatcher.test.ts | 8 +++--- tests/fakes/messages.ts | 8 +++--- tests/fakes/onboarding.ts | 2 +- tests/helpers/db.ts | 6 ++-- tests/moderation/events.test.ts | 2 +- tests/moderation/memberListing.test.ts | 6 ++-- tests/moderation/post.test.ts | 4 +-- tests/onboarding/commands.test.ts | 6 ++-- tests/onboarding/events.test.ts | 2 +- tests/registry.test.ts | 2 +- tests/services/AuditService.test.ts | 2 +- tests/settings.test.ts | 4 +-- 51 files changed, 94 insertions(+), 94 deletions(-) rename src/client/{NovaClient.ts => DingirClient.ts} (91%) rename src/{slash-commands/Admin => commands/admin}/noroles.ts (100%) rename src/{slash-commands/Admin => commands/admin}/post.ts (100%) rename src/{slash-commands/Admin => commands/admin}/rolesince.ts (100%) rename src/{slash-commands/Admin => commands/admin}/simulate.ts (100%) rename src/{slash-commands/Config => commands/config}/config.ts (86%) rename src/{slash-commands/Config/Subcommands => commands/config/groups}/birthdays.ts (100%) rename src/{slash-commands/Config/Subcommands => commands/config/groups}/debug.ts (100%) rename src/{slash-commands/Config/Subcommands => commands/config/groups}/newroles.ts (100%) rename src/{slash-commands/Config/Subcommands => commands/config/groups}/welcome.ts (100%) create mode 100644 src/commands/index.ts rename src/{slash-commands/Info => commands/info}/about.ts (100%) rename src/{slash-commands/Info => commands/info}/mybirthday.ts (100%) rename src/{slash-commands/Info => commands/info}/ping.ts (100%) rename src/{slash-commands/Info => commands/info}/profile.ts (100%) rename src/{client/database => db}/db.ts (96%) rename src/{client/database => db}/migrations/001-baseline.ts (100%) rename src/{client/database => db}/migrations/002-userprofile-unique.ts (97%) rename src/{client/database => db}/migrations/003-serverconfig-access-tracking.ts (100%) rename src/{client/database => db}/migrator.ts (100%) rename src/{client/database => db}/schema.ts (100%) delete mode 100644 src/slash-commands/index.ts diff --git a/README.md b/README.md index efe6dcff..1dcd9487 100644 --- a/README.md +++ b/README.md @@ -267,31 +267,31 @@ Migrations run automatically on startup. Current migrations: ``` src/ - client/ # Discord.js client setup and models - database/ # Drizzle schema, better-sqlite3 setup, migrations - NovaClient.ts # Main client class (intents, startup) + client/ + DingirClient.ts # Main client class (intents, startup) + commands/ # Slash command definitions, organized by category + info/ + admin/ + config/groups/ # /config subcommand groups config/ env.ts # Environment loading and validation + db/ # Drizzle schema, better-sqlite3 setup, migrations events/ # Discord event handlers framework/ command.ts # Command definition and resolution event.ts # Event definition settings.ts # Reusable /config setting groups (channel, boolean) services/ # Business logic (onboarding, birthdays, audit, etc.) - slash-commands/ # Slash command definitions, organized by category - Info/ - Admin/ - Config/Subcommands/ utilities/ # Logging, formatting index.ts # Entry point ``` ### Adding a Command -Slash commands are defined in `src/slash-commands/`. Here's a minimal example: +Slash commands are defined in `src/commands/`. Here's a minimal example: ```typescript -// src/slash-commands/Info/hello.ts +// src/commands/info/hello.ts import { defineCommand } from '../../framework/command'; export default defineCommand({ @@ -315,7 +315,7 @@ Commands are auto-loaded from the directory. Set `adminOnly: true` to require Ad Use the helpers in `src/framework/settings.ts`. For a channel setting: ```typescript -// src/slash-commands/Config/config.ts (add to groups array) +// src/commands/config/config.ts (add to groups array) channelSetting({ name: 'myfeature', description: 'Set the my feature channel', @@ -325,9 +325,9 @@ channelSetting({ }) ``` -Then ensure the field is defined in `src/client/database/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. +Then ensure the field is defined in `src/db/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. -**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/client/database/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `ConfigService.updateConfig(serverId, { field: value })`. +**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/db/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `ConfigService.updateConfig(serverId, { field: value })`. ### Adding an Event @@ -349,10 +349,10 @@ Set `once: true` to only listen once (useful for `clientReady`). ### Adding a Migration -Migrations are plain synchronous functions over the better-sqlite3 handle. Create a new file in `src/client/database/migrations/` and register it in `src/client/database/migrator.ts`: +Migrations are plain synchronous functions over the better-sqlite3 handle. Create a new file in `src/db/migrations/` and register it in `src/db/migrator.ts`: ```typescript -// src/client/database/migrations/004-add-myfeature.ts +// src/db/migrations/004-add-myfeature.ts import type { Database } from 'better-sqlite3'; export function up(db: Database): void { diff --git a/src/client/NovaClient.ts b/src/client/DingirClient.ts similarity index 91% rename from src/client/NovaClient.ts rename to src/client/DingirClient.ts index b436ff43..5acee882 100644 --- a/src/client/NovaClient.ts +++ b/src/client/DingirClient.ts @@ -3,14 +3,14 @@ import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; import type { Command } from '../framework/command.ts'; import { bindEvent } from '../framework/event.ts'; import { validateRegistry } from '../framework/registry.ts'; -import { commands } from '../slash-commands/index.ts'; +import { commands } from '../commands/index.ts'; import { events } from '../events/index.ts'; import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown.ts'; import { Logger } from '../utilities/Logger.ts'; -import { getDatabase } from './database/db.ts'; -import { migrate } from './database/migrator.ts'; +import { getDatabase } from '../db/db.ts'; +import { migrate } from '../db/migrator.ts'; -class NovaClient extends Client { +class DingirClient extends Client { public slashCommands: Collection = new Collection(); private shuttingDown = false; @@ -75,4 +75,4 @@ class NovaClient extends Client { } } -export { NovaClient }; +export { DingirClient }; diff --git a/src/slash-commands/Admin/noroles.ts b/src/commands/admin/noroles.ts similarity index 100% rename from src/slash-commands/Admin/noroles.ts rename to src/commands/admin/noroles.ts diff --git a/src/slash-commands/Admin/post.ts b/src/commands/admin/post.ts similarity index 100% rename from src/slash-commands/Admin/post.ts rename to src/commands/admin/post.ts diff --git a/src/slash-commands/Admin/rolesince.ts b/src/commands/admin/rolesince.ts similarity index 100% rename from src/slash-commands/Admin/rolesince.ts rename to src/commands/admin/rolesince.ts diff --git a/src/slash-commands/Admin/simulate.ts b/src/commands/admin/simulate.ts similarity index 100% rename from src/slash-commands/Admin/simulate.ts rename to src/commands/admin/simulate.ts diff --git a/src/slash-commands/Config/config.ts b/src/commands/config/config.ts similarity index 86% rename from src/slash-commands/Config/config.ts rename to src/commands/config/config.ts index e9f69315..9df569ad 100644 --- a/src/slash-commands/Config/config.ts +++ b/src/commands/config/config.ts @@ -1,10 +1,10 @@ import { ChannelType } from 'discord.js'; import { defineCommand } from '../../framework/command.ts'; import { booleanSetting, channelSetting } from '../../framework/settings.ts'; -import BirthdaysGroup from './Subcommands/birthdays.ts'; -import DebugGroup from './Subcommands/debug.ts'; -import NewRolesGroup from './Subcommands/newroles.ts'; -import WelcomeGroup from './Subcommands/welcome.ts'; +import BirthdaysGroup from './groups/birthdays.ts'; +import DebugGroup from './groups/debug.ts'; +import NewRolesGroup from './groups/newroles.ts'; +import WelcomeGroup from './groups/welcome.ts'; const textChannels = [ChannelType.GuildText, ChannelType.GuildAnnouncement] as const; diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/commands/config/groups/birthdays.ts similarity index 100% rename from src/slash-commands/Config/Subcommands/birthdays.ts rename to src/commands/config/groups/birthdays.ts diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/commands/config/groups/debug.ts similarity index 100% rename from src/slash-commands/Config/Subcommands/debug.ts rename to src/commands/config/groups/debug.ts diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/commands/config/groups/newroles.ts similarity index 100% rename from src/slash-commands/Config/Subcommands/newroles.ts rename to src/commands/config/groups/newroles.ts diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/commands/config/groups/welcome.ts similarity index 100% rename from src/slash-commands/Config/Subcommands/welcome.ts rename to src/commands/config/groups/welcome.ts diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 00000000..da175ec8 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,13 @@ +import type { Command } from '../framework/command.ts'; +import noroles from './admin/noroles.ts'; +import post from './admin/post.ts'; +import rolesince from './admin/rolesince.ts'; +import simulate from './admin/simulate.ts'; +import config from './config/config.ts'; +import about from './info/about.ts'; +import mybirthday from './info/mybirthday.ts'; +import ping from './info/ping.ts'; +import profile from './info/profile.ts'; + +/** Every slash command the bot registers. Add new commands here. */ +export const commands: Command[] = [noroles, post, rolesince, simulate, config, about, mybirthday, ping, profile]; diff --git a/src/slash-commands/Info/about.ts b/src/commands/info/about.ts similarity index 100% rename from src/slash-commands/Info/about.ts rename to src/commands/info/about.ts diff --git a/src/slash-commands/Info/mybirthday.ts b/src/commands/info/mybirthday.ts similarity index 100% rename from src/slash-commands/Info/mybirthday.ts rename to src/commands/info/mybirthday.ts diff --git a/src/slash-commands/Info/ping.ts b/src/commands/info/ping.ts similarity index 100% rename from src/slash-commands/Info/ping.ts rename to src/commands/info/ping.ts diff --git a/src/slash-commands/Info/profile.ts b/src/commands/info/profile.ts similarity index 100% rename from src/slash-commands/Info/profile.ts rename to src/commands/info/profile.ts diff --git a/src/client/database/db.ts b/src/db/db.ts similarity index 96% rename from src/client/database/db.ts rename to src/db/db.ts index 1ea4f7b6..cc639e17 100644 --- a/src/client/database/db.ts +++ b/src/db/db.ts @@ -1,6 +1,6 @@ import Database from 'better-sqlite3'; import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import { env } from '../../config/env.ts'; +import { env } from '../config/env.ts'; import * as schema from './schema.ts'; export type AppDatabase = BetterSQLite3Database; diff --git a/src/client/database/migrations/001-baseline.ts b/src/db/migrations/001-baseline.ts similarity index 100% rename from src/client/database/migrations/001-baseline.ts rename to src/db/migrations/001-baseline.ts diff --git a/src/client/database/migrations/002-userprofile-unique.ts b/src/db/migrations/002-userprofile-unique.ts similarity index 97% rename from src/client/database/migrations/002-userprofile-unique.ts rename to src/db/migrations/002-userprofile-unique.ts index 6d80efd5..7b6370aa 100644 --- a/src/client/database/migrations/002-userprofile-unique.ts +++ b/src/db/migrations/002-userprofile-unique.ts @@ -1,5 +1,5 @@ import type { Database } from 'better-sqlite3'; -import { Logger } from '../../../utilities/Logger.ts'; +import { Logger } from '../../utilities/Logger.ts'; const INDEX_NAME = 'user_profiles_server_user_unique'; diff --git a/src/client/database/migrations/003-serverconfig-access-tracking.ts b/src/db/migrations/003-serverconfig-access-tracking.ts similarity index 100% rename from src/client/database/migrations/003-serverconfig-access-tracking.ts rename to src/db/migrations/003-serverconfig-access-tracking.ts diff --git a/src/client/database/migrator.ts b/src/db/migrator.ts similarity index 100% rename from src/client/database/migrator.ts rename to src/db/migrator.ts diff --git a/src/client/database/schema.ts b/src/db/schema.ts similarity index 100% rename from src/client/database/schema.ts rename to src/db/schema.ts diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 92eadf86..64169473 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,6 +1,6 @@ import { GuildMember, Message, PermissionFlagsBits } from 'discord.js'; -import type { NovaClient } from '../client/NovaClient.ts'; -import type { ServerConfig } from '../client/database/schema.ts'; +import type { DingirClient } from '../client/DingirClient.ts'; +import type { ServerConfig } from '../db/schema.ts'; import { defineEvent } from '../framework/event.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; import { AuditEmbed } from '../services/AuditEmbed.ts'; @@ -16,7 +16,7 @@ const formatError = (error: unknown): string => error instanceof Error ? error.message : String(error); const auditHoneypot = async ( - client: NovaClient, + client: DingirClient, config: ServerConfig, member: GuildMember, channelId: string, @@ -32,7 +32,7 @@ const auditHoneypot = async ( /** Returns true when the message was posted in the honeypot channel and has been dealt with. */ export const handleHoneypot = async ( - client: NovaClient, + client: DingirClient, message: Message, config: ServerConfig ): Promise => { diff --git a/src/framework/command.ts b/src/framework/command.ts index e14dde44..d0279a3c 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -19,7 +19,7 @@ import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, } from 'discord.js'; -import type { ServerConfig } from '../client/database/schema.ts'; +import type { ServerConfig } from '../db/schema.ts'; export type DeferMode = 'ephemeral' | 'public' | false; diff --git a/src/framework/event.ts b/src/framework/event.ts index b1536217..6d2359e4 100644 --- a/src/framework/event.ts +++ b/src/framework/event.ts @@ -1,11 +1,11 @@ import { Client, type ClientEvents } from 'discord.js'; -import type { NovaClient } from '../client/NovaClient.ts'; +import type { DingirClient } from '../client/DingirClient.ts'; import { Logger } from '../utilities/Logger.ts'; export interface EventDefinition { name: K; once?: boolean; - run(client: NovaClient, ...args: ClientEvents[K]): Promise; + run(client: DingirClient, ...args: ClientEvents[K]): Promise; } export type AnyEventDefinition = { [K in keyof ClientEvents]: EventDefinition }[keyof ClientEvents]; @@ -27,7 +27,7 @@ const guildIdOf = (arg: unknown): string | undefined => { /** Registers an event on the client, logging (never throwing) handler failures. */ export function bindEvent( - client: NovaClient, + client: DingirClient, def: EventDefinition ): void { const listener = (...args: ClientEvents[K]): void => { diff --git a/src/index.ts b/src/index.ts index 0b651c6c..4564e9d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,9 +10,9 @@ async function main(): Promise { process.exit(1); } - const { NovaClient } = await import('./client/NovaClient.ts'); - const novaClient = new NovaClient(); - await novaClient.start(); + const { DingirClient } = await import('./client/DingirClient.ts'); + const dingirClient = new DingirClient(); + await dingirClient.start(); } main().catch((error: unknown) => { diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index 67027c40..85286a09 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -1,5 +1,5 @@ import { Attachment, Client, EmbedBuilder } from 'discord.js'; -import type { ServerConfig } from '../client/database/schema.ts'; +import type { ServerConfig } from '../db/schema.ts'; import { Logger } from '../utilities/Logger.ts'; export const sendAudit = async ( diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index bf0ed1c6..db30a461 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,6 +1,6 @@ import { Client } from 'discord.js'; import { DateTime } from 'luxon'; -import type { ServerConfig } from '../client/database/schema.ts'; +import type { ServerConfig } from '../db/schema.ts'; import { env } from '../config/env.ts'; import { ConfigService } from '../services/ConfigService.ts'; import { Logger } from '../utilities/Logger.ts'; diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 530c1924..125643cc 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; -import { getDb, type AppDatabase } from '../client/database/db.ts'; -import { serverConfigs, userProfiles, type ServerConfig } from '../client/database/schema.ts'; +import { getDb, type AppDatabase } from '../db/db.ts'; +import { serverConfigs, userProfiles, type ServerConfig } from '../db/schema.ts'; export type ServerConfigPatch = Partial>; diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 16ad34ed..96e6545e 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,5 +1,5 @@ import { AttachmentBuilder, Client, Guild, GuildMember, type PartialGuildMember, Role } from 'discord.js'; -import type { ServerConfig } from '../client/database/schema.ts'; +import type { ServerConfig } from '../db/schema.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; import { Logger } from '../utilities/Logger.ts'; import { AuditEmbed } from './AuditEmbed.ts'; diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index b7352c66..8e6b6118 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,6 +1,6 @@ import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; -import { getDb } from '../client/database/db.ts'; -import { userProfiles, type UserProfile } from '../client/database/schema.ts'; +import { getDb } from '../db/db.ts'; +import { userProfiles, type UserProfile } from '../db/schema.ts'; export class UserProfileService { public static async getServerBirthdays(serverId: string): Promise { diff --git a/src/slash-commands/index.ts b/src/slash-commands/index.ts deleted file mode 100644 index 9e47b47a..00000000 --- a/src/slash-commands/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Command } from '../framework/command.ts'; -import noroles from './Admin/noroles.ts'; -import post from './Admin/post.ts'; -import rolesince from './Admin/rolesince.ts'; -import simulate from './Admin/simulate.ts'; -import config from './Config/config.ts'; -import about from './Info/about.ts'; -import mybirthday from './Info/mybirthday.ts'; -import ping from './Info/ping.ts'; -import profile from './Info/profile.ts'; - -/** Every slash command the bot registers. Add new commands here. */ -export const commands: Command[] = [noroles, post, rolesince, simulate, config, about, mybirthday, ping, profile]; diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index 1265bf9c..221c9088 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { DateTime } from 'luxon'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; -import type { ServerConfig } from '../../src/client/database/schema.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService.ts'; import { apiError, diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 111a5ff9..4b4826a6 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; import { initEnv } from '../../src/config/env.ts'; import { type CommandContext, createReply } from '../../src/framework/command.ts'; -import mybirthday from '../../src/slash-commands/Info/mybirthday.ts'; -import profile from '../../src/slash-commands/Info/profile.ts'; +import mybirthday from '../../src/commands/info/mybirthday.ts'; +import profile from '../../src/commands/info/profile.ts'; import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index 53db5ed8..cfbc2725 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import Database from 'better-sqlite3'; -import { migrate, migrations, type Migration } from '../../src/client/database/migrator.ts'; +import { migrate, migrations, type Migration } from '../../src/db/migrator.ts'; import { Logger } from '../../src/utilities/Logger.ts'; type Db = Database.Database; diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index 660f9941..d96e33c9 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { count, eq } from 'drizzle-orm'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; -import { serverConfigs, userProfiles } from '../../src/client/database/schema.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; +import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; import { ConfigService } from '../../src/services/ConfigService.ts'; import { closeTestDb, createTestDb } from '../helpers/db.ts'; diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts index fa495645..9d15f22f 100644 --- a/tests/database/schema.test.ts +++ b/tests/database/schema.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { getTableColumns, getTableName } from 'drizzle-orm'; import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; -import { serverConfigs, userProfiles } from '../../src/client/database/schema.ts'; +import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; import { closeTestDb, createTestDb } from '../helpers/db.ts'; const columnNames = (table: SQLiteTable): string[] => diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index 6673ea0d..0a6521d4 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; import { ConfigService } from '../../src/services/ConfigService.ts'; import { UserProfileService } from '../../src/services/UserProfileService.ts'; import { diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index a307c542..3f5b5c54 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; -import type { ServerConfig } from '../../src/client/database/schema.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; import { run } from '../../src/services/DataCheckService.ts'; import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; import { clearConfigs, clearProfiles, closeTestDb, allProfiles, countProfiles, createConfig, createConfigs, createProfiles, createTestDb, findConfig } from '../helpers/db.ts'; diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index ead390b6..9e2a1f29 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; -import type { DatabaseHandle } from '../src/client/database/db.ts'; +import type { DatabaseHandle } from '../src/db/db.ts'; import { closeTestDb, createTestDb } from './helpers/db.ts'; -import type { NovaClient } from '../src/client/NovaClient.ts'; +import type { DingirClient } from '../src/client/DingirClient.ts'; import { type Command, defineCommand } from '../src/framework/command.ts'; import interactionCreate from '../src/events/interactionCreate.ts'; import { fakeInteraction } from './fakes/interaction.ts'; @@ -36,9 +36,9 @@ afterEach(() => { const clientWith = (...commands: Command[]) => ({ slashCommands: new Collection(commands.map((c) => [c.name, c])), - }) as unknown as NovaClient; + }) as unknown as DingirClient; -const dispatch = (client: NovaClient, interaction: unknown) => +const dispatch = (client: DingirClient, interaction: unknown) => interactionCreate.run(client, interaction as never); test('ignores interactions that are not chat input commands', async () => { diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts index eca6f993..a66b4de5 100644 --- a/tests/fakes/messages.ts +++ b/tests/fakes/messages.ts @@ -1,11 +1,11 @@ import { Collection, User } from 'discord.js'; -import type { NovaClient } from '../../src/client/NovaClient.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; import type { Command } from '../../src/framework/command.ts'; import interactionCreate from '../../src/events/interactionCreate.ts'; import type { FakeInteraction } from './interaction.ts'; export interface AuditSink { - client: NovaClient; + client: DingirClient; sent: any[]; } @@ -18,7 +18,7 @@ export function fakeAuditClient(channelId = 'audit-1', commands: Command[] = []) const client = { channels: { fetch: async (id: string) => (id === channelId ? channel : null) }, slashCommands: new Collection(commands.map((c) => [c.name, c])), - } as unknown as NovaClient; + } as unknown as DingirClient; return { client, sent }; } @@ -76,7 +76,7 @@ export function fakeMessage(overrides: Record = {}) { } export async function runSlash( - client: NovaClient, + client: DingirClient, fake: FakeInteraction, extra: Record = {} ): Promise { diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index cecf76c5..50b5fc04 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,5 +1,5 @@ import type { Client, GuildMember } from 'discord.js'; -import type { ServerConfig } from '../../src/client/database/schema.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; import type { CommandContext } from '../../src/framework/command.ts'; import { fakeInteraction } from './interaction.ts'; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index e968fe8e..90449a42 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,12 +1,12 @@ import { eq } from 'drizzle-orm'; -import { createDatabase, getDb, setDatabase, type DatabaseHandle } from '../../src/client/database/db.ts'; -import { migrate } from '../../src/client/database/migrator.ts'; +import { createDatabase, getDb, setDatabase, type DatabaseHandle } from '../../src/db/db.ts'; +import { migrate } from '../../src/db/migrator.ts'; import { serverConfigs, userProfiles, type ServerConfig, type UserProfile, -} from '../../src/client/database/schema.ts'; +} from '../../src/db/schema.ts'; /** An in-memory, fully migrated database that the services use until it is closed. */ export function createTestDb(): DatabaseHandle { diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index 72f01fca..36d0d429 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; import messageCreate from '../../src/events/messageCreate.ts'; import messageDelete from '../../src/events/messageDelete.ts'; diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 873d1bc6..29b136a2 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { after, before, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; -import noroles from '../../src/slash-commands/Admin/noroles.ts'; -import rolesince from '../../src/slash-commands/Admin/rolesince.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; +import noroles from '../../src/commands/admin/noroles.ts'; +import rolesince from '../../src/commands/admin/rolesince.ts'; import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing.ts'; import { closeTestDb, createTestDb } from '../helpers/db.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index ade82432..a4c838d3 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; import { Collection, PermissionFlagsBits } from 'discord.js'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; -import post from '../../src/slash-commands/Admin/post.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; +import post from '../../src/commands/admin/post.ts'; import { ConfigService } from '../../src/services/ConfigService.ts'; import { closeTestDb, createTestDb } from '../helpers/db.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 50e5aba6..c12a2dad 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; import type { Handler } from '../../src/framework/command.ts'; -import NewRolesGroup from '../../src/slash-commands/Config/Subcommands/newroles.ts'; +import NewRolesGroup from '../../src/commands/config/groups/newroles.ts'; import WelcomeGroup, { MAX_WELCOME_MESSAGE_LENGTH, validateImageUrl, validateWelcomeMessage, -} from '../../src/slash-commands/Config/Subcommands/welcome.ts'; +} from '../../src/commands/config/groups/welcome.ts'; import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db.ts'; diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 27455289..9ec30eb7 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/client/database/db.ts'; +import type { DatabaseHandle } from '../../src/db/db.ts'; import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db.ts'; diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 243aa566..109b09e3 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { events } from '../src/events/index.ts'; import { validateRegistry } from '../src/framework/registry.ts'; -import { commands } from '../src/slash-commands/index.ts'; +import { commands } from '../src/commands/index.ts'; test('validateRegistry accepts unique names', () => { assert.doesNotThrow(() => validateRegistry('command', [{ name: 'a' }, { name: 'b' }])); diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index 7de7210c..c651929a 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { Client, EmbedBuilder } from 'discord.js'; -import type { ServerConfig } from '../../src/client/database/schema.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; import { sendAudit } from '../../src/services/AuditService.ts'; console.warn = (): void => undefined; diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 45d9efaf..fa09f33e 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { after, afterEach, before, beforeEach, test } from 'node:test'; import { ChannelType } from 'discord.js'; -import type { DatabaseHandle } from '../src/client/database/db.ts'; +import type { DatabaseHandle } from '../src/db/db.ts'; import { closeTestDb, clearConfigs, createTestDb } from './helpers/db.ts'; -import type { ServerConfig } from '../src/client/database/schema.ts'; +import type { ServerConfig } from '../src/db/schema.ts'; import { ConfigService } from '../src/services/ConfigService.ts'; import { type CommandContext, defineCommand, type Handler } from '../src/framework/command.ts'; import { From 0cc6f43c57efed59521160461fd6b6b438b96cac Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:14:50 +0100 Subject: [PATCH 21/77] Introduce App composition root and pass dependencies explicitly Replace the env Proxy, lazy database singleton, honeypot static map and module-level shutdown hooks with an App built once in index.ts. Handlers receive it as run(app, client, ...) for events and ctx.app for commands; services become plain functions taking the db or app. The clock is injected where it decides behaviour. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 11 +- src/app.ts | 43 +++++ src/client/DingirClient.ts | 34 ++-- src/commands/admin/post.ts | 13 +- src/commands/admin/rolesince.ts | 3 +- src/commands/admin/simulate.ts | 6 +- src/commands/config/groups/birthdays.ts | 10 +- src/commands/config/groups/newroles.ts | 12 +- src/commands/config/groups/welcome.ts | 14 +- src/commands/info/mybirthday.ts | 22 +-- src/commands/info/ping.ts | 2 +- src/commands/info/profile.ts | 4 +- src/config/env.ts | 19 -- src/db/db.ts | 35 +--- src/db/migrations/002-userprofile-unique.ts | 6 +- src/db/migrator.ts | 7 +- src/events/guildCreate.ts | 11 +- src/events/guildDelete.ts | 9 +- src/events/guildMemberAdd.ts | 10 +- src/events/guildMemberRemove.ts | 19 +- src/events/guildMemberUpdate.ts | 11 +- src/events/interactionCreate.ts | 29 ++- src/events/messageCreate.ts | 50 ++--- src/events/messageDelete.ts | 17 +- src/events/messageUpdate.ts | 12 +- src/events/ready.ts | 35 ++-- src/framework/CommandRegistrar.ts | 24 --- src/framework/command.ts | 8 +- src/framework/event.ts | 9 +- src/framework/registrar.ts | 22 +++ src/framework/settings.ts | 8 +- src/framework/shutdown.ts | 37 ++-- src/index.ts | 22 ++- src/services/AuditEmbed.ts | 30 ++- src/services/AuditService.ts | 7 +- src/services/BirthdayService.ts | 83 ++++----- src/services/ConfigService.ts | 86 +++++---- src/services/DataCheckService.ts | 50 +++-- src/services/HoneyPotEnforcementService.ts | 37 ---- src/services/HoneypotTracker.ts | 39 ++++ src/services/MemberListing.ts | 2 +- src/services/MemberResolver.ts | 6 +- src/services/OnboardingService.ts | 85 ++++++--- src/services/Scheduler.ts | 11 +- src/services/UserProfileService.ts | 197 ++++++++++---------- src/services/WelcomeImage.ts | 7 +- src/utilities/Logger.ts | 55 ++---- tests/app.test.ts | 55 ++++++ tests/birthdays/BirthdayService.test.ts | 47 +++-- tests/birthdays/commands.test.ts | 37 +--- tests/database/migrations.test.ts | 34 ++-- tests/database/purgeGuild.test.ts | 37 ++-- tests/database/schema.test.ts | 16 +- tests/database/services.test.ts | 59 +++--- tests/datacheck/DataCheckService.test.ts | 28 ++- tests/dispatcher.test.ts | 37 +--- tests/fakes/messages.ts | 4 +- tests/fakes/onboarding.ts | 3 + tests/helpers/app.ts | 66 +++++++ tests/helpers/db.ts | 73 +++----- tests/logger.test.ts | 21 +-- tests/moderation/events.test.ts | 82 ++++---- tests/moderation/memberListing.test.ts | 22 +-- tests/moderation/post.test.ts | 33 +--- tests/onboarding/OnboardingService.test.ts | 33 ++-- tests/onboarding/commands.test.ts | 41 ++-- tests/onboarding/events.test.ts | 23 +-- tests/services/AuditEmbed.test.ts | 6 +- tests/services/AuditService.test.ts | 15 +- tests/services/Scheduler.test.ts | 25 ++- tests/settings.test.ts | 54 ++---- 71 files changed, 1074 insertions(+), 1046 deletions(-) create mode 100644 src/app.ts delete mode 100644 src/framework/CommandRegistrar.ts create mode 100644 src/framework/registrar.ts delete mode 100644 src/services/HoneyPotEnforcementService.ts create mode 100644 src/services/HoneypotTracker.ts create mode 100644 tests/app.test.ts create mode 100644 tests/helpers/app.ts diff --git a/README.md b/README.md index 1dcd9487..8f46d728 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ src/ info/ admin/ config/groups/ # /config subcommand groups + app.ts # Composition root: the App (env, db, logger, clock, ...) config/ env.ts # Environment loading and validation db/ # Drizzle schema, better-sqlite3 setup, migrations @@ -308,7 +309,7 @@ export default defineCommand({ }); ``` -Commands are auto-loaded from the directory. Set `adminOnly: true` to require Administrator permission. Use `defer: 'ephemeral'` for private replies. +Command handlers receive the shared `App` as `ctx.app` (database, logger, clock, env). Register new commands in `src/commands/index.ts`. Set `adminOnly: true` to require Administrator permission. Use `defer: 'ephemeral'` for private replies. ### Adding a Config Setting @@ -327,7 +328,7 @@ channelSetting({ Then ensure the field is defined in `src/db/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. -**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/db/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `ConfigService.updateConfig(serverId, { field: value })`. +**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/db/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `updateConfig(ctx.app.db, serverId, { field: value })`. ### Adding an Event @@ -339,13 +340,13 @@ import { defineEvent } from '../framework/event'; export default defineEvent({ name: 'messageCreate', // discord.js event name - run: async (client, message) => { - // Handle the event + run: async (app, client, message) => { + // `app` carries env, db, logger, clock, honeypot and shutdown; see src/app.ts }, }); ``` -Set `once: true` to only listen once (useful for `clientReady`). +Register new events in `src/events/index.ts`. Set `once: true` to only listen once (useful for `clientReady`). ### Adding a Migration diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..8ec7efdc --- /dev/null +++ b/src/app.ts @@ -0,0 +1,43 @@ +import { loadEnv, type Env } from './config/env.ts'; +import { openDb, type Db } from './db/db.ts'; +import { migrate } from './db/migrator.ts'; +import { createShutdownRegistry, type ShutdownRegistry } from './framework/shutdown.ts'; +import { createHoneypotTracker, type HoneypotTracker } from './services/HoneypotTracker.ts'; +import { createConsoleLogger, type Logger } from './utilities/Logger.ts'; + +/** Everything the bot's handlers and services depend on, built once at startup. */ +export interface App { + env: Readonly; + db: Db; + logger: Logger; + clock: () => Date; + honeypot: HoneypotTracker; + shutdown: ShutdownRegistry; +} + +/** Builds the app, opening and migrating the database unless one is supplied. */ +export function createApp(overrides: Partial = {}): App { + const env = overrides.env ?? loadEnv(); + const logger = overrides.logger ?? createConsoleLogger(); + const shutdown = overrides.shutdown ?? createShutdownRegistry(logger); + + let db = overrides.db; + if (!db) { + const opened = openDb(env.dbPath); + const applied = migrate(opened.$client, logger); + logger.info('Database migrations complete', { applied: applied.length ? applied : 'none' }); + shutdown.register(() => { + opened.$client.close(); + }); + db = opened; + } + + return { + env, + db, + logger, + clock: overrides.clock ?? (() => new Date()), + honeypot: overrides.honeypot ?? createHoneypotTracker(), + shutdown, + }; +} diff --git a/src/client/DingirClient.ts b/src/client/DingirClient.ts index 5acee882..e9fba1bb 100644 --- a/src/client/DingirClient.ts +++ b/src/client/DingirClient.ts @@ -1,20 +1,17 @@ -import { env } from '../config/env.ts'; import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; +import type { App } from '../app.ts'; import type { Command } from '../framework/command.ts'; import { bindEvent } from '../framework/event.ts'; import { validateRegistry } from '../framework/registry.ts'; import { commands } from '../commands/index.ts'; import { events } from '../events/index.ts'; -import { registerShutdownHook, runShutdownHooks } from '../framework/shutdown.ts'; -import { Logger } from '../utilities/Logger.ts'; -import { getDatabase } from '../db/db.ts'; -import { migrate } from '../db/migrator.ts'; class DingirClient extends Client { public slashCommands: Collection = new Collection(); + private readonly app: App; private shuttingDown = false; - public constructor() { + public constructor(app: App) { super({ partials: [Partials.Message, Partials.Channel, Partials.GuildMember], intents: [ @@ -24,15 +21,11 @@ class DingirClient extends Client { GatewayIntentBits.MessageContent, ], }); + this.app = app; } public async start(): Promise { - const { sqlite } = getDatabase(); - const applied = migrate(sqlite); - Logger.info('Database migrations complete', { applied: applied.length ? applied : 'none' }); - registerShutdownHook(() => { - sqlite.close(); - }); + const { env, logger } = this.app; validateRegistry('command', commands); validateRegistry('event', events); @@ -41,13 +34,13 @@ class DingirClient extends Client { this.slashCommands.set(command.name, command); } for (const event of events) { - bindEvent(this, event); + bindEvent(this.app, this, event); } - this.on('error', (error) => Logger.error('Discord client error', undefined, error)); - this.on('warn', (message) => Logger.warn(message)); + this.on('error', (error) => logger.error('Discord client error', undefined, error)); + this.on('warn', (message) => logger.warn(message)); process.on('unhandledRejection', (reason) => - Logger.error('Unhandled promise rejection', undefined, reason) + logger.error('Unhandled promise rejection', undefined, reason) ); for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.on(signal, () => { @@ -56,21 +49,22 @@ class DingirClient extends Client { } await this.login(env.token); - Logger.info('Logged in'); + logger.info('Logged in'); } private async shutdown(signal: string): Promise { + const { logger } = this.app; if (this.shuttingDown) { return; } this.shuttingDown = true; - Logger.info(`${signal} received, shutting down`); + logger.info(`${signal} received, shutting down`); try { await this.destroy(); } catch (error) { - Logger.error('Error destroying client', undefined, error); + logger.error('Error destroying client', undefined, error); } - await runShutdownHooks(); + await this.app.shutdown.run(); process.exit(0); } } diff --git a/src/commands/admin/post.ts b/src/commands/admin/post.ts index 6e89104e..c73206a4 100644 --- a/src/commands/admin/post.ts +++ b/src/commands/admin/post.ts @@ -1,10 +1,9 @@ import { ChannelType, Message, PermissionFlagsBits } from 'discord.js'; import { defineCommand } from '../../framework/command.ts'; import { EmbedColours } from '../../resources/EmbedColours.ts'; -import { AuditEmbed } from '../../services/AuditEmbed.ts'; +import { memberAuditEmbed } from '../../services/AuditEmbed.ts'; import { sendAudit } from '../../services/AuditService.ts'; import { resolveTextChannel } from '../../services/MemberResolver.ts'; -import { Logger } from '../../utilities/Logger.ts'; export default defineCommand({ name: 'post', @@ -65,17 +64,21 @@ export default defineCommand({ allowedMentions: { parse: ['users', 'roles'] }, }); } catch (error) { - Logger.warn('Failed to send /post message', { guildId: ctx.guild.id, channelId: channel.id }, error); + ctx.app.logger.warn( + 'Failed to send /post message', + { guildId: ctx.guild.id, channelId: channel.id }, + error + ); await ctx.reply('An error was encountered sending this message.'); return; } - const audit = AuditEmbed.forMember(ctx.member, EmbedColours.neutral, 'Post created via Dingir') + const audit = memberAuditEmbed(ctx.member, EmbedColours.neutral, 'Post created via Dingir') .addField('Channel', channel.toString()) .addField('Content', content ?? 'No text') .addField('Attachment', attachment ? attachment.name : 'No') .addField('Message', sent.url); - await sendAudit(ctx.interaction.client, ctx.config, audit); + await sendAudit(ctx.app, ctx.interaction.client, ctx.config, audit); await ctx.reply(`Posted in ${channel.toString()}: ${sent.url}`); }, diff --git a/src/commands/admin/rolesince.ts b/src/commands/admin/rolesince.ts index 1eabb487..30da6aeb 100644 --- a/src/commands/admin/rolesince.ts +++ b/src/commands/admin/rolesince.ts @@ -21,12 +21,13 @@ export default defineCommand({ const role = ctx.interaction.options.getRole('role', true); const days = ctx.interaction.options.getInteger('days') ?? 0; + const now = ctx.app.clock().getTime(); await ctx.guild.members.fetch(); const members = [...role.members.values()].filter((member) => { if (member.user.bot) { return false; } - const joinedDays = wholeDaysSinceJoin(member); + const joinedDays = wholeDaysSinceJoin(member, now); return joinedDays === null ? days === 0 : joinedDays >= days; }); diff --git a/src/commands/admin/simulate.ts b/src/commands/admin/simulate.ts index f3414db0..3a48ec70 100644 --- a/src/commands/admin/simulate.ts +++ b/src/commands/admin/simulate.ts @@ -35,7 +35,7 @@ export default defineCommand({ if (!target) { return; } - const sent = await auditJoin(ctx.interaction.client, target, ctx.config); + const sent = await auditJoin(ctx.app, ctx.interaction.client, target, ctx.config); await ctx.reply({ content: sent ? `Sent the "member joined" audit for ${target.toString()}. No roles or welcome message were applied.` @@ -54,7 +54,9 @@ export default defineCommand({ if (!target) { return; } - const result = await complete(ctx.interaction.client, target, ctx.config, { dryRun: true }); + const result = await complete(ctx.app, ctx.interaction.client, target, ctx.config, { + dryRun: true, + }); const payload = result.welcomePayload; const welcome = payload ? `\n\nWelcome preview:\n${payload.content ?? '*(no text)*'}${ diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index eaea07de..4a127272 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -5,7 +5,7 @@ import { deleteCalendarMessage, refreshCalendar, } from '../../../services/BirthdayService.ts'; -import { ConfigService } from '../../../services/ConfigService.ts'; +import { updateConfig } from '../../../services/ConfigService.ts'; import { resolveTextChannel } from '../../../services/MemberResolver.ts'; export const BirthdaysGroup = defineSubcommandGroup({ @@ -49,11 +49,11 @@ export const BirthdaysGroup = defineSubcommandGroup({ allowedMentions: { parse: [] }, }); const path = `${message.channelId}/${message.id}`; - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { birthdayCalendarMessagePath: path, }); - const status = await refreshCalendar(client, ctx.config); + const status = await refreshCalendar(ctx.app, client, ctx.config); const link = calendarMessageUrl(ctx.guild.id, path); await ctx.reply( status === 'updated' @@ -67,7 +67,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ description: 'Sync the birthday calendar for this server', defer: 'ephemeral', run: async (ctx) => { - const status = await refreshCalendar(ctx.interaction.client, ctx.config); + const status = await refreshCalendar(ctx.app, ctx.interaction.client, ctx.config); const replies = { updated: `Calendar successfully synchronised for ${ctx.guild.name}.`, 'not-configured': @@ -91,7 +91,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ return; } await deleteCalendarMessage(ctx.interaction.client, ctx.config.birthdayCalendarMessagePath); - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { birthdayCalendarMessagePath: null, }); await ctx.reply('Birthday calendar removed.'); diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts index 5720a633..5cc0647a 100644 --- a/src/commands/config/groups/newroles.ts +++ b/src/commands/config/groups/newroles.ts @@ -1,20 +1,20 @@ import { Role } from 'discord.js'; import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; import { EmbedColours } from '../../../resources/EmbedColours.ts'; -import { AuditEmbed } from '../../../services/AuditEmbed.ts'; +import { memberAuditEmbed } from '../../../services/AuditEmbed.ts'; import { sendAudit } from '../../../services/AuditService.ts'; -import { ConfigService } from '../../../services/ConfigService.ts'; +import { updateConfig } from '../../../services/ConfigService.ts'; import { parseRoleIds, roleProblem } from '../../../services/OnboardingService.ts'; const ROLE_OPTIONS = ['role-one', 'role-two', 'role-three'] as const; const NO_MENTIONS = { parse: [] }; const auditChange = (ctx: CommandContext, description: string, roleIds: string[]) => { - const embed = AuditEmbed.forMember(ctx.member, EmbedColours.info, description); + const embed = memberAuditEmbed(ctx.member, EmbedColours.info, description); if (roleIds.length > 0) { embed.addField('New member roles', roleIds.map((id) => `<@&${id}>`).join('\n')); } - return sendAudit(ctx.interaction.client, ctx.config, embed); + return sendAudit(ctx.app, ctx.interaction.client, ctx.config, embed); }; const get = async (ctx: CommandContext) => { @@ -80,7 +80,7 @@ export const NewRolesGroup = defineSubcommandGroup({ } const ids = [...roles.keys()]; - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { guestRoleIds: ids.join(','), }); await auditChange(ctx, 'New member roles updated', ids); @@ -94,7 +94,7 @@ export const NewRolesGroup = defineSubcommandGroup({ name: 'clear', description: 'Clear the roles given to members when they complete onboarding', run: async (ctx) => { - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { guestRoleIds: null }); + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { guestRoleIds: null }); await auditChange(ctx, 'New member roles cleared', []); await ctx.reply('New-member roles cleared.'); }, diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index f848f6e9..44a67e2a 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -1,7 +1,7 @@ import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; -import { ConfigService, type ServerConfigPatch } from '../../../services/ConfigService.ts'; +import { type ServerConfigPatch, updateConfig } from '../../../services/ConfigService.ts'; import { complete } from '../../../services/OnboardingService.ts'; -import { isHttpUrl, WelcomeImage } from '../../../services/WelcomeImage.ts'; +import { isHttpUrl, render } from '../../../services/WelcomeImage.ts'; export const MAX_WELCOME_MESSAGE_LENGTH = 1500; @@ -24,7 +24,7 @@ const setMessage = async (ctx: CommandContext) => { await ctx.reply(problem); return; } - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { welcomeMessage: text }); + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { welcomeMessage: text }); await ctx.reply({ content: 'Welcome message saved. Use `{member}` in the text to mention the new member.', allowedMentions: NO_MENTIONS, @@ -39,14 +39,14 @@ const setImage = async (ctx: CommandContext) => { return; } try { - await WelcomeImage.render(ctx.member, url); + await render(ctx.app, ctx.member, url); } catch (error) { await ctx.reply( `That image could not be used: ${error instanceof Error ? error.message : String(error)}` ); return; } - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { welcomeMessageBackgroundUrl: url, }); await ctx.reply('Welcome image saved. Use /config welcome preview to see how it looks.'); @@ -72,12 +72,12 @@ const clear = async (ctx: CommandContext) => { if (which === 'image' || which === 'all') { patch.welcomeMessageBackgroundUrl = null; } - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, patch); + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, patch); await ctx.reply(`Cleared welcome ${which === 'all' ? 'message and image' : which}.`); }; const preview = async (ctx: CommandContext) => { - const result = await complete(ctx.interaction.client, ctx.member, ctx.config, { dryRun: true }); + const result = await complete(ctx.app, ctx.interaction.client, ctx.member, ctx.config, { dryRun: true }); const payload = result.welcomePayload; if (!payload) { await ctx.reply(`Nothing would be sent: ${result.welcome.replace(/^(skipped|failed):/, '')}.`); diff --git a/src/commands/info/mybirthday.ts b/src/commands/info/mybirthday.ts index 2ff23596..780fc3e4 100644 --- a/src/commands/info/mybirthday.ts +++ b/src/commands/info/mybirthday.ts @@ -1,19 +1,17 @@ import { DateTime } from 'luxon'; -import { env } from '../../config/env.ts'; import { type CommandContext, defineCommand } from '../../framework/command.ts'; import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates.ts'; import { refreshCalendar } from '../../services/BirthdayService.ts'; -import { Logger } from '../../utilities/Logger.ts'; -import { UserProfileService } from '../../services/UserProfileService.ts'; +import { clearBirthday, setBirthday } from '../../services/UserProfileService.ts'; const refresh = async (ctx: CommandContext): Promise => { try { - const status = await refreshCalendar(ctx.interaction.client, ctx.config); + const status = await refreshCalendar(ctx.app, ctx.interaction.client, ctx.config); if (status !== 'updated' && status !== 'not-configured') { - Logger.warn('Birthday calendar not refreshed', { guild: ctx.guild.id, status }); + ctx.app.logger.warn('Birthday calendar not refreshed', { guild: ctx.guild.id, status }); } } catch (error) { - Logger.error('Birthday calendar refresh failed', { guild: ctx.guild.id }, error); + ctx.app.logger.error('Birthday calendar refresh failed', { guild: ctx.guild.id }, error); } }; @@ -51,11 +49,12 @@ export default defineCommand({ return; } - await UserProfileService.setBirthday(ctx.guild.id, ctx.interaction.user.id, month, day); + await setBirthday(ctx.app.db, ctx.guild.id, ctx.interaction.user.id, month, day); - const now = DateTime.now(); - const next = nextOccurrence(month, day, now, env.timezone); - const isToday = next.hasSame(now.setZone(env.timezone), 'day'); + const zone = ctx.app.env.timezone; + const now = DateTime.fromJSDate(ctx.app.clock()); + const next = nextOccurrence(month, day, now, zone); + const isToday = next.hasSame(now.setZone(zone), 'day'); const text = isToday ? 'today 🎉' : next.setLocale('en-GB').toLocaleString(DateTime.DATE_FULL); @@ -67,7 +66,8 @@ export default defineCommand({ name: 'clear', description: 'Remove your birthday from this server', run: async (ctx) => { - const cleared = await UserProfileService.clearBirthday( + const cleared = await clearBirthday( + ctx.app.db, ctx.guild.id, ctx.interaction.user.id ); diff --git a/src/commands/info/ping.ts b/src/commands/info/ping.ts index 13a51f0c..20f4337e 100644 --- a/src/commands/info/ping.ts +++ b/src/commands/info/ping.ts @@ -5,7 +5,7 @@ export default defineCommand({ description: 'Ping Dingir', defer: 'ephemeral', run: async (ctx) => { - const roundTrip = Date.now() - ctx.interaction.createdTimestamp; + const roundTrip = ctx.app.clock().getTime() - ctx.interaction.createdTimestamp; const websocket = Math.round(ctx.interaction.client.ws.ping); await ctx.reply(`Pong! WebSocket: ${websocket}ms, round-trip: ${roundTrip}ms`); }, diff --git a/src/commands/info/profile.ts b/src/commands/info/profile.ts index cf864dc6..553491f0 100644 --- a/src/commands/info/profile.ts +++ b/src/commands/info/profile.ts @@ -3,7 +3,7 @@ import { DateTime } from 'luxon'; import { defineCommand } from '../../framework/command.ts'; import { EmbedColours } from '../../resources/EmbedColours.ts'; import { resolveMember } from '../../services/MemberResolver.ts'; -import { UserProfileService } from '../../services/UserProfileService.ts'; +import { findUserProfile } from '../../services/UserProfileService.ts'; export default defineCommand({ name: 'profile', @@ -21,7 +21,7 @@ export default defineCommand({ return; } - const profile = await UserProfileService.findUserProfile(ctx.guild.id, member.id); + const profile = await findUserProfile(ctx.app.db, ctx.guild.id, member.id); const birthday = profile?.birthdayMonth && profile.birthdayDay ? DateTime.utc(2024, profile.birthdayMonth, profile.birthdayDay) diff --git a/src/config/env.ts b/src/config/env.ts index e888e9fe..5351c78d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { IANAZone } from 'luxon'; export interface Env { @@ -97,20 +95,3 @@ export function loadEnv(source: Source = process.env): Readonly { devGuildId, }); } - -let cached: Readonly | undefined; - -/** Loads and validates process.env, ensures the DB directory exists, and caches the result. */ -export function initEnv(source: Source = process.env): Readonly { - const loaded = loadEnv(source); - if (loaded.dbPath !== ':memory:') { - fs.mkdirSync(path.dirname(loaded.dbPath), { recursive: true }); - } - cached = loaded; - return loaded; -} - -/** Typed accessor for the validated environment; initialises lazily from process.env. */ -export const env: Readonly = new Proxy({} as Env, { - get: (_target, prop) => (cached ?? initEnv())[prop as keyof Env], -}); diff --git a/src/db/db.ts b/src/db/db.ts index cc639e17..a65e9879 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,34 +1,19 @@ +import fs from 'node:fs'; +import path from 'node:path'; import Database from 'better-sqlite3'; import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import { env } from '../config/env.ts'; import * as schema from './schema.ts'; -export type AppDatabase = BetterSQLite3Database; +export type Db = BetterSQLite3Database & { $client: Database.Database }; -export interface DatabaseHandle { - sqlite: Database.Database; - db: AppDatabase; -} - -export function createDatabase(path: string): DatabaseHandle { - const sqlite = new Database(path); +/** Opens (creating the directory if needed) a SQLite database with the pragmas the bot relies on. */ +export function openDb(file: string): Db { + if (file !== ':memory:') { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } + const sqlite = new Database(file); sqlite.pragma('journal_mode = WAL'); sqlite.pragma('foreign_keys = ON'); sqlite.pragma('busy_timeout = 5000'); - return { sqlite, db: drizzle(sqlite, { schema }) }; -} - -let current: DatabaseHandle | undefined; - -/** The shared database; opened lazily from env.dbPath unless a test installed one. */ -export function getDatabase(): DatabaseHandle { - current ??= createDatabase(env.dbPath); - return current; -} - -export const getDb = (): AppDatabase => getDatabase().db; - -/** Test seam: point the services at another database. */ -export function setDatabase(handle: DatabaseHandle | undefined): void { - current = handle; + return drizzle(sqlite, { schema }); } diff --git a/src/db/migrations/002-userprofile-unique.ts b/src/db/migrations/002-userprofile-unique.ts index 7b6370aa..fa2c09f9 100644 --- a/src/db/migrations/002-userprofile-unique.ts +++ b/src/db/migrations/002-userprofile-unique.ts @@ -1,5 +1,5 @@ import type { Database } from 'better-sqlite3'; -import { Logger } from '../../utilities/Logger.ts'; +import type { Logger } from '../../utilities/Logger.ts'; const INDEX_NAME = 'user_profiles_server_user_unique'; @@ -28,7 +28,7 @@ const birthdayKey = (row: ProfileRow): string => const newestFirst = (a: ProfileRow, b: ProfileRow): number => toTime(b.updatedAt) - toTime(a.updatedAt) || b.id - a.id; -export function up(db: Database): void { +export function up(db: Database, logger: Logger): void { const rows = db .prepare( 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL' @@ -58,7 +58,7 @@ export function up(db: Database): void { const keep = withBirthday.length > 0 ? withBirthday[0] : [...group].sort(newestFirst)[0]; if (new Set(withBirthday.map(birthdayKey)).size > 1) { - Logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { + logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { serverId: keep.serverId, userId: keep.userId, keptId: keep.id, diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 5e152398..7e42d0f5 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -1,11 +1,12 @@ import type { Database } from 'better-sqlite3'; +import type { Logger } from '../utilities/Logger.ts'; import * as baseline from './migrations/001-baseline.ts'; import * as userProfileUnique from './migrations/002-userprofile-unique.ts'; import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking.ts'; export interface Migration { name: string; - up: (db: Database) => void; + up: (db: Database, logger: Logger) => void; } export const migrations: Migration[] = [ @@ -15,7 +16,7 @@ export const migrations: Migration[] = [ ]; /** Applies pending migrations in order, each in its own transaction, and returns their names. */ -export function migrate(db: Database, list: Migration[] = migrations): string[] { +export function migrate(db: Database, logger: Logger, list: Migration[] = migrations): string[] { db.exec('CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) PRIMARY KEY)'); const done = new Set( @@ -29,7 +30,7 @@ export function migrate(db: Database, list: Migration[] = migrations): string[] continue; } db.transaction(() => { - migration.up(db); + migration.up(db, logger); record.run(migration.name); })(); applied.push(migration.name); diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 69f72f03..2acec9d2 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,12 +1,11 @@ import { defineEvent } from '../framework/event.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { Logger } from '../utilities/Logger.ts'; +import { getConfig, resetAccessFailures } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildCreate', - run: async (client, guild) => { - const config = await ConfigService.getConfig(guild.id); - await ConfigService.resetAccessFailures(config); - Logger.info('Bot added to guild', { guild: guild.id, name: guild.name }); + run: async (app, _client, guild) => { + const config = await getConfig(app.db, guild.id); + await resetAccessFailures(app.db, config); + app.logger.info('Bot added to guild', { guild: guild.id, name: guild.name }); }, }); diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index f213d20a..048a5379 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,12 +1,11 @@ import { defineEvent } from '../framework/event.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { Logger } from '../utilities/Logger.ts'; +import { purgeGuild } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildDelete', - run: async (client, guild) => { - const result = await ConfigService.purgeGuild(guild.id); - Logger.info('Bot removed from guild', { + run: async (app, _client, guild) => { + const result = await purgeGuild(app.db, guild.id); + app.logger.info('Bot removed from guild', { guild: guild.id, name: guild.name, config: result.config, diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 2c4d5a7d..7700ff4e 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,16 +1,16 @@ import { defineEvent } from '../framework/event.ts'; +import { getConfig } from '../services/ConfigService.ts'; import { auditJoin, complete } from '../services/OnboardingService.ts'; -import { ConfigService } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildMemberAdd', - run: async (client, member) => { - const config = await ConfigService.getConfig(member.guild.id); + run: async (app, client, member) => { + const config = await getConfig(app.db, member.guild.id); - await auditJoin(client, member, config); + await auditJoin(app, client, member, config); if (!member.pending) { - await complete(client, member, config); + await complete(app, client, member, config); } }, }); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 306f8c79..14b6f874 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,29 +1,28 @@ import { defineEvent } from '../framework/event.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; -import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { memberAuditEmbed } from '../services/AuditEmbed.ts'; import { sendAudit } from '../services/AuditService.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService.ts'; -import { UserProfileService } from '../services/UserProfileService.ts'; +import { getConfig } from '../services/ConfigService.ts'; +import { deleteUser } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberRemove', - run: async (client, member) => { + run: async (app, client, member) => { const user = member.user; if (user.bot) { return; } - const dataDeleted = await UserProfileService.deleteUser(member.guild.id, user.id); - if (HoneyPotEnforcementService.isActive(member.guild.id, user.id)) { + const dataDeleted = await deleteUser(app.db, member.guild.id, user.id); + if (app.honeypot.isActive(member.guild.id, user.id)) { return; } - const audit = AuditEmbed.forMember(member.partial ? user : member, EmbedColours.negative, 'Member left') + const audit = memberAuditEmbed(member.partial ? user : member, EmbedColours.negative, 'Member left') .addField('ID', user.id) .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data'); - const config = await ConfigService.getConfig(member.guild.id); - await sendAudit(client, config, audit); + const config = await getConfig(app.db, member.guild.id); + await sendAudit(app, client, config, audit); }, }); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index b44305a0..7910add9 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,13 +1,12 @@ import { defineEvent } from '../framework/event.ts'; import { complete, completedScreening } from '../services/OnboardingService.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { Logger } from '../utilities/Logger.ts'; +import { getConfig } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildMemberUpdate', - run: async (client, oldMember, newMember) => { + run: async (app, client, oldMember, newMember) => { if (oldMember.partial) { - Logger.debug('Skipping onboarding: previous member state unknown', { + app.logger.debug('Skipping onboarding: previous member state unknown', { guild: newMember.guild.id, member: newMember.id, }); @@ -17,7 +16,7 @@ export default defineEvent({ return; } - const config = await ConfigService.getConfig(newMember.guild.id); - await complete(client, newMember, config); + const config = await getConfig(app.db, newMember.guild.id); + await complete(app, client, newMember, config); }, }); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 791f339e..0c67bb54 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,12 +1,11 @@ import { MessageFlags } from 'discord.js'; import { type CommandContext, createReply } from '../framework/command.ts'; import { defineEvent } from '../framework/event.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { Logger } from '../utilities/Logger.ts'; +import { getConfig } from '../services/ConfigService.ts'; export default defineEvent({ name: 'interactionCreate', - run: async (client, interaction) => { + run: async (app, client, interaction) => { if (!interaction.isChatInputCommand()) { return; } @@ -22,7 +21,7 @@ export default defineEvent({ const command = client.slashCommands.get(interaction.commandName); const resolved = command?.resolve(interaction); if (!resolved) { - Logger.warn('Unknown command', { + app.logger.warn('Unknown command', { command: interaction.commandName, subcommand: interaction.options.getSubcommand(false), guild: interaction.guildId, @@ -34,13 +33,14 @@ export default defineEvent({ const reply = createReply(interaction); try { - const config = await ConfigService.getConfig(interaction.guildId); + const config = await getConfig(app.db, interaction.guildId); if (resolved.defer) { await interaction.deferReply( resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {} ); } const ctx: CommandContext = { + app, interaction, guild: interaction.guild, member: interaction.member, @@ -49,20 +49,17 @@ export default defineEvent({ }; await resolved.run(ctx); } catch (error) { - Logger.error( - 'Command failed', - { - command: interaction.commandName, - path: resolved.path, - guild: interaction.guildId, - user: interaction.user.id, - }, - error - ); + const context = { + command: interaction.commandName, + path: resolved.path, + guild: interaction.guildId, + user: interaction.user.id, + }; + app.logger.error('Command failed', context, error); try { await reply('Something went wrong running this command.'); } catch (replyError) { - Logger.error('Could not send command error reply', { path: resolved.path }, replyError); + app.logger.error('Could not send command error reply', { path: resolved.path }, replyError); } } }, diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 64169473..999b8504 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,14 +1,13 @@ -import { GuildMember, Message, PermissionFlagsBits } from 'discord.js'; +import { GuildMember, Message, PermissionFlagsBits, type Snowflake } from 'discord.js'; +import type { App } from '../app.ts'; import type { DingirClient } from '../client/DingirClient.ts'; import type { ServerConfig } from '../db/schema.ts'; import { defineEvent } from '../framework/event.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; -import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { memberAuditEmbed } from '../services/AuditEmbed.ts'; import { sendAudit } from '../services/AuditService.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService.ts'; -import { Logger } from '../utilities/Logger.ts'; -import { UserProfileService } from '../services/UserProfileService.ts'; +import { getConfig } from '../services/ConfigService.ts'; +import { deleteUser, incrementActivityScore } from '../services/UserProfileService.ts'; const DELETE_MESSAGE_SECONDS = 7 * 24 * 60 * 60; @@ -16,22 +15,24 @@ const formatError = (error: unknown): string => error instanceof Error ? error.message : String(error); const auditHoneypot = async ( + app: App, client: DingirClient, config: ServerConfig, member: GuildMember, - channelId: string, + channelId: Snowflake, description: string, action: string ): Promise => { - const audit = AuditEmbed.forMember(member, EmbedColours.negative, description) + const audit = memberAuditEmbed(member, EmbedColours.negative, description) .addField('Member ID', member.id) .addField('Channel', `<#${channelId}>`) .addField('Action', action); - await sendAudit(client, config, audit); + await sendAudit(app, client, config, audit); }; /** Returns true when the message was posted in the honeypot channel and has been dealt with. */ export const handleHoneypot = async ( + app: App, client: DingirClient, message: Message, config: ServerConfig @@ -47,15 +48,15 @@ export const handleHoneypot = async ( return true; } - if (!HoneyPotEnforcementService.begin(message.guild.id, member.id)) { + if (!app.honeypot.begin(message.guild.id, member.id)) { return true; } if (!member.bannable) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); + app.honeypot.cancel(message.guild.id, member.id); const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, error); - await auditHoneypot(client, config, member, message.channelId, 'Honey-pot ban failed', error); + app.logger.error(`Honey-pot ban failed for ${member.id}.`, undefined, error); + await auditHoneypot(app, client, config, member, message.channelId, 'Honey-pot ban failed', error); return true; } @@ -65,17 +66,22 @@ export const handleHoneypot = async ( reason: `Posted in honey-pot channel ${message.channelId}`, }); } catch (error) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); + app.honeypot.cancel(message.guild.id, member.id); const errorMessage = formatError(error); - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, errorMessage); - await auditHoneypot(client, config, member, message.channelId, 'Honey-pot ban failed', errorMessage); + app.logger.error(`Honey-pot ban failed for ${member.id}.`, undefined, errorMessage); + await auditHoneypot(app, client, config, member, message.channelId, 'Honey-pot ban failed', errorMessage); return true; } - await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { - Logger.writeError(`Could not delete profile data for honey-pot ban ${member.id}.`, formatError(error)); + await deleteUser(app.db, message.guild.id, member.id).catch((error) => { + app.logger.error( + `Could not delete profile data for honey-pot ban ${member.id}.`, + undefined, + formatError(error) + ); }); await auditHoneypot( + app, client, config, member, @@ -88,16 +94,16 @@ export const handleHoneypot = async ( export default defineEvent({ name: 'messageCreate', - run: async (client, message) => { + run: async (app, client, message) => { if (!message.inGuild() || message.author.bot || message.webhookId || message.system) { return; } - const config = await ConfigService.getConfig(message.guildId); - if (await handleHoneypot(client, message, config)) { + const config = await getConfig(app.db, message.guildId); + if (await handleHoneypot(app, client, message, config)) { return; } - await UserProfileService.incrementActivityScore(message.guildId, message.author.id); + await incrementActivityScore(app.db, message.guildId, message.author.id); }, }); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 724eb3dc..54fc930f 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,24 +1,23 @@ import { defineEvent } from '../framework/event.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; -import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { AuditEmbed, memberAuditEmbed } from '../services/AuditEmbed.ts'; import { sendAudit } from '../services/AuditService.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { HoneyPotEnforcementService } from '../services/HoneyPotEnforcementService.ts'; +import { getConfig } from '../services/ConfigService.ts'; export default defineEvent({ name: 'messageDelete', - run: async (client, message) => { + run: async (app, client, message) => { if (!message.guildId) { return; } if (message.author?.bot) { return; } - if (message.author && HoneyPotEnforcementService.isActive(message.guildId, message.author.id)) { + if (message.author && app.honeypot.isActive(message.guildId, message.author.id)) { return; } - const config = await ConfigService.getConfig(message.guildId); + const config = await getConfig(app.db, message.guildId); if (message.partial || !message.author) { const audit = new AuditEmbed() @@ -27,11 +26,11 @@ export default defineEvent({ .setTimestamp() .addField('Channel', `<#${message.channelId}>`) .addField('Message ID', message.id); - await sendAudit(client, config, audit); + await sendAudit(app, client, config, audit); return; } - const audit = AuditEmbed.forMember(message.author, EmbedColours.neutral, 'A message was deleted') + const audit = memberAuditEmbed(message.author, EmbedColours.neutral, 'A message was deleted') .addField('Channel', `<#${message.channelId}>`) .addField('Author ID', message.author.id); if (message.content) { @@ -44,6 +43,6 @@ export default defineEvent({ if (message.embeds.length > 0) { audit.addField('Embeds', message.embeds.length.toString()); } - await sendAudit(client, config, audit); + await sendAudit(app, client, config, audit); }, }); diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 67a075b3..754c8916 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -1,12 +1,12 @@ import { defineEvent } from '../framework/event.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; -import { AuditEmbed } from '../services/AuditEmbed.ts'; +import { memberAuditEmbed } from '../services/AuditEmbed.ts'; import { sendAudit } from '../services/AuditService.ts'; -import { ConfigService } from '../services/ConfigService.ts'; +import { getConfig } from '../services/ConfigService.ts'; export default defineEvent({ name: 'messageUpdate', - run: async (client, oldMessage, partialNew) => { + run: async (app, client, oldMessage, partialNew) => { if (!partialNew.guildId) { return; } @@ -29,13 +29,13 @@ export default defineEvent({ return; } - const audit = AuditEmbed.forMember(newMessage.author, EmbedColours.neutral, 'A message was edited') + const audit = memberAuditEmbed(newMessage.author, EmbedColours.neutral, 'A message was edited') .addField('Channel', `<#${newMessage.channelId}>`) .addField('Previous', previous === null ? '*(not cached)*' : previous) .addField('Current', newMessage.content) .addField('Jump to message', newMessage.url); - const config = await ConfigService.getConfig(newMessage.guildId); - await sendAudit(client, config, audit); + const config = await getConfig(app.db, newMessage.guildId); + await sendAudit(app, client, config, audit); }, }); diff --git a/src/events/ready.ts b/src/events/ready.ts index e8352305..f7e18e07 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,43 +1,34 @@ -import { env } from '../config/env.ts'; import { defineEvent } from '../framework/event.ts'; -import { registerShutdownHook } from '../framework/shutdown.ts'; +import { registerCommands } from '../framework/registrar.ts'; import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService.ts'; -import { run as runDataCheck } from '../services/DataCheckService.ts'; +import { runDataCheck } from '../services/DataCheckService.ts'; import { Scheduler } from '../services/Scheduler.ts'; -import { CommandRegistrar } from '../framework/CommandRegistrar.ts'; -import { Logger } from '../utilities/Logger.ts'; - -let schedulerInstalled = false; export default defineEvent({ name: 'clientReady', once: true, - run: async (client) => { + run: async (app, client) => { + const { env, logger } = app; client.user!.setPresence({ status: 'online' }); - Logger.info('Online'); - await CommandRegistrar.register([...client.slashCommands.values()]); - - if (schedulerInstalled) { - return; - } - schedulerInstalled = true; + logger.info('Online'); + await registerCommands(app, [...client.slashCommands.values()]); try { - const scheduler = new Scheduler(env.jobSchedule, env.timezone, [ - { name: 'data-check', run: () => runDataCheck(client) }, - { name: 'birthday-notifications', run: () => notifyBirthdays(client) }, - { name: 'birthday-calendars', run: () => refreshAllCalendars(client) }, + const scheduler = new Scheduler(logger, env.jobSchedule, env.timezone, [ + { name: 'data-check', run: () => runDataCheck(app, client) }, + { name: 'birthday-notifications', run: () => notifyBirthdays(app, client) }, + { name: 'birthday-calendars', run: () => refreshAllCalendars(app, client) }, ]); scheduler.start(); - registerShutdownHook(() => scheduler.stop()); - Logger.info('Scheduler started', { + app.shutdown.register(() => scheduler.stop()); + logger.info('Scheduler started', { schedule: env.jobSchedule, timezone: env.timezone, next: scheduler.nextInvocation()?.toISOString(), }); } catch (error) { - Logger.error('Could not start scheduler; scheduled jobs are disabled', undefined, error); + logger.error('Could not start scheduler; scheduled jobs are disabled', undefined, error); } }, }); diff --git a/src/framework/CommandRegistrar.ts b/src/framework/CommandRegistrar.ts deleted file mode 100644 index 4fcff360..00000000 --- a/src/framework/CommandRegistrar.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { env } from '../config/env.ts'; -import { REST, Routes } from 'discord.js'; -import type { Command } from './command.ts'; -import { Logger } from '../utilities/Logger.ts'; - -export class CommandRegistrar { - public static async register(commands: Command[]): Promise { - const guildId = env.devGuildId; - const scope = guildId ? `dev guild ${guildId}` : 'globally'; - Logger.info(`Registering ${commands.length} application commands ${scope}`); - - const rest = new REST({ version: '10' }).setToken(env.token); - const route = guildId - ? Routes.applicationGuildCommands(env.clientId, guildId) - : Routes.applicationCommands(env.clientId); - - try { - const data = (await rest.put(route, { body: commands.map((c) => c.toJSON()) })) as unknown[]; - Logger.info(`Registered ${data.length} application commands ${scope}`); - } catch (error) { - Logger.error('Failed to register application commands', { scope }, error); - } - } -} diff --git a/src/framework/command.ts b/src/framework/command.ts index d0279a3c..05caf1df 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -19,6 +19,7 @@ import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, } from 'discord.js'; +import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; export type DeferMode = 'ephemeral' | 'public' | false; @@ -32,6 +33,7 @@ export interface ReplyOptions { } export interface CommandContext { + app: App; interaction: ChatInputCommandInteraction<'cached'>; guild: Guild; member: GuildMember; @@ -159,18 +161,18 @@ export function defineCommand(def: CommandDefinition): Command { const routes = new Map(); let json: RESTPostAPIChatInputApplicationCommandsJSONBody; try { - let builder = new SlashCommandBuilder() + const builder = new SlashCommandBuilder() .setName(def.name) .setDescription(def.description) .setContexts([InteractionContextType.Guild]); if (def.adminOnly) { - builder = builder.setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + builder.setDefaultMemberPermissions(PermissionFlagsBits.Administrator); } if (def.run) { if (def.options) { - builder = def.options(builder) as unknown as SlashCommandBuilder; + def.options(builder); } routes.set(routeKey(null, null), { run: def.run, defer: def.defer ?? false, path: def.name }); } diff --git a/src/framework/event.ts b/src/framework/event.ts index 6d2359e4..cd1848ff 100644 --- a/src/framework/event.ts +++ b/src/framework/event.ts @@ -1,11 +1,11 @@ import { Client, type ClientEvents } from 'discord.js'; +import type { App } from '../app.ts'; import type { DingirClient } from '../client/DingirClient.ts'; -import { Logger } from '../utilities/Logger.ts'; export interface EventDefinition { name: K; once?: boolean; - run(client: DingirClient, ...args: ClientEvents[K]): Promise; + run(app: App, client: DingirClient, ...args: ClientEvents[K]): Promise; } export type AnyEventDefinition = { [K in keyof ClientEvents]: EventDefinition }[keyof ClientEvents]; @@ -27,12 +27,13 @@ const guildIdOf = (arg: unknown): string | undefined => { /** Registers an event on the client, logging (never throwing) handler failures. */ export function bindEvent( + app: App, client: DingirClient, def: EventDefinition ): void { const listener = (...args: ClientEvents[K]): void => { - def.run(client, ...args).catch((error: unknown) => { - Logger.error('Event handler failed', { event: def.name, guild: guildIdOf(args[0]) }, error); + def.run(app, client, ...args).catch((error: unknown) => { + app.logger.error('Event handler failed', { event: def.name, guild: guildIdOf(args[0]) }, error); }); }; const target: Client = client; diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts new file mode 100644 index 00000000..16b7e67d --- /dev/null +++ b/src/framework/registrar.ts @@ -0,0 +1,22 @@ +import { REST, Routes } from 'discord.js'; +import type { App } from '../app.ts'; +import type { Command } from './command.ts'; + +export async function registerCommands(app: App, commands: Command[]): Promise { + const { env, logger } = app; + const guildId = env.devGuildId; + const scope = guildId ? `dev guild ${guildId}` : 'globally'; + logger.info(`Registering ${commands.length} application commands ${scope}`); + + const rest = new REST({ version: '10' }).setToken(env.token); + const route = guildId + ? Routes.applicationGuildCommands(env.clientId, guildId) + : Routes.applicationCommands(env.clientId); + + try { + const data = (await rest.put(route, { body: commands.map((c) => c.toJSON()) })) as unknown[]; + logger.info(`Registered ${data.length} application commands ${scope}`); + } catch (error) { + logger.error('Failed to register application commands', { scope }, error); + } +} diff --git a/src/framework/settings.ts b/src/framework/settings.ts index b9af7292..8e52618c 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -1,6 +1,6 @@ import { type ApplicationCommandOptionAllowedChannelTypes, Guild } from 'discord.js'; import { defineSubcommandGroup, type SubcommandGroupDefinition } from './command.ts'; -import { ConfigService } from '../services/ConfigService.ts'; +import { updateConfig } from '../services/ConfigService.ts'; export type ChannelField = 'auditChannelId' | 'announcementsChannelId' | 'honeyPotChannelId'; export type BooleanField = 'debug' | 'systemMessagesEnabled'; @@ -68,7 +68,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi ), run: async (ctx) => { const channel = ctx.interaction.options.getChannel('channel', true); - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { [field]: channel.id, }); await ctx.reply(channelSetText(label, channel.id)); @@ -87,7 +87,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi name: 'clear', description: `Clear the ${label.toLowerCase()}`, run: async (ctx) => { - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { [field]: null }); + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { [field]: null }); await ctx.reply(channelClearText(label)); }, }, @@ -110,7 +110,7 @@ export function booleanSetting(opts: BooleanSettingOptions): SubcommandGroupDefi ), run: async (ctx) => { const enabled = ctx.interaction.options.getBoolean('enabled', true); - ctx.config = await ConfigService.updateConfig(ctx.config.serverId, { [field]: enabled }); + ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { [field]: enabled }); await ctx.reply(booleanText(label, enabled)); }, }, diff --git a/src/framework/shutdown.ts b/src/framework/shutdown.ts index eb0b3b9f..7f31d33c 100644 --- a/src/framework/shutdown.ts +++ b/src/framework/shutdown.ts @@ -1,20 +1,27 @@ -import { Logger } from '../utilities/Logger.ts'; +import type { Logger } from '../utilities/Logger.ts'; -type ShutdownHook = () => Promise | void; +export type ShutdownHook = () => Promise | void; -const hooks: ShutdownHook[] = []; - -export function registerShutdownHook(hook: ShutdownHook): void { - hooks.push(hook); +export interface ShutdownRegistry { + register(hook: ShutdownHook): void; + /** Runs hooks in reverse registration order; a failing hook never blocks the rest. */ + run(): Promise; } -/** Runs hooks in reverse registration order; a failing hook never blocks the rest. */ -export async function runShutdownHooks(): Promise { - for (const hook of [...hooks].reverse()) { - try { - await hook(); - } catch (error) { - Logger.error('Shutdown hook failed', undefined, error); - } - } +export function createShutdownRegistry(logger: Logger): ShutdownRegistry { + const hooks: ShutdownHook[] = []; + return { + register: (hook) => { + hooks.push(hook); + }, + run: async () => { + for (const hook of [...hooks].reverse()) { + try { + await hook(); + } catch (error) { + logger.error('Shutdown hook failed', undefined, error); + } + } + }, + }; } diff --git a/src/index.ts b/src/index.ts index 4564e9d2..6fddf7c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,25 @@ -import { initEnv } from './config/env.ts'; -import { Logger } from './utilities/Logger.ts'; +import { createApp } from './app.ts'; +import { DingirClient } from './client/DingirClient.ts'; +import { type Env, loadEnv } from './config/env.ts'; +import { createConsoleLogger } from './utilities/Logger.ts'; + +const logger = createConsoleLogger(); async function main(): Promise { + let env: Env; try { - initEnv(); + env = loadEnv(); } catch (error) { - Logger.writeError(error instanceof Error ? error.message : String(error)); - Logger.writeError('Invalid configuration, shutting down.'); + logger.error(error instanceof Error ? error.message : String(error)); + logger.error('Invalid configuration, shutting down.'); process.exit(1); } - const { DingirClient } = await import('./client/DingirClient.ts'); - const dingirClient = new DingirClient(); - await dingirClient.start(); + const app = createApp({ env, logger }); + await new DingirClient(app).start(); } main().catch((error: unknown) => { - Logger.writeError('Fatal error during startup.', error); + logger.error('Fatal error during startup.', undefined, error); process.exit(1); }); diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts index 06349cd9..79eab28b 100644 --- a/src/services/AuditEmbed.ts +++ b/src/services/AuditEmbed.ts @@ -20,22 +20,6 @@ export class AuditEmbed extends EmbedBuilder { private cut = false; private storedFields = 0; - public static forMember( - member: GuildMember | User, - colour: ColorResolvable, - description: string - ): AuditEmbed { - const user = member instanceof User ? member : member.user; - const name = member.displayName || user.tag; - const embed = new AuditEmbed(); - embed - .setColor(colour) - .setAuthor({ name: truncate(name, LIMITS.fieldName), iconURL: member.displayAvatarURL() }) - .setDescription(description) - .setTimestamp(); - return embed; - } - public setTitle(title: string | null): this { return super.setTitle(title === null ? null : this.limit(title, LIMITS.title)); } @@ -96,3 +80,17 @@ export class AuditEmbed extends EmbedBuilder { return truncate(text, max); } } + +export function memberAuditEmbed( + member: GuildMember | User, + colour: ColorResolvable, + description: string +): AuditEmbed { + const user = member instanceof User ? member : member.user; + const name = member.displayName || user.tag; + return new AuditEmbed() + .setColor(colour) + .setAuthor({ name: truncate(name, LIMITS.fieldName), iconURL: member.displayAvatarURL() }) + .setDescription(description) + .setTimestamp(); +} diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index 85286a09..90468da0 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -1,8 +1,9 @@ import { Attachment, Client, EmbedBuilder } from 'discord.js'; +import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; -import { Logger } from '../utilities/Logger.ts'; export const sendAudit = async ( + app: App, client: Client, config: ServerConfig, embed: EmbedBuilder, @@ -17,13 +18,13 @@ export const sendAudit = async ( try { const channel = await client.channels.fetch(channelId); if (!channel || !channel.isSendable()) { - Logger.warn('Audit channel missing or not sendable', context); + app.logger.warn('Audit channel missing or not sendable', context); return false; } await channel.send({ embeds: [embed], files: files?.length ? files : undefined }); return true; } catch (error) { - Logger.warn('Failed to send audit message', context, error); + app.logger.warn('Failed to send audit message', context, error); return false; } }; diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index db30a461..3b0a10d2 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,10 +1,9 @@ -import { Client } from 'discord.js'; +import { Client, type Snowflake } from 'discord.js'; import { DateTime } from 'luxon'; import type { ServerConfig } from '../db/schema.ts'; -import { env } from '../config/env.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { Logger } from '../utilities/Logger.ts'; -import { UserProfileService } from '../services/UserProfileService.ts'; +import type { App } from '../app.ts'; +import { getConfigs } from './ConfigService.ts'; +import { getServerBirthdays } from './UserProfileService.ts'; import { type BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates.ts'; import { resolveMember, resolveTextChannel } from './MemberResolver.ts'; @@ -15,23 +14,18 @@ export type CalendarStatus = | 'message-missing' | 'failed'; -export interface BirthdayOptions { - now?: DateTime; - zone?: string; -} - const MESSAGE_LIMIT = 2000; const SEPARATOR = '-------------'; const LOCALE = 'en-GB'; export const parseCalendarPath = ( path: string | null | undefined -): { channelId: string; messageId: string } | null => { +): { channelId: Snowflake; messageId: Snowflake } | null => { const [channelId, messageId, ...rest] = (path ?? '').split('/'); return channelId && messageId && rest.length === 0 ? { channelId, messageId } : null; }; -export const calendarMessageUrl = (guildId: string, path: string): string => +export const calendarMessageUrl = (guildId: Snowflake, path: string): string => `https://discord.com/channels/${guildId}/${path}`; export const buildCalendarContent = ( @@ -58,11 +52,14 @@ export const buildCalendarContent = ( return content; }; +const nowOf = (app: App): DateTime => DateTime.fromJSDate(app.clock()); + export const refreshCalendar = async ( + app: App, client: Client, - config: ServerConfig, - opts: BirthdayOptions = {} + config: ServerConfig ): Promise => { + const { logger } = app; const guild = config.serverId; const target = parseCalendarPath(config.birthdayCalendarMessagePath); if (!target) { @@ -70,33 +67,29 @@ export const refreshCalendar = async ( } try { const channel = await client.channels.fetch(target.channelId).catch((error: unknown) => { - Logger.warn('Birthday calendar channel fetch failed', { guild }, error); + logger.warn('Birthday calendar channel fetch failed', { guild }, error); return null; }); if (!channel || !channel.isTextBased()) { return 'channel-missing'; } const message = await channel.messages.fetch(target.messageId).catch((error: unknown) => { - Logger.warn('Birthday calendar message fetch failed', { guild }, error); + logger.warn('Birthday calendar message fetch failed', { guild }, error); return null; }); if (!message) { return 'message-missing'; } - const profiles = (await UserProfileService.getServerBirthdays(guild)).map((p) => ({ + const profiles = (await getServerBirthdays(app.db, guild)).map((p) => ({ userId: p.userId, month: p.birthdayMonth!, day: p.birthdayDay!, })); - const content = buildCalendarContent( - profiles, - opts.now ?? DateTime.now(), - opts.zone ?? env.timezone - ); + const content = buildCalendarContent(profiles, nowOf(app), app.env.timezone); await message.edit({ content, allowedMentions: { parse: [] } }); return 'updated'; } catch (error) { - Logger.error('Birthday calendar refresh failed', { guild }, error); + logger.error('Birthday calendar refresh failed', { guild }, error); return 'failed'; } }; @@ -120,41 +113,41 @@ export const deleteCalendarMessage = async ( } }; -export const refreshAllCalendars = async ( - client: Client, - opts: BirthdayOptions = {} -): Promise => { - Logger.info('Refreshing birthday calendars'); - const configs = await ConfigService.getConfigs(); +export const refreshAllCalendars = async (app: App, client: Client): Promise => { + const { logger } = app; + logger.info('Refreshing birthday calendars'); + const configs = await getConfigs(app.db); for (const config of configs) { if (!config.birthdayCalendarMessagePath) { continue; } try { - const status = await refreshCalendar(client, config, opts); + const status = await refreshCalendar(app, client, config); if (status !== 'updated') { - Logger.warn('Birthday calendar not updated', { guild: config.serverId, status }); + logger.warn('Birthday calendar not updated', { guild: config.serverId, status }); } } catch (error) { - Logger.error('Birthday calendar refresh crashed', { guild: config.serverId }, error); + logger.error('Birthday calendar refresh crashed', { guild: config.serverId }, error); } } }; const notifyGuild = async ( + app: App, client: Client, config: ServerConfig, - now: DateTime, - zone: string + now: DateTime ): Promise => { + const { logger } = app; + const zone = app.env.timezone; const guild = client.guilds.cache.get(config.serverId) ?? (await client.guilds.fetch(config.serverId)); const channel = await resolveTextChannel(guild, config.announcementsChannelId!); if (!channel) { - Logger.warn('Announcements channel unavailable', { guild: config.serverId }); + logger.warn('Announcements channel unavailable', { guild: config.serverId }); return; } - const profiles = await UserProfileService.getServerBirthdays(config.serverId); + const profiles = await getServerBirthdays(app.db, config.serverId); const ids: string[] = []; for (const profile of profiles) { if (!isBirthdayToday(profile.birthdayMonth!, profile.birthdayDay!, now, zone)) { @@ -165,7 +158,7 @@ const notifyGuild = async ( ids.push(profile.userId); } } catch (error) { - Logger.warn( + logger.warn( 'Could not resolve birthday member', { guild: config.serverId, user: profile.userId }, error @@ -181,22 +174,18 @@ const notifyGuild = async ( }); }; -export const notifyBirthdays = async ( - client: Client, - opts: BirthdayOptions = {} -): Promise => { - Logger.info('Sending birthday notifications'); - const now = opts.now ?? DateTime.now(); - const zone = opts.zone ?? env.timezone; - const configs = await ConfigService.getConfigs(); +export const notifyBirthdays = async (app: App, client: Client): Promise => { + app.logger.info('Sending birthday notifications'); + const now = nowOf(app); + const configs = await getConfigs(app.db); for (const config of configs) { if (!config.announcementsChannelId) { continue; } try { - await notifyGuild(client, config, now, zone); + await notifyGuild(app, client, config, now); } catch (error) { - Logger.error('Birthday notification failed', { guild: config.serverId }, error); + app.logger.error('Birthday notification failed', { guild: config.serverId }, error); } } }; diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 125643cc..50ef6550 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -1,56 +1,54 @@ +import type { Snowflake } from 'discord.js'; import { eq } from 'drizzle-orm'; -import { getDb, type AppDatabase } from '../db/db.ts'; +import type { Db } from '../db/db.ts'; import { serverConfigs, userProfiles, type ServerConfig } from '../db/schema.ts'; export type ServerConfigPatch = Partial>; -export class ConfigService { - public static async getConfig(serverId: string): Promise { - const db = getDb(); - db.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); - return db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get()!; - } +export async function getConfig(db: Db, serverId: Snowflake): Promise { + db.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); + return db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get()!; +} - public static async getConfigs(): Promise { - return getDb().select().from(serverConfigs).all(); - } +export async function getConfigs(db: Db): Promise { + return db.select().from(serverConfigs).all(); +} - public static async updateConfig( - serverId: string, - patch: ServerConfigPatch - ): Promise { - const db = getDb(); - return db.transaction((tx) => { - tx.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); - return tx - .update(serverConfigs) - .set({ ...patch, updatedAt: new Date() }) - .where(eq(serverConfigs.serverId, serverId)) - .returning() - .get(); - }); - } +export async function updateConfig( + db: Db, + serverId: Snowflake, + patch: ServerConfigPatch +): Promise { + return db.transaction((tx) => { + tx.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); + return tx + .update(serverConfigs) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(serverConfigs.serverId, serverId)) + .returning() + .get(); + }); +} - public static async deleteConfig(serverId: string): Promise { - const result = getDb().delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); - return result.changes > 0; - } +export async function deleteConfig(db: Db, serverId: Snowflake): Promise { + const result = db.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); + return result.changes > 0; +} - public static async resetAccessFailures(config: ServerConfig): Promise { - if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { - return config; - } - return this.updateConfig(config.serverId, { accessFailureCount: 0, firstAccessFailureAt: null }); +export async function resetAccessFailures(db: Db, config: ServerConfig): Promise { + if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { + return config; } + return updateConfig(db, config.serverId, { accessFailureCount: 0, firstAccessFailureAt: null }); +} - public static async purgeGuild( - serverId: string, - database: AppDatabase = getDb() - ): Promise<{ config: boolean; profiles: number }> { - return database.transaction((tx) => { - const profiles = tx.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); - const configs = tx.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); - return { config: configs.changes > 0, profiles: profiles.changes }; - }); - } +export async function purgeGuild( + db: Db, + serverId: Snowflake +): Promise<{ config: boolean; profiles: number }> { + return db.transaction((tx) => { + const profiles = tx.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); + const configs = tx.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); + return { config: configs.changes > 0, profiles: profiles.changes }; + }); } diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index 82c6c9b2..f8e73872 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,8 +1,7 @@ -import { Client } from 'discord.js'; -import { env } from '../config/env.ts'; -import { ConfigService } from '../services/ConfigService.ts'; -import { Logger } from '../utilities/Logger.ts'; -import { UserProfileService } from '../services/UserProfileService.ts'; +import { Client, type Snowflake } from 'discord.js'; +import type { App } from '../app.ts'; +import { getConfigs, purgeGuild, updateConfig } from './ConfigService.ts'; +import { deleteUsers, getServerProfiles } from './UserProfileService.ts'; import { type AccessOutcome, type RetentionPolicyConfig, @@ -10,34 +9,33 @@ import { nextAccessState, } from './RetentionPolicy.ts'; -const cleanMembers = async ( - client: Client, - serverId: string -): Promise => { +const cleanMembers = async (app: App, client: Client, serverId: Snowflake): Promise => { const guild = await client.guilds.fetch(serverId); const members = await guild.members.fetch(); if (members.size === 0) { - Logger.warn('Member list empty; skipping profile cleanup', { guild: serverId }); + app.logger.warn('Member list empty; skipping profile cleanup', { guild: serverId }); return; } - const profiles = await UserProfileService.getServerProfiles(serverId); + const profiles = await getServerProfiles(app.db, serverId); const departed = profiles.filter((p) => !members.has(p.userId)).map((p) => p.userId); if (departed.length > 0) { - const removed = await UserProfileService.deleteUsers(serverId, departed); - Logger.info('Removed profiles of departed members', { guild: serverId, removed }); + const removed = await deleteUsers(app.db, serverId, departed); + app.logger.info('Removed profiles of departed members', { guild: serverId, removed }); } }; -export const run = async ( +export const runDataCheck = async ( + app: App, client: Client, - now: Date = new Date(), policy: RetentionPolicyConfig = { - minFailures: env.purgeMinFailures, - graceDays: env.purgeGraceDays, + minFailures: app.env.purgeMinFailures, + graceDays: app.env.purgeGraceDays, } ): Promise => { - Logger.info('Running data check'); - const configs = await ConfigService.getConfigs(); + const { logger } = app; + const now = app.clock(); + logger.info('Running data check'); + const configs = await getConfigs(app.db); for (const config of configs) { const guild = config.serverId; try { @@ -46,7 +44,7 @@ export const run = async ( await client.guilds.fetch(guild); } catch (error) { outcome = classifyGuildFetchError(error); - Logger.warn('Guild fetch failed', { guild, outcome }, error); + logger.warn('Guild fetch failed', { guild, outcome }, error); } const next = nextAccessState( @@ -60,8 +58,8 @@ export const run = async ( ); if (next.purge) { - const result = await ConfigService.purgeGuild(guild); - Logger.warn('Purged guild after sustained loss of access', { + const result = await purgeGuild(app.db, guild); + logger.warn('Purged guild after sustained loss of access', { guild, failures: next.accessFailureCount, config: result.config, @@ -74,18 +72,18 @@ export const run = async ( next.accessFailureCount !== config.accessFailureCount || next.firstAccessFailureAt?.getTime() !== config.firstAccessFailureAt?.getTime() ) { - await ConfigService.updateConfig(config.serverId, { + await updateConfig(app.db, config.serverId, { accessFailureCount: next.accessFailureCount, firstAccessFailureAt: next.firstAccessFailureAt, }); } if (outcome === 'ok') { - await cleanMembers(client, guild); + await cleanMembers(app, client, guild); } } catch (error) { - Logger.error('Data check failed for guild', { guild }, error); + logger.error('Data check failed for guild', { guild }, error); } } - Logger.info('Data check complete'); + logger.info('Data check complete'); }; diff --git a/src/services/HoneyPotEnforcementService.ts b/src/services/HoneyPotEnforcementService.ts deleted file mode 100644 index f3efda35..00000000 --- a/src/services/HoneyPotEnforcementService.ts +++ /dev/null @@ -1,37 +0,0 @@ -const ENFORCEMENT_WINDOW_MS = 5 * 60 * 1000; - -export class HoneyPotEnforcementService { - private static readonly activeEnforcements = new Map(); - - private static getKey(serverId: string, userId: string): string { - return `${serverId}:${userId}`; - } - - public static begin(serverId: string, userId: string): boolean { - const key = this.getKey(serverId, userId); - if (this.activeEnforcements.has(key)) { - return false; - } - - const timeout = setTimeout(() => { - this.activeEnforcements.delete(key); - }, ENFORCEMENT_WINDOW_MS); - timeout.unref(); - - this.activeEnforcements.set(key, timeout); - return true; - } - - public static isActive(serverId: string, userId: string): boolean { - return this.activeEnforcements.has(this.getKey(serverId, userId)); - } - - public static cancel(serverId: string, userId: string): void { - const key = this.getKey(serverId, userId); - const timeout = this.activeEnforcements.get(key); - if (timeout) { - clearTimeout(timeout); - this.activeEnforcements.delete(key); - } - } -} diff --git a/src/services/HoneypotTracker.ts b/src/services/HoneypotTracker.ts new file mode 100644 index 00000000..114dbc94 --- /dev/null +++ b/src/services/HoneypotTracker.ts @@ -0,0 +1,39 @@ +import type { Snowflake } from 'discord.js'; + +const ENFORCEMENT_WINDOW_MS = 5 * 60 * 1000; + +export interface HoneypotTracker { + /** Marks enforcement as started; false when it is already in progress for this member. */ + begin(serverId: Snowflake, userId: Snowflake): boolean; + isActive(serverId: Snowflake, userId: Snowflake): boolean; + cancel(serverId: Snowflake, userId: Snowflake): void; +} + +export function createHoneypotTracker(): HoneypotTracker { + const active = new Map(); + const keyOf = (serverId: Snowflake, userId: Snowflake): string => `${serverId}:${userId}`; + + return { + begin: (serverId, userId) => { + const key = keyOf(serverId, userId); + if (active.has(key)) { + return false; + } + const timeout = setTimeout(() => { + active.delete(key); + }, ENFORCEMENT_WINDOW_MS); + timeout.unref(); + active.set(key, timeout); + return true; + }, + isActive: (serverId, userId) => active.has(keyOf(serverId, userId)), + cancel: (serverId, userId) => { + const key = keyOf(serverId, userId); + const timeout = active.get(key); + if (timeout) { + clearTimeout(timeout); + active.delete(key); + } + }, + }; +} diff --git a/src/services/MemberListing.ts b/src/services/MemberListing.ts index f1041a55..88cd4a8d 100644 --- a/src/services/MemberListing.ts +++ b/src/services/MemberListing.ts @@ -20,7 +20,7 @@ const fileLine = (member: GuildMember): string => : `joined ${DateTime.fromMillis(member.joinedTimestamp, { zone: 'utc' }).toISODate()}` }`; -export const wholeDaysSinceJoin = (member: GuildMember, now: number = Date.now()): number | null => { +export const wholeDaysSinceJoin = (member: GuildMember, now: number): number | null => { if (member.joinedTimestamp === null) { return null; } diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts index 895a21bb..2d77e70e 100644 --- a/src/services/MemberResolver.ts +++ b/src/services/MemberResolver.ts @@ -1,4 +1,4 @@ -import { Guild, GuildMember, type GuildTextBasedChannel, User } from 'discord.js'; +import { Guild, GuildMember, type GuildTextBasedChannel, type Snowflake, User } from 'discord.js'; const UNKNOWN_MEMBER = 10007; const UNKNOWN_USER = 10013; @@ -8,7 +8,7 @@ const errorCode = (error: unknown): unknown => export const resolveMember = async ( guild: Guild, - userOrId: User | string + userOrId: User | Snowflake ): Promise => { const id = typeof userOrId === 'string' ? userOrId : userOrId.id; const cached = guild.members.cache.get(id); @@ -28,7 +28,7 @@ export const resolveMember = async ( export const resolveTextChannel = async ( guild: Guild, - id: string + id: Snowflake ): Promise => { const channel = guild.channels.cache.get(id) ?? (await guild.channels.fetch(id).catch(() => null)); if (!channel || !channel.isTextBased() || channel.isDMBased() || !channel.isSendable()) { diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 96e6545e..2769abbe 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,8 +1,16 @@ -import { AttachmentBuilder, Client, Guild, GuildMember, type PartialGuildMember, Role } from 'discord.js'; +import { + AttachmentBuilder, + Client, + Guild, + GuildMember, + type PartialGuildMember, + Role, + type Snowflake, +} from 'discord.js'; +import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; -import { Logger } from '../utilities/Logger.ts'; -import { AuditEmbed } from './AuditEmbed.ts'; +import { memberAuditEmbed } from './AuditEmbed.ts'; import { sendAudit } from './AuditService.ts'; import { WelcomeImage, type WelcomeImageRenderer } from './WelcomeImage.ts'; @@ -16,7 +24,7 @@ export interface WelcomePayload { } export interface RoleSkip { - id: string; + id: Snowflake; reason: string; } @@ -26,7 +34,7 @@ export interface OnboardingResult { roles: StepResult; welcome: StepResult; debug: StepResult; - rolesAdded: string[]; + rolesAdded: Snowflake[]; rolesSkipped: RoleSkip[]; welcomePayload?: WelcomePayload; } @@ -64,7 +72,7 @@ export const roleProblem = (guild: Guild, role: Role): string | null => { return null; }; -export const parseRoleIds = (raw: string | null | undefined): string[] => +export const parseRoleIds = (raw: string | null | undefined): Snowflake[] => (raw ?? '') .split(',') .map((id) => id.trim()) @@ -91,14 +99,21 @@ export const formatOnboardingSummary = (result: OnboardingResult): string => { return lines.join('\n'); }; -export const auditJoin = (client: Client, member: GuildMember, config: ServerConfig): Promise => +export const auditJoin = ( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig +): Promise => sendAudit( + app, client, config, - AuditEmbed.forMember(member, EmbedColours.positive, 'New member joined').addField('ID', member.id) + memberAuditEmbed(member, EmbedColours.positive, 'New member joined').addField('ID', member.id) ); const auditFailure = ( + app: App, client: Client, member: GuildMember, config: ServerConfig, @@ -106,14 +121,16 @@ const auditFailure = ( detail: string ): Promise => sendAudit( + app, client, config, - AuditEmbed.forMember(member, EmbedColours.negative, description) + memberAuditEmbed(member, EmbedColours.negative, description) .addField('ID', member.id) .addField('Reason', detail) ); async function runStep( + app: App, name: string, member: GuildMember, step: () => Promise @@ -121,12 +138,17 @@ async function runStep( try { return await step(); } catch (error) { - Logger.error(`Onboarding step failed: ${name}`, { guild: member.guild.id, member: member.id }, error); + app.logger.error( + `Onboarding step failed: ${name}`, + { guild: member.guild.id, member: member.id }, + error + ); return `failed:${messageOf(error)}`; } } async function addGuestRoles( + app: App, client: Client, member: GuildMember, config: ServerConfig, @@ -164,6 +186,7 @@ async function addGuestRoles( } catch (error) { result.rolesAdded = []; await auditFailure( + app, client, member, config, @@ -176,6 +199,7 @@ async function addGuestRoles( } async function sendWelcome( + app: App, client: Client, member: GuildMember, config: ServerConfig, @@ -202,12 +226,19 @@ async function sendWelcome( } if (url) { try { - payload.image = await renderImage(member, url); + payload.image = await renderImage(app, member, url); } catch (error) { payload.imageError = messageOf(error); - Logger.warn('Welcome image failed', { guild: member.guild.id, member: member.id }, error); + app.logger.warn('Welcome image failed', { guild: member.guild.id, member: member.id }, error); if (!dryRun) { - await auditFailure(client, member, config, 'Unable to generate welcome image.', payload.imageError); + await auditFailure( + app, + client, + member, + config, + 'Unable to generate welcome image.', + payload.imageError + ); } } } @@ -226,13 +257,21 @@ async function sendWelcome( allowedMentions: { users: [member.id] }, }); } catch (error) { - await auditFailure(client, member, config, 'Unable to send welcome message.', messageOf(error)); + await auditFailure( + app, + client, + member, + config, + 'Unable to send welcome message.', + messageOf(error) + ); throw error; } return 'done'; } export async function complete( + app: App, client: Client, member: GuildMember, config: ServerConfig, @@ -252,11 +291,12 @@ export async function complete( }; if (!dryRun) { - result.audit = await runStep('audit', member, async () => { + result.audit = await runStep(app, 'audit', member, async () => { const sent = await sendAudit( + app, client, config, - AuditEmbed.forMember(member, EmbedColours.neutral, 'Member completed onboarding').addField( + memberAuditEmbed(member, EmbedColours.neutral, 'Member completed onboarding').addField( 'ID', member.id ) @@ -265,19 +305,20 @@ export async function complete( }); } - result.roles = await runStep('roles', member, () => - addGuestRoles(client, member, config, dryRun, result) + result.roles = await runStep(app, 'roles', member, () => + addGuestRoles(app, client, member, config, dryRun, result) ); - result.welcome = await runStep('welcome', member, () => - sendWelcome(client, member, config, dryRun, result, renderImage) + result.welcome = await runStep(app, 'welcome', member, () => + sendWelcome(app, client, member, config, dryRun, result, renderImage) ); if (config.debug && !dryRun) { - result.debug = await runStep('debug', member, async () => { + result.debug = await runStep(app, 'debug', member, async () => { const sent = await sendAudit( + app, client, config, - AuditEmbed.forMember(member, EmbedColours.info, 'Onboarding diagnostics') + memberAuditEmbed(member, EmbedColours.info, 'Onboarding diagnostics') .addField('ID', member.id) .addField('Steps', formatOnboardingSummary(result)) ); diff --git a/src/services/Scheduler.ts b/src/services/Scheduler.ts index 945ffcf0..e46e9aee 100644 --- a/src/services/Scheduler.ts +++ b/src/services/Scheduler.ts @@ -1,5 +1,5 @@ import { Cron } from 'croner'; -import { Logger } from '../utilities/Logger.ts'; +import type { Logger } from '../utilities/Logger.ts'; export interface SchedulerTask { name: string; @@ -27,6 +27,7 @@ const defaultScheduleFn: ScheduleFn = (spec, callback) => { export class Scheduler { private job: ScheduledJobLike | null = null; + private readonly logger: Logger; private current: Promise | null = null; private readonly cron: string; private readonly timezone: string; @@ -34,11 +35,13 @@ export class Scheduler { private readonly scheduleFn: ScheduleFn; public constructor( + logger: Logger, cron: string, timezone: string, tasks: SchedulerTask[], scheduleFn: ScheduleFn = defaultScheduleFn ) { + this.logger = logger; this.cron = cron; this.timezone = timezone; this.tasks = tasks; @@ -47,7 +50,7 @@ export class Scheduler { public start(): void { if (this.job) { - Logger.warn('Scheduler already started', { cron: this.cron }); + this.logger.warn('Scheduler already started', { cron: this.cron }); return; } const job = this.scheduleFn({ rule: this.cron, tz: this.timezone }, () => { @@ -68,7 +71,7 @@ export class Scheduler { public async runNow(): Promise { if (this.current) { - Logger.warn('Scheduled run skipped; previous run still in progress'); + this.logger.warn('Scheduled run skipped; previous run still in progress'); return; } const run = this.runTasks(); @@ -85,7 +88,7 @@ export class Scheduler { try { await task.run(); } catch (error) { - Logger.error('Scheduled task failed', { task: task.name }, error); + this.logger.error('Scheduled task failed', { task: task.name }, error); } } } diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 8e6b6118..717fbf12 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,112 +1,115 @@ +import type { Snowflake } from 'discord.js'; import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; -import { getDb } from '../db/db.ts'; +import type { Db } from '../db/db.ts'; import { userProfiles, type UserProfile } from '../db/schema.ts'; -export class UserProfileService { - public static async getServerBirthdays(serverId: string): Promise { - return getDb() - .select() - .from(userProfiles) - .where( - and( - eq(userProfiles.serverId, serverId), - isNotNull(userProfiles.birthdayDay), - isNotNull(userProfiles.birthdayMonth) - ) +const forUser = (serverId: Snowflake, userId: Snowflake) => + and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId)); + +export async function getServerBirthdays(db: Db, serverId: Snowflake): Promise { + return db + .select() + .from(userProfiles) + .where( + and( + eq(userProfiles.serverId, serverId), + isNotNull(userProfiles.birthdayDay), + isNotNull(userProfiles.birthdayMonth) ) - .all(); - } + ) + .all(); +} - public static async getServerProfiles(serverId: string): Promise { - return getDb().select().from(userProfiles).where(eq(userProfiles.serverId, serverId)).all(); - } +export async function getServerProfiles(db: Db, serverId: Snowflake): Promise { + return db.select().from(userProfiles).where(eq(userProfiles.serverId, serverId)).all(); +} - public static async setBirthday( - serverId: string, - userId: string, - month: number, - day: number - ): Promise { - getDb() - .insert(userProfiles) - .values({ serverId, userId, birthdayMonth: month, birthdayDay: day }) - .onConflictDoUpdate({ - target: [userProfiles.serverId, userProfiles.userId], - set: { birthdayMonth: month, birthdayDay: day, updatedAt: new Date() }, - }) - .run(); - } +export async function setBirthday( + db: Db, + serverId: Snowflake, + userId: Snowflake, + month: number, + day: number +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, birthdayMonth: month, birthdayDay: day }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { birthdayMonth: month, birthdayDay: day, updatedAt: new Date() }, + }) + .run(); +} - public static async clearBirthday(serverId: string, userId: string): Promise { - const result = getDb() - .update(userProfiles) - .set({ birthdayYear: null, birthdayMonth: null, birthdayDay: null, updatedAt: new Date() }) - .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) - .run(); - return result.changes > 0; - } +export async function clearBirthday( + db: Db, + serverId: Snowflake, + userId: Snowflake +): Promise { + const result = db + .update(userProfiles) + .set({ birthdayYear: null, birthdayMonth: null, birthdayDay: null, updatedAt: new Date() }) + .where(forUser(serverId, userId)) + .run(); + return result.changes > 0; +} - public static async deleteUsers(serverId: string, userIds: string[]): Promise { - const db = getDb(); - let removed = 0; - for (let i = 0; i < userIds.length; i += 500) { - removed += db - .delete(userProfiles) - .where( - and(eq(userProfiles.serverId, serverId), inArray(userProfiles.userId, userIds.slice(i, i + 500))) - ) - .run().changes; - } - return removed; +export async function deleteUsers( + db: Db, + serverId: Snowflake, + userIds: Snowflake[] +): Promise { + let removed = 0; + for (let i = 0; i < userIds.length; i += 500) { + removed += db + .delete(userProfiles) + .where( + and(eq(userProfiles.serverId, serverId), inArray(userProfiles.userId, userIds.slice(i, i + 500))) + ) + .run().changes; } + return removed; +} - public static async getUserProfile(serverId: string, userId: string): Promise { - const db = getDb(); - db.insert(userProfiles).values({ serverId, userId }).onConflictDoNothing().run(); - return db - .select() - .from(userProfiles) - .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) - .get()!; - } +export async function getUserProfile( + db: Db, + serverId: Snowflake, + userId: Snowflake +): Promise { + db.insert(userProfiles).values({ serverId, userId }).onConflictDoNothing().run(); + return db.select().from(userProfiles).where(forUser(serverId, userId)).get()!; +} - public static async findUserProfile( - serverId: string, - userId: string - ): Promise { - return ( - getDb() - .select() - .from(userProfiles) - .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) - .get() ?? null - ); - } +export async function findUserProfile( + db: Db, + serverId: Snowflake, + userId: Snowflake +): Promise { + return db.select().from(userProfiles).where(forUser(serverId, userId)).get() ?? null; +} - public static async incrementActivityScore(serverId: string, userId: string): Promise { - getDb() - .insert(userProfiles) - .values({ serverId, userId, activityScore: 1 }) - .onConflictDoUpdate({ - target: [userProfiles.serverId, userProfiles.userId], - set: { - activityScore: sql`coalesce(${userProfiles.activityScore}, 0) + 1`, - updatedAt: new Date(), - }, - }) - .run(); - } +export async function incrementActivityScore( + db: Db, + serverId: Snowflake, + userId: Snowflake +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, activityScore: 1 }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { + activityScore: sql`coalesce(${userProfiles.activityScore}, 0) + 1`, + updatedAt: new Date(), + }, + }) + .run(); +} - public static async deleteUser(serverId: string, userId: string): Promise { - const result = getDb() - .delete(userProfiles) - .where(and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId))) - .run(); - return result.changes > 0; - } +export async function deleteUser(db: Db, serverId: Snowflake, userId: Snowflake): Promise { + const result = db.delete(userProfiles).where(forUser(serverId, userId)).run(); + return result.changes > 0; +} - public static async deleteUsersByServer(serverId: string): Promise { - const result = getDb().delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); - return result.changes > 0; - } +export async function deleteUsersByServer(db: Db, serverId: Snowflake): Promise { + const result = db.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); + return result.changes > 0; } diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts index 39ed8ef0..30a9488c 100644 --- a/src/services/WelcomeImage.ts +++ b/src/services/WelcomeImage.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { AttachmentBuilder, GuildMember } from 'discord.js'; import { DateTime } from 'luxon'; import { Canvas, createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; -import { env } from '../config/env.ts'; +import type { App } from '../app.ts'; const FONT_FAMILY = 'Roboto'; const FONT_PATH = path.join(import.meta.dirname, '..', 'resources', 'fonts', 'Roboto-Regular.ttf'); @@ -67,11 +67,12 @@ const downloadImage = async (url: string): Promise => { }; export type WelcomeImageRenderer = ( + app: App, member: GuildMember, backgroundUrl: string ) => Promise; -export const render: WelcomeImageRenderer = async (member, backgroundUrl) => { +export const render: WelcomeImageRenderer = async (app, member, backgroundUrl) => { ensureFont(); if (member.joinedTimestamp === null) { throw new Error(`Join date unavailable for member ${member.id}`); @@ -87,7 +88,7 @@ export const render: WelcomeImageRenderer = async (member, backgroundUrl) => { const canvas = createCanvas(700, 250); const ctx = canvas.getContext('2d'); - const joined = DateTime.fromMillis(member.joinedTimestamp, { zone: env.timezone }).toLocaleString( + const joined = DateTime.fromMillis(member.joinedTimestamp, { zone: app.env.timezone }).toLocaleString( DateTime.DATE_FULL ); const name = member.displayName; diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index 2da81ca2..d1f8b4d9 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -30,41 +30,26 @@ export const formatContext = (context?: Context): string => .join(' ') : ''; -export class Logger { - public static writeLog(log: string): void { - console.log(`[${timestamp()}]: ${log}`); - } - - public static writeError(log: string, error?: unknown): void { - console.error(`[${timestamp()}]: ${log}`); - if (error !== undefined && error !== null && error !== '') { - console.error(`[${timestamp()}]: ${formatError(error)}`); - } - } - - public static debug(message: string, context?: Context, error?: unknown): void { - Logger.emit('DEBUG', message, context, error); - } - - public static info(message: string, context?: Context, error?: unknown): void { - Logger.emit('INFO', message, context, error); - } - - public static warn(message: string, context?: Context, error?: unknown): void { - Logger.emit('WARN', message, context, error); - } +export interface Logger { + debug(message: string, context?: Context, error?: unknown): void; + info(message: string, context?: Context, error?: unknown): void; + warn(message: string, context?: Context, error?: unknown): void; + error(message: string, context?: Context, error?: unknown): void; +} - public static error(message: string, context?: Context, error?: unknown): void { - Logger.emit('ERROR', message, context, error); +const emit = (level: Level, message: string, context?: Context, error?: unknown): void => { + const ctx = formatContext(context); + const line = `[${timestamp()}] ${level}: ${message}${ctx ? ` ${ctx}` : ''}`; + const write = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log; + write(line); + if (error !== undefined && error !== null) { + write(`[${timestamp()}] ${level}: ${formatError(error)}`); } +}; - private static emit(level: Level, message: string, context?: Context, error?: unknown): void { - const ctx = formatContext(context); - const line = `[${timestamp()}] ${level}: ${message}${ctx ? ` ${ctx}` : ''}`; - const write = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log; - write(line); - if (error !== undefined && error !== null) { - write(`[${timestamp()}] ${level}: ${formatError(error)}`); - } - } -} +export const createConsoleLogger = (): Logger => ({ + debug: (message, context, error) => emit('DEBUG', message, context, error), + info: (message, context, error) => emit('INFO', message, context, error), + warn: (message, context, error) => emit('WARN', message, context, error), + error: (message, context, error) => emit('ERROR', message, context, error), +}); diff --git a/tests/app.test.ts b/tests/app.test.ts new file mode 100644 index 00000000..99a49aed --- /dev/null +++ b/tests/app.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createApp } from '../src/app.ts'; +import { loadEnv } from '../src/config/env.ts'; +import { createShutdownRegistry } from '../src/framework/shutdown.ts'; +import { createHoneypotTracker } from '../src/services/HoneypotTracker.ts'; +import { createTestApp, fakeLogger, FIXED_NOW } from './helpers/app.ts'; + +test('createApp opens and migrates the database, and closes it on shutdown', async () => { + const { logger, logs } = fakeLogger(); + const env = loadEnv({ TOKEN: 't', CLIENT_ID: 'c', DB_PATH: ':memory:' }); + const app = createApp({ env, logger }); + + const tables = app.db.$client + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() as { name: string }[]; + assert.ok(tables.some((t) => t.name === 'ServerConfigs')); + assert.ok(logs.some((l) => l.message === 'Database migrations complete')); + + await app.shutdown.run(); + assert.equal(app.db.$client.open, false); +}); + +test('createApp keeps supplied dependencies', () => { + const app = createTestApp(); + const again = createApp({ ...app }); + assert.equal(again.db, app.db); + assert.equal(again.clock(), FIXED_NOW); + app.close(); +}); + +test('shutdown hooks run in reverse order and a failing hook does not block the rest', async () => { + const { logger, logs } = fakeLogger(); + const registry = createShutdownRegistry(logger); + const order: string[] = []; + registry.register(() => void order.push('first')); + registry.register(() => { + throw new Error('nope'); + }); + registry.register(async () => void order.push('last')); + await registry.run(); + assert.deepEqual(order, ['last', 'first']); + assert.equal(logs.filter((l) => l.level === 'error').length, 1); +}); + +test('honeypot tracker is scoped to its own instance', () => { + const a = createHoneypotTracker(); + const b = createHoneypotTracker(); + assert.equal(a.begin('g', 'u'), true); + assert.equal(a.begin('g', 'u'), false); + assert.equal(a.isActive('g', 'u'), true); + assert.equal(b.isActive('g', 'u'), false); + a.cancel('g', 'u'); + assert.equal(a.isActive('g', 'u'), false); +}); diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index 221c9088..c700d9a6 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; +import { after, beforeEach, test } from 'node:test'; import { DateTime } from 'luxon'; -import type { DatabaseHandle } from '../../src/db/db.ts'; import type { ServerConfig } from '../../src/db/schema.ts'; import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService.ts'; import { @@ -11,26 +10,22 @@ import { fakeMessage, fakeTextChannel, } from '../fakes/guild.ts'; -import { clearConfigs, clearProfiles, closeTestDb, createConfig, createProfiles, createTestDb } from '../helpers/db.ts'; - -console.warn = (): void => undefined; -console.error = (): void => undefined; -console.log = (): void => undefined; +import { createTestApp, type TestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; const zone = 'Europe/London'; -const at = (iso: string): DateTime => DateTime.fromISO(iso, { zone }); - -let db: DatabaseHandle; +const at = (iso: string): Date => DateTime.fromISO(iso, { zone }).toJSDate(); -before(async () => { - db = createTestDb(); -}); +const base = createTestApp(); +const { clearConfigs, clearProfiles, createConfig, createProfiles } = dbFixtures(base.db); +const appAt = (iso?: string): TestApp => + createTestApp({ db: base.db, ...(iso ? { clock: () => at(iso) } : {}) }); -after(async () => { - closeTestDb(db); +after(() => { + base.close(); }); -beforeEach(async () => { +beforeEach(() => { clearProfiles(); clearConfigs(); }); @@ -40,20 +35,20 @@ const makeConfig = (fields: Record = {}): ServerConfig => test('refreshCalendar: not configured', async () => { const config = await makeConfig(); - assert.equal(await refreshCalendar(fakeClient(), config, { zone }), 'not-configured'); + assert.equal(await refreshCalendar(appAt(), fakeClient(), config), 'not-configured'); }); test('refreshCalendar: channel fetch rejecting keeps the stored path', async () => { const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const client = fakeClient({ channelFetchError: apiError(50001) }); - assert.equal(await refreshCalendar(client, config, { zone }), 'channel-missing'); + assert.equal(await refreshCalendar(appAt(), client, config), 'channel-missing'); assert.equal(config.birthdayCalendarMessagePath, 'c1/m1'); }); test('refreshCalendar: message missing', async () => { const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const client = fakeClient({ channels: [fakeTextChannel('c1')] }); - assert.equal(await refreshCalendar(client, config, { zone }), 'message-missing'); + assert.equal(await refreshCalendar(appAt(), client, config), 'message-missing'); }); test('refreshCalendar: edit failure reports failed', async () => { @@ -63,7 +58,7 @@ test('refreshCalendar: edit failure reports failed', async () => { throw new Error('boom'); }; const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); - assert.equal(await refreshCalendar(client, config, { zone }), 'failed'); + assert.equal(await refreshCalendar(appAt(), client, config), 'failed'); }); test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', async () => { @@ -76,7 +71,7 @@ test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', asy const message = fakeMessage('m1'); const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); - const status = await refreshCalendar(client, config, { zone, now: at('2027-02-27T10:00') }); + const status = await refreshCalendar(appAt('2027-02-27T10:00'), client, config); assert.equal(status, 'updated'); const [edit] = message.edits; assert.deepEqual(edit.allowedMentions, { parse: [] }); @@ -92,7 +87,7 @@ test('refreshCalendar: empty state', async () => { const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const message = fakeMessage('m1'); const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); - assert.equal(await refreshCalendar(client, config, { zone }), 'updated'); + assert.equal(await refreshCalendar(appAt(), client, config), 'updated'); assert.match(message.edits[0].content, /There are no birthdays in this server/); }); @@ -107,7 +102,7 @@ test('notifyBirthdays: skips departed members and mentions only present ones', a const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here', 'other'], channels: [channel] }); const client = fakeClient({ guilds: { g1: guild } }); - await notifyBirthdays(client, { zone, now: at('2027-06-01T09:00') }); + await notifyBirthdays(appAt('2027-06-01T09:00'), client); assert.equal(channel.sent.length, 1); assert.equal(channel.sent[0].content, 'Happy Birthday to <@here>!'); assert.deepEqual(channel.sent[0].allowedMentions, { users: ['here'] }); @@ -123,13 +118,13 @@ test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a', 'b'], channels: [channel] }); const client = fakeClient({ guilds: { g1: guild } }); - await notifyBirthdays(client, { zone, now: at('2027-02-28T09:00') }); + await notifyBirthdays(appAt('2027-02-28T09:00'), client); assert.equal(channel.sent.length, 1); assert.match(channel.sent[0].content, /<@a>/); assert.match(channel.sent[0].content, /<@b>/); channel.sent.length = 0; - await notifyBirthdays(client, { zone, now: at('2027-03-01T09:00') }); + await notifyBirthdays(appAt('2027-03-01T09:00'), client); assert.equal(channel.sent.length, 0); }); @@ -138,6 +133,6 @@ test('notifyBirthdays: no announcements channel sends nothing', async () => { await createProfiles([{ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }]); const channel = fakeTextChannel('ann'); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'], channels: [channel] }); - await notifyBirthdays(fakeClient({ guilds: { g1: guild } }), { zone, now: at('2027-06-01T09:00') }); + await notifyBirthdays(appAt('2027-06-01T09:00'), fakeClient({ guilds: { g1: guild } })); assert.equal(channel.sent.length, 0); }); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 4b4826a6..7aae5cc9 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -1,40 +1,22 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/db/db.ts'; -import { initEnv } from '../../src/config/env.ts'; +import { after, beforeEach, test } from 'node:test'; import { type CommandContext, createReply } from '../../src/framework/command.ts'; import mybirthday from '../../src/commands/info/mybirthday.ts'; import profile from '../../src/commands/info/profile.ts'; import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; -import { - clearConfigs, - clearProfiles, - closeTestDb, - countProfiles, - createConfig, - createProfiles, - createTestDb, - findProfile, -} from '../helpers/db.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; -initEnv({ TOKEN: 't', CLIENT_ID: 'c', DB_PATH: ':memory:', BOT_TIMEZONE: 'Europe/London' }); +const app = createTestApp(); +const { clearConfigs, clearProfiles, countProfiles, createConfig, createProfiles, findProfile } = + dbFixtures(app.db); -console.warn = (): void => undefined; -console.error = (): void => undefined; -console.log = (): void => undefined; - -let db: DatabaseHandle; - -before(async () => { - db = createTestDb(); -}); - -after(async () => { - closeTestDb(db); +after(() => { + app.close(); }); -beforeEach(async () => { +beforeEach(() => { clearProfiles(); clearConfigs(); }); @@ -52,6 +34,7 @@ const exec = async ( raw.guild = guild; const resolved = command.resolve(fake.interaction)!; const ctx = { + app, interaction: fake.interaction, guild, member: raw.member, diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index cfbc2725..9ed23f54 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import Database from 'better-sqlite3'; import { migrate, migrations, type Migration } from '../../src/db/migrator.ts'; -import { Logger } from '../../src/utilities/Logger.ts'; +import { fakeLogger } from '../helpers/app.ts'; + +const { logger, logs } = fakeLogger(); type Db = Database.Database; @@ -48,13 +50,13 @@ const insertProfiles = (db: Db, rows: ReturnType[]): void => }; const runUpTo = (db: Db, to: string): string[] => - migrate(db, migrations.slice(0, migrations.findIndex((m) => m.name === to) + 1)); + migrate(db, logger, migrations.slice(0, migrations.findIndex((m) => m.name === to) + 1)); const allRows = (db: Db, sql: string): unknown[] => db.prepare(sql).all(); test('migrations on an empty database create the expected schema', () => { withDb((db) => { - assert.deepEqual(migrate(db), ALL); + assert.deepEqual(migrate(db, logger), ALL); const serverColumns = columnsOf(db, 'ServerConfigs'); for (const column of [ @@ -94,9 +96,9 @@ test('migrations on an empty database create the expected schema', () => { test('running migrations twice is a no-op', () => { withDb((db) => { - migrate(db); + migrate(db, logger); insertProfiles(db, [profileRow('s1', 'u1', null, null, 4, '2024-01-01 00:00:00.000 +00:00')]); - assert.deepEqual(migrate(db), []); + assert.deepEqual(migrate(db, logger), []); assert.equal(allRows(db, 'SELECT * FROM `UserProfiles`').length, 1); }); }); @@ -118,7 +120,7 @@ test('baseline is a no-op on a legacy sync() database and later migrations prese assert.deepEqual(allRows(db, 'SELECT * FROM `UserProfiles` ORDER BY id'), before); assert.ok(!columnsOf(db, 'ServerConfigs').includes('accessFailureCount')); - assert.deepEqual(migrate(db), ALL.slice(1)); + assert.deepEqual(migrate(db, logger), ALL.slice(1)); const config = db.prepare('SELECT * FROM `ServerConfigs` WHERE serverId = ?').get('s1') as Record< string, @@ -144,7 +146,7 @@ test('baseline is a no-op on a legacy sync() database and later migrations prese test('a database already migrated by the previous umzug setup runs nothing and keeps its data', () => { withDb((db) => { - migrate(db); + migrate(db, logger); insertProfiles(db, [profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00')]); db.exec( "INSERT INTO `ServerConfigs` (serverId, accessFailureCount, createdAt, updatedAt) VALUES ('s1', 2, '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')" @@ -158,7 +160,7 @@ test('a database already migrated by the previous umzug setup runs nothing and k }, })); - assert.deepEqual(migrate(db, spies), []); + assert.deepEqual(migrate(db, logger, spies), []); assert.deepEqual(ran, []); assert.deepEqual(allRows(db, 'SELECT userId, activityScore FROM `UserProfiles`'), [ { userId: 'u1', activityScore: 3 }, @@ -174,7 +176,7 @@ test('baseline adds missing nullable columns to an older schema', () => { db.exec( 'CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)' ); - migrate(db); + migrate(db, logger); assert.ok(columnsOf(db, 'ServerConfigs').includes('honeyPotChannelId')); assert.ok(columnsOf(db, 'UserProfiles').includes('activityScore')); }); @@ -199,19 +201,11 @@ test('002 dedupes UserProfiles and enforces uniqueness afterwards', () => { profileRow('s1', 'd', 4, 4, 0, '2024-01-01 00:00:00.000 +00:00'), ]); - const warnings: string[] = []; - const originalWarn = Logger.warn; - Logger.warn = (message: string) => { - warnings.push(message); - }; - try { - runUpTo(db, '002-userprofile-unique'); - } finally { - Logger.warn = originalWarn; - } + runUpTo(db, '002-userprofile-unique'); + const warnings = logs.filter((entry) => entry.level === 'warn'); assert.equal(warnings.length, 1); - assert.match(warnings[0], /Conflicting birthdays/); + assert.match(warnings[0].message, /Conflicting birthdays/); const rows = db .prepare( diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index d96e33c9..d5ede858 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -1,23 +1,18 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; +import { after, beforeEach, test } from 'node:test'; import { count, eq } from 'drizzle-orm'; -import type { DatabaseHandle } from '../../src/db/db.ts'; import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; -import { ConfigService } from '../../src/services/ConfigService.ts'; -import { closeTestDb, createTestDb } from '../helpers/db.ts'; +import { purgeGuild } from '../../src/services/ConfigService.ts'; +import { createTestApp } from '../helpers/app.ts'; -let handle: DatabaseHandle; - -before(() => { - handle = createTestDb(); -}); +const app = createTestApp(); +const { db } = app; after(() => { - closeTestDb(handle); + app.close(); }); beforeEach(() => { - const { db } = handle; db.delete(userProfiles).run(); db.delete(serverConfigs).run(); db.insert(serverConfigs).values([{ serverId: 's1' }, { serverId: 's2' }]).run(); @@ -31,37 +26,37 @@ beforeEach(() => { }); const profileCount = (serverId?: string): number => { - const query = handle.db.select({ n: count() }).from(userProfiles); + const query = db.select({ n: count() }).from(userProfiles); return (serverId ? query.where(eq(userProfiles.serverId, serverId)) : query).get()!.n; }; -const configCount = (): number => handle.db.select({ n: count() }).from(serverConfigs).get()!.n; +const configCount = (): number => db.select({ n: count() }).from(serverConfigs).get()!.n; test('purgeGuild deletes only the target server and reports counts', async () => { - assert.deepEqual(await ConfigService.purgeGuild('s1'), { config: true, profiles: 2 }); + assert.deepEqual(await purgeGuild(db, 's1'), { config: true, profiles: 2 }); - assert.equal(handle.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), undefined); - assert.ok(handle.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's2')).get()); + assert.equal(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), undefined); + assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's2')).get()); assert.equal(profileCount('s1'), 0); assert.equal(profileCount('s2'), 1); }); test('purgeGuild on an unknown server deletes nothing', async () => { - assert.deepEqual(await ConfigService.purgeGuild('nope', handle.db), { config: false, profiles: 0 }); + assert.deepEqual(await purgeGuild(db, 'nope'), { config: false, profiles: 0 }); assert.equal(configCount(), 2); assert.equal(profileCount(), 3); }); test('purgeGuild rolls back the profile delete when the config delete fails', async () => { - handle.sqlite.exec( + db.$client.exec( "CREATE TRIGGER block_config_delete BEFORE DELETE ON `ServerConfigs` BEGIN SELECT RAISE(ABORT, 'boom'); END" ); try { - await assert.rejects(ConfigService.purgeGuild('s1'), /boom/); + await assert.rejects(purgeGuild(db, 's1'), /boom/); } finally { - handle.sqlite.exec('DROP TRIGGER block_config_delete'); + db.$client.exec('DROP TRIGGER block_config_delete'); } assert.equal(profileCount('s1'), 2); - assert.ok(handle.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get()); + assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get()); }); diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts index 9d15f22f..2c9e47d9 100644 --- a/tests/database/schema.test.ts +++ b/tests/database/schema.test.ts @@ -3,31 +3,31 @@ import { test } from 'node:test'; import { getTableColumns, getTableName } from 'drizzle-orm'; import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; -import { closeTestDb, createTestDb } from '../helpers/db.ts'; +import { createTestApp } from '../helpers/app.ts'; const columnNames = (table: SQLiteTable): string[] => Object.values(getTableColumns(table)).map((column) => column.name); test('Drizzle schema columns exist in the migrated database', () => { - const handle = createTestDb(); + const app = createTestApp(); try { for (const table of [serverConfigs, userProfiles]) { const actual = ( - handle.sqlite.pragma(`table_info(\`${getTableName(table)}\`)`) as { name: string }[] + app.db.$client.pragma(`table_info(\`${getTableName(table)}\`)`) as { name: string }[] ).map((column) => column.name); for (const name of columnNames(table)) { assert.ok(actual.includes(name), `${getTableName(table)}.${name} missing from database`); } } } finally { - closeTestDb(handle); + app.close(); } }); test('migrated database applies column defaults and round-trips dates', () => { - const handle = createTestDb(); + const app = createTestApp(); try { - const { db } = handle; + const { db } = app; db.insert(serverConfigs).values({ serverId: 's1' }).run(); const config = db.select().from(serverConfigs).get()!; assert.equal(config.systemMessagesEnabled, false); @@ -38,7 +38,7 @@ test('migrated database applies column defaults and round-trips dates', () => { const when = new Date('2024-01-01T12:00:00.000Z'); db.update(serverConfigs).set({ firstAccessFailureAt: when }).run(); - const raw = handle.sqlite.prepare('SELECT firstAccessFailureAt AS v FROM `ServerConfigs`').get() as { + const raw = app.db.$client.prepare('SELECT firstAccessFailureAt AS v FROM `ServerConfigs`').get() as { v: string; }; assert.equal(raw.v, '2024-01-01 12:00:00.000 +00:00'); @@ -47,6 +47,6 @@ test('migrated database applies column defaults and round-trips dates', () => { db.insert(userProfiles).values({ serverId: 's1', userId: 'u1' }).run(); assert.equal(db.select().from(userProfiles).get()!.activityScore, 0); } finally { - closeTestDb(handle); + app.close(); } }); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index 0a6521d4..2082274a 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -1,24 +1,21 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/db/db.ts'; -import { ConfigService } from '../../src/services/ConfigService.ts'; -import { UserProfileService } from '../../src/services/UserProfileService.ts'; +import { after, beforeEach, test } from 'node:test'; +import { getConfig, getConfigs, updateConfig } from '../../src/services/ConfigService.ts'; import { - allProfiles, - clearConfigs, - clearProfiles, - closeTestDb, - createTestDb, -} from '../helpers/db.ts'; + clearBirthday, + getServerBirthdays, + incrementActivityScore, + setBirthday, +} from '../../src/services/UserProfileService.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; -let db: DatabaseHandle; - -before(() => { - db = createTestDb(); -}); +const app = createTestApp(); +const { db } = app; +const { allProfiles, clearConfigs, clearProfiles } = dbFixtures(db); after(() => { - closeTestDb(db); + app.close(); }); beforeEach(() => { @@ -27,28 +24,28 @@ beforeEach(() => { }); test('getConfig creates the row once and returns the same one afterwards', async () => { - const first = await ConfigService.getConfig('s1'); - const second = await ConfigService.getConfig('s1'); - assert.equal((await ConfigService.getConfigs()).length, 1); + const first = await getConfig(db, 's1'); + const second = await getConfig(db, 's1'); + assert.equal((await getConfigs(db)).length, 1); assert.equal(first.createdAt.getTime(), second.createdAt.getTime()); assert.equal(first.debug, false); }); test('updateConfig patches fields, bumps updatedAt and creates a missing row', async () => { - const created = await ConfigService.updateConfig('s1', { auditChannelId: 'c1' }); + const created = await updateConfig(db, 's1', { auditChannelId: 'c1' }); assert.equal(created.auditChannelId, 'c1'); - const updated = await ConfigService.updateConfig('s1', { debug: true, auditChannelId: null }); + const updated = await updateConfig(db, 's1', { debug: true, auditChannelId: null }); assert.equal(updated.debug, true); assert.equal(updated.auditChannelId, null); assert.ok(updated.updatedAt.getTime() >= created.updatedAt.getTime()); - assert.equal((await ConfigService.getConfig('s1')).debug, true); + assert.equal((await getConfig(db, 's1')).debug, true); }); test('incrementActivityScore upserts and increments atomically', async () => { - await UserProfileService.incrementActivityScore('s1', 'u1'); - await UserProfileService.incrementActivityScore('s1', 'u1'); - await UserProfileService.incrementActivityScore('s1', 'u2'); + await incrementActivityScore(db, 's1', 'u1'); + await incrementActivityScore(db, 's1', 'u1'); + await incrementActivityScore(db, 's1', 'u2'); const scores = allProfiles().map((p) => [p.userId, p.activityScore]); assert.deepEqual(scores, [ ['u1', 2], @@ -57,14 +54,14 @@ test('incrementActivityScore upserts and increments atomically', async () => { }); test('setBirthday upserts without losing the activity score; clearBirthday reports presence', async () => { - await UserProfileService.incrementActivityScore('s1', 'u1'); - await UserProfileService.setBirthday('s1', 'u1', 3, 4); - await UserProfileService.setBirthday('s1', 'u1', 5, 6); + await incrementActivityScore(db, 's1', 'u1'); + await setBirthday(db, 's1', 'u1', 3, 4); + await setBirthday(db, 's1', 'u1', 5, 6); const [profile] = allProfiles(); assert.deepEqual([profile.birthdayMonth, profile.birthdayDay, profile.activityScore], [5, 6, 1]); assert.equal(allProfiles().length, 1); - assert.equal(await UserProfileService.clearBirthday('s1', 'u1'), true); - assert.equal(await UserProfileService.clearBirthday('s1', 'nobody'), false); - assert.equal((await UserProfileService.getServerBirthdays('s1')).length, 0); + assert.equal(await clearBirthday(db, 's1', 'u1'), true); + assert.equal(await clearBirthday(db, 's1', 'nobody'), false); + assert.equal((await getServerBirthdays(db, 's1')).length, 0); }); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index 3f5b5c54..f531d666 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -1,30 +1,26 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/db/db.ts'; +import { after, beforeEach, test } from 'node:test'; import type { ServerConfig } from '../../src/db/schema.ts'; -import { run } from '../../src/services/DataCheckService.ts'; +import { runDataCheck } from '../../src/services/DataCheckService.ts'; import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; -import { clearConfigs, clearProfiles, closeTestDb, allProfiles, countProfiles, createConfig, createConfigs, createProfiles, createTestDb, findConfig } from '../helpers/db.ts'; - -console.warn = (): void => undefined; -console.error = (): void => undefined; -console.log = (): void => undefined; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; const policy = { minFailures: 3, graceDays: 7 }; const DAY = 24 * 60 * 60 * 1000; const t0 = new Date('2027-01-01T00:00:00Z'); -let db: DatabaseHandle; - -before(async () => { - db = createTestDb(); -}); +const base = createTestApp(); +const { allProfiles, clearConfigs, clearProfiles, countProfiles, createConfig, createConfigs, createProfiles, findConfig } = + dbFixtures(base.db); +const run = (client: ReturnType, now: Date, p = policy): Promise => + runDataCheck(createTestApp({ db: base.db, clock: () => now }), client, p); -after(async () => { - closeTestDb(db); +after(() => { + base.close(); }); -beforeEach(async () => { +beforeEach(() => { clearProfiles(); clearConfigs(); }); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index 9e2a1f29..46050c58 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -1,36 +1,16 @@ import assert from 'node:assert/strict'; -import { after, afterEach, before, beforeEach, test } from 'node:test'; +import { after, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; -import type { DatabaseHandle } from '../src/db/db.ts'; -import { closeTestDb, createTestDb } from './helpers/db.ts'; import type { DingirClient } from '../src/client/DingirClient.ts'; import { type Command, defineCommand } from '../src/framework/command.ts'; import interactionCreate from '../src/events/interactionCreate.ts'; import { fakeInteraction } from './fakes/interaction.ts'; +import { createTestApp } from './helpers/app.ts'; -let db: DatabaseHandle; -const original = { log: console.log, warn: console.warn, error: console.error }; -let errors: string[]; +const app = createTestApp(); -before(async () => { - db = createTestDb(); -}); - -after(async () => { - closeTestDb(db); -}); - -beforeEach(() => { - errors = []; - console.log = () => {}; - console.warn = () => {}; - console.error = (msg: unknown) => { - errors.push(String(msg)); - }; -}); - -afterEach(() => { - Object.assign(console, original); +after(() => { + app.close(); }); const clientWith = (...commands: Command[]) => @@ -39,7 +19,7 @@ const clientWith = (...commands: Command[]) => }) as unknown as DingirClient; const dispatch = (client: DingirClient, interaction: unknown) => - interactionCreate.run(client, interaction as never); + interactionCreate.run(app, client, interaction as never); test('ignores interactions that are not chat input commands', async () => { const { interaction, calls } = fakeInteraction({ chatInput: false }); @@ -134,7 +114,8 @@ test('a throwing handler produces exactly one ephemeral error reply', async () = payload: { content: 'Something went wrong running this command.', flags: MessageFlags.Ephemeral }, }, ]); - assert.ok(errors.some((line) => line.includes('Command failed') && line.includes('command=test'))); + const failure = app.logsAt('error').find((entry) => entry.message === 'Command failed'); + assert.equal(failure?.context?.command, 'test'); }); test('a handler that throws after replying results in a single follow-up error message', async () => { @@ -169,5 +150,5 @@ test('a failing error reply is logged and does not throw', async () => { throw new Error('reply failed'); }; await dispatch(clientWith(cmd), interaction); - assert.ok(errors.some((line) => line.includes('Could not send command error reply'))); + assert.ok(app.logsAt('error').some((entry) => entry.message === 'Could not send command error reply')); }); diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts index a66b4de5..0225046e 100644 --- a/tests/fakes/messages.ts +++ b/tests/fakes/messages.ts @@ -1,4 +1,5 @@ import { Collection, User } from 'discord.js'; +import type { App } from '../../src/app.ts'; import type { DingirClient } from '../../src/client/DingirClient.ts'; import type { Command } from '../../src/framework/command.ts'; import interactionCreate from '../../src/events/interactionCreate.ts'; @@ -76,10 +77,11 @@ export function fakeMessage(overrides: Record = {}) { } export async function runSlash( + app: App, client: DingirClient, fake: FakeInteraction, extra: Record = {} ): Promise { Object.assign(fake.interaction, { client, ...extra }); - await interactionCreate.run(client, fake.interaction); + await interactionCreate.run(app, client, fake.interaction); } diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index 50b5fc04..94f5110f 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,4 +1,5 @@ import type { Client, GuildMember } from 'discord.js'; +import type { App } from '../../src/app.ts'; import type { ServerConfig } from '../../src/db/schema.ts'; import type { CommandContext } from '../../src/framework/command.ts'; import { fakeInteraction } from './interaction.ts'; @@ -97,6 +98,7 @@ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { } export function fakeCommandContext( + app: App, options: Record, env: ReturnType ) { @@ -104,6 +106,7 @@ export function fakeCommandContext( (interaction as any).client = env.client; const replies: any[] = []; const ctx = { + app, interaction, guild: env.guild, member: env.member, diff --git a/tests/helpers/app.ts b/tests/helpers/app.ts new file mode 100644 index 00000000..9f705074 --- /dev/null +++ b/tests/helpers/app.ts @@ -0,0 +1,66 @@ +import { createApp, type App } from '../../src/app.ts'; +import { loadEnv } from '../../src/config/env.ts'; +import { openDb } from '../../src/db/db.ts'; +import { migrate } from '../../src/db/migrator.ts'; +import type { Logger } from '../../src/utilities/Logger.ts'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + level: LogLevel; + message: string; + context?: Record; + error?: unknown; +} + +export interface TestApp extends App { + /** Every entry written through app.logger, in order. */ + logs: LogEntry[]; + /** Entries at one level, for concise assertions. */ + logsAt(level: LogLevel): LogEntry[]; + close(): void; +} + +export const FIXED_NOW = new Date('2027-01-15T12:00:00Z'); + +export function fakeLogger(): { logger: Logger; logs: LogEntry[] } { + const logs: LogEntry[] = []; + const at = + (level: LogLevel): Logger[LogLevel] => + (message, context, error) => { + logs.push({ level, message, context, error }); + }; + return { + logger: { debug: at('debug'), info: at('info'), warn: at('warn'), error: at('error') }, + logs, + }; +} + +/** An App backed by a fresh in-memory migrated database, a capturing logger and a fixed clock. */ +export function createTestApp(overrides: Partial = {}): TestApp { + const { logger, logs } = fakeLogger(); + const env = + overrides.env ?? + loadEnv({ + TOKEN: 'test-token', + CLIENT_ID: '1', + DB_PATH: ':memory:', + BOT_TIMEZONE: 'Europe/London', + }); + const db = overrides.db ?? openDb(':memory:'); + if (!overrides.db) { + migrate(db.$client, overrides.logger ?? logger); + } + const app = createApp({ + env, + db, + logger, + clock: () => FIXED_NOW, + ...overrides, + }); + return Object.assign(app, { + logs, + logsAt: (level: LogLevel) => logs.filter((entry) => entry.level === level), + close: () => db.$client.close(), + }); +} diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 90449a42..f7fdd5cb 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,6 +1,5 @@ import { eq } from 'drizzle-orm'; -import { createDatabase, getDb, setDatabase, type DatabaseHandle } from '../../src/db/db.ts'; -import { migrate } from '../../src/db/migrator.ts'; +import type { Db } from '../../src/db/db.ts'; import { serverConfigs, userProfiles, @@ -8,48 +7,36 @@ import { type UserProfile, } from '../../src/db/schema.ts'; -/** An in-memory, fully migrated database that the services use until it is closed. */ -export function createTestDb(): DatabaseHandle { - const handle = createDatabase(':memory:'); - migrate(handle.sqlite); - setDatabase(handle); - return handle; -} - -export function closeTestDb(handle: DatabaseHandle): void { - setDatabase(undefined); - handle.sqlite.close(); -} - type ConfigInsert = typeof serverConfigs.$inferInsert; type ProfileInsert = typeof userProfiles.$inferInsert; -export const clearConfigs = (): void => { - getDb().delete(serverConfigs).run(); -}; - -export const clearProfiles = (): void => { - getDb().delete(userProfiles).run(); -}; - -export const createConfig = (values: ConfigInsert): ServerConfig => - getDb().insert(serverConfigs).values(values).returning().get(); - -export const createConfigs = (values: ConfigInsert[]): void => { - getDb().insert(serverConfigs).values(values).run(); -}; - -export const findConfig = (serverId: string): ServerConfig | null => - getDb().select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? null; - -export const createProfiles = (values: ProfileInsert[]): void => { - getDb().insert(userProfiles).values(values).run(); -}; - -export const allProfiles = (): UserProfile[] => getDb().select().from(userProfiles).all(); - -export const countProfiles = (): number => allProfiles().length; +/** Direct table access for arranging and asserting database state in tests. */ +export function dbFixtures(db: Db) { + const allProfiles = (): UserProfile[] => db.select().from(userProfiles).all(); + return { + clearConfigs: (): void => { + db.delete(serverConfigs).run(); + }, + clearProfiles: (): void => { + db.delete(userProfiles).run(); + }, + createConfig: (values: ConfigInsert): ServerConfig => + db.insert(serverConfigs).values(values).returning().get(), + createConfigs: (values: ConfigInsert[]): void => { + db.insert(serverConfigs).values(values).run(); + }, + findConfig: (serverId: string): ServerConfig | null => + db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? null, + createProfiles: (values: ProfileInsert[]): void => { + db.insert(userProfiles).values(values).run(); + }, + allProfiles, + countProfiles: (): number => allProfiles().length, + findProfile: (userId: string, serverId?: string): UserProfile | null => + allProfiles().find( + (p) => p.userId === userId && (serverId === undefined || p.serverId === serverId) + ) ?? null, + }; +} -export const findProfile = (userId: string, serverId?: string): UserProfile | null => - allProfiles().find((p) => p.userId === userId && (serverId === undefined || p.serverId === serverId)) ?? - null; +export type DbFixtures = ReturnType; diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 4ce4fda2..51ced3a1 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, test } from 'node:test'; -import { Logger, formatContext } from '../src/utilities/Logger.ts'; +import { createConsoleLogger, formatContext } from '../src/utilities/Logger.ts'; type Method = 'log' | 'warn' | 'error'; const original = { log: console.log, warn: console.warn, error: console.error }; @@ -19,23 +19,6 @@ afterEach(() => { Object.assign(console, original); }); -test('writeError prints the stack for Error instances', () => { - const err = new Error('boom'); - Logger.writeError('failed', err); - assert.equal(lines.error.length, 2); - assert.match(lines.error[0], /^\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\]: failed$/); - assert.ok(lines.error[1].includes(err.stack as string)); -}); - -test('writeError stringifies non-Error values and skips missing ones', () => { - Logger.writeError('failed', 'plain text'); - Logger.writeError('failed', 42); - Logger.writeError('failed'); - assert.equal(lines.error.length, 5); - assert.ok(lines.error[1].endsWith(': plain text')); - assert.ok(lines.error[3].endsWith(': 42')); -}); - test('formatContext renders key=value pairs', () => { assert.equal( formatContext({ guild: '123', name: 'a b', count: 3, ok: true, none: null }), @@ -44,6 +27,8 @@ test('formatContext renders key=value pairs', () => { assert.equal(formatContext(), ''); }); +const Logger = createConsoleLogger(); + test('level helpers include level, message, context and error', () => { Logger.info('hello', { guild: '1' }); Logger.warn('careful'); diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index 36d0d429..a4d0ba01 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -1,38 +1,29 @@ import assert from 'node:assert/strict'; -import { after, afterEach, before, beforeEach, test } from 'node:test'; +import { after, beforeEach, afterEach, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { DatabaseHandle } from '../../src/db/db.ts'; import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; import messageCreate from '../../src/events/messageCreate.ts'; import messageDelete from '../../src/events/messageDelete.ts'; import messageUpdate from '../../src/events/messageUpdate.ts'; -import { ConfigService } from '../../src/services/ConfigService.ts'; -import { HoneyPotEnforcementService } from '../../src/services/HoneyPotEnforcementService.ts'; -import { UserProfileService } from '../../src/services/UserProfileService.ts'; -import { clearProfiles, closeTestDb, createTestDb, findProfile } from '../helpers/db.ts'; +import { updateConfig } from '../../src/services/ConfigService.ts'; +import { incrementActivityScore } from '../../src/services/UserProfileService.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages.ts'; -let db: DatabaseHandle; -const original = { log: console.log, warn: console.warn, error: console.error }; +const app = createTestApp(); +const { clearProfiles, findProfile } = dbFixtures(app.db); -before(async () => { - db = createTestDb(); - await ConfigService.updateConfig('guild-1', { auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); +after(() => { + app.close(); }); -after(async () => { - closeTestDb(db); +beforeEach(async () => { + await updateConfig(app.db, 'guild-1', { auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); }); -beforeEach(() => { - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; -}); - -afterEach(async () => { - Object.assign(console, original); - HoneyPotEnforcementService.cancel('guild-1', 'u1'); +afterEach(() => { + app.honeypot.cancel('guild-1', 'u1'); clearProfiles(); }); @@ -50,7 +41,7 @@ test('honeypot message bans the member and does not count activity', async () => }, }), }); - await messageCreate.run(sink.client, message); + await messageCreate.run(app, sink.client, message); assert.ok(banned); assert.equal(await score(), null); assert.equal(sink.sent.length, 1); @@ -59,25 +50,25 @@ test('honeypot message bans the member and does not count activity', async () => test('normal messages increment activity once', async () => { const sink = fakeAuditClient(); - await messageCreate.run(sink.client, fakeMessage()); - await messageCreate.run(sink.client, fakeMessage()); + await messageCreate.run(app, sink.client, fakeMessage()); + await messageCreate.run(app, sink.client, fakeMessage()); assert.equal(await score(), 2); assert.equal(sink.sent.length, 0); }); test('bot, webhook, system and DM messages do not count', async () => { const sink = fakeAuditClient(); - await messageCreate.run(sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); - await messageCreate.run(sink.client, fakeMessage({ webhookId: 'w1' })); - await messageCreate.run(sink.client, fakeMessage({ system: true })); - await messageCreate.run(sink.client, fakeMessage({ guildId: null })); + await messageCreate.run(app, sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); + await messageCreate.run(app, sink.client, fakeMessage({ webhookId: 'w1' })); + await messageCreate.run(app, sink.client, fakeMessage({ system: true })); + await messageCreate.run(app, sink.client, fakeMessage({ guildId: null })); assert.equal(await score(), null); }); test('messageDelete never changes the activity score', async () => { const sink = fakeAuditClient(); - await UserProfileService.incrementActivityScore('guild-1', 'u1'); - await messageDelete.run(sink.client, fakeMessage()); + await incrementActivityScore(app.db, 'guild-1', 'u1'); + await messageDelete.run(app, sink.client, fakeMessage()); assert.equal(await score(), 1); assert.equal(sink.sent.length, 1); }); @@ -86,7 +77,7 @@ test('messageDelete audits uncached messages without content', async () => { const sink = fakeAuditClient(); const message = fakeMessage({ partial: true, author: null, id: 'm42', channelId: 'chan-7' }); message.author = null; - await messageDelete.run(sink.client, message); + await messageDelete.run(app, sink.client, message); const embed = auditJson(sink.sent); assert.match(embed.description, /not cached/); assert.deepEqual( @@ -102,7 +93,7 @@ test('messageDelete handles very long content and attachments', async () => { attachments: new Collection([['a', { name: 'a.png' }], ['b', { name: 'b.png' }]]), embeds: [{}], }); - await messageDelete.run(sink.client, message); + await messageDelete.run(app, sink.client, message); const embed = auditJson(sink.sent); const text = embed.fields.find((f: any) => f.name === 'Message'); assert.ok(text.value.length <= 1024); @@ -112,15 +103,16 @@ test('messageDelete handles very long content and attachments', async () => { test('messageDelete skips bots and active honeypot enforcement', async () => { const sink = fakeAuditClient(); - await messageDelete.run(sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); - HoneyPotEnforcementService.begin('guild-1', 'u1'); - await messageDelete.run(sink.client, fakeMessage()); + await messageDelete.run(app, sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); + app.honeypot.begin('guild-1', 'u1'); + await messageDelete.run(app, sink.client, fakeMessage()); assert.equal(sink.sent.length, 0); }); test('messageUpdate notes an uncached previous message', async () => { const sink = fakeAuditClient(); await messageUpdate.run( + app, sink.client, fakeMessage({ partial: true, content: null }), fakeMessage({ content: 'edited' }) @@ -140,19 +132,20 @@ test('messageUpdate fetches partial new messages and returns when fetching fails throw new Error('Unknown Message'); }, }); - await messageUpdate.run(sink.client, fakeMessage({ content: 'old' }), failing); + await messageUpdate.run(app, sink.client, fakeMessage({ content: 'old' }), failing); assert.equal(sink.sent.length, 0); const fetched = fakeMessage({ content: 'new' }); const partial = fakeMessage({ partial: true, fetch: async () => fetched }); - await messageUpdate.run(sink.client, fakeMessage({ content: 'old' }), partial); + await messageUpdate.run(app, sink.client, fakeMessage({ content: 'old' }), partial); assert.equal(sink.sent.length, 1); }); test('messageUpdate skips unchanged content and bots', async () => { const sink = fakeAuditClient(); - await messageUpdate.run(sink.client, fakeMessage(), fakeMessage()); + await messageUpdate.run(app, sink.client, fakeMessage(), fakeMessage()); await messageUpdate.run( + app, sink.client, fakeMessage({ content: 'a' }), fakeMessage({ content: 'b', author: fakeUser('u1', { bot: true }) }) @@ -163,6 +156,7 @@ test('messageUpdate skips unchanged content and bots', async () => { test('messageUpdate truncates long content', async () => { const sink = fakeAuditClient(); await messageUpdate.run( + app, sink.client, fakeMessage({ content: 'a'.repeat(4000) }), fakeMessage({ content: 'b'.repeat(4000) }) @@ -173,9 +167,9 @@ test('messageUpdate truncates long content', async () => { test('guildMemberRemove handles partial members and deletes profile data', async () => { const sink = fakeAuditClient(); - await UserProfileService.incrementActivityScore('guild-1', 'u1'); + await incrementActivityScore(app.db, 'guild-1', 'u1'); const partial = { partial: true, guild: { id: 'guild-1' }, user: fakeUser('u1') } as any; - await guildMemberRemove.run(sink.client, partial); + await guildMemberRemove.run(app, sink.client, partial); assert.equal(await score(), null); const embed = auditJson(sink.sent); assert.equal(embed.description, 'Member left'); @@ -185,10 +179,10 @@ test('guildMemberRemove handles partial members and deletes profile data', async test('guildMemberRemove during honeypot enforcement only deletes data', async () => { const sink = fakeAuditClient(); - await UserProfileService.incrementActivityScore('guild-1', 'u1'); - HoneyPotEnforcementService.begin('guild-1', 'u1'); + await incrementActivityScore(app.db, 'guild-1', 'u1'); + app.honeypot.begin('guild-1', 'u1'); const member = fakeMember('u1', { guild: { id: 'guild-1' } }) as any; - await guildMemberRemove.run(sink.client, member); + await guildMemberRemove.run(app, sink.client, member); assert.equal(await score(), null); assert.equal(sink.sent.length, 0); }); diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 29b136a2..1d2ef7c7 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -1,20 +1,16 @@ import assert from 'node:assert/strict'; -import { after, before, test } from 'node:test'; +import { after, test } from 'node:test'; import { Collection } from 'discord.js'; -import type { DatabaseHandle } from '../../src/db/db.ts'; import noroles from '../../src/commands/admin/noroles.ts'; import rolesince from '../../src/commands/admin/rolesince.ts'; import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing.ts'; -import { closeTestDb, createTestDb } from '../helpers/db.ts'; +import { createTestApp, FIXED_NOW } from '../helpers/app.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages.ts'; -let db: DatabaseHandle; -before(async () => { - db = createTestDb(); -}); -after(async () => { - closeTestDb(db); +const app = createTestApp(); +after(() => { + app.close(); }); const DAY = 24 * 60 * 60 * 1000; @@ -53,7 +49,7 @@ test('empty listing returns the empty message', () => { }); test('rolesince filters by days in server and excludes bots', async () => { - const now = Date.now(); + const now = FIXED_NOW.getTime(); const old = fakeMember('1', { joinedTimestamp: now - 40 * DAY }); const recent = fakeMember('2', { joinedTimestamp: now - 2 * DAY }); const bot = fakeMember('3', { joinedTimestamp: now - 90 * DAY, user: fakeUser('3', { bot: true }) }); @@ -67,7 +63,7 @@ test('rolesince filters by days in server and excludes bots', async () => { const fake = fakeInteraction({ commandName: 'rolesince', options: { role }, guild }); Object.assign(fake.interaction.options, { getInteger: () => 30 }); - await runSlash(sink.client, fake); + await runSlash(app, sink.client, fake); assert.equal(fetched, true); assert.equal(fake.calls[0].method, 'deferReply'); const content = fake.calls.at(-1)!.payload.content as string; @@ -78,7 +74,7 @@ test('rolesince filters by days in server and excludes bots', async () => { const all = fakeInteraction({ commandName: 'rolesince', options: { role }, guild }); Object.assign(all.interaction.options, { getInteger: () => null }); - await runSlash(sink.client, all); + await runSlash(app, sink.client, all); const everyone = all.calls.at(-1)!.payload.content as string; assert.match(everyone, /<@1>/); assert.match(everyone, /<@2>/); @@ -96,7 +92,7 @@ test('noroles lists only non-bot members with just the everyone role', async () }; const sink = fakeAuditClient('a', [noroles]); const fake = fakeInteraction({ commandName: 'noroles', guild }); - await runSlash(sink.client, fake); + await runSlash(app, sink.client, fake); assert.equal(fake.calls[0].method, 'deferReply'); const content = fake.calls.at(-1)!.payload.content as string; assert.match(content, /<@1>/); diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index a4c838d3..2c0e550e 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -1,33 +1,20 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; +import { after, before, test } from 'node:test'; import { Collection, PermissionFlagsBits } from 'discord.js'; -import type { DatabaseHandle } from '../../src/db/db.ts'; import post from '../../src/commands/admin/post.ts'; -import { ConfigService } from '../../src/services/ConfigService.ts'; -import { closeTestDb, createTestDb } from '../helpers/db.ts'; +import { updateConfig } from '../../src/services/ConfigService.ts'; +import { createTestApp } from '../helpers/app.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages.ts'; -let db: DatabaseHandle; -const original = { log: console.log, warn: console.warn, error: console.error }; +const app = createTestApp(); before(async () => { - db = createTestDb(); - await ConfigService.updateConfig('guild-1', { auditChannelId: 'audit-1' }); -}); - -after(async () => { - closeTestDb(db); -}); - -beforeEach(() => { - console.log = () => {}; - console.warn = () => {}; - console.error = () => {}; + await updateConfig(app.db, 'guild-1', { auditChannelId: 'audit-1' }); }); after(() => { - Object.assign(console, original); + app.close(); }); const target = (send: () => Promise, granted: bigint[]) => ({ @@ -72,7 +59,7 @@ test('send failure replies with an error and neither succeeds nor audits', async }, allPerms), { content: 'hi' } ); - await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); const reply = fake.calls.at(-1)!; assert.equal(reply.method, 'editReply'); assert.match(reply.payload.content, /error was encountered/); @@ -89,7 +76,7 @@ test('success audits then replies with the jump link', async () => { }, allPerms), { content: 'hello world', attachment: { name: 'pic.png' } } ); - await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.equal(sentPayloads.length, 1); assert.equal(sink.sent.length, 1); const embed = auditJson(sink.sent); @@ -112,7 +99,7 @@ test('missing permission replies clearly and does not send', async () => { }, [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]), { attachment: { name: 'pic.png' } } ); - await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.equal(sends, 0); assert.equal(sink.sent.length, 0); assert.match(fake.calls.at(-1)!.payload.content, /AttachFiles/); @@ -121,7 +108,7 @@ test('missing permission replies clearly and does not send', async () => { test('requires content or attachment and limits content length', async () => { const sink = fakeAuditClient('audit-1', [post]); const fake = setup(target(async () => ({ url: 'x' }), allPerms), {}); - await runSlash(sink.client, fake, { member: fakeMember('user-1') }); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.match(fake.calls.at(-1)!.payload.content, /at least text or an attachment/); const content = post.toJSON().options!.find((o: any) => o.name === 'content') as any; diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts index 9f211eac..1e824898 100644 --- a/tests/onboarding/OnboardingService.test.ts +++ b/tests/onboarding/OnboardingService.test.ts @@ -8,6 +8,9 @@ import { parseRoleIds, } from '../../src/services/OnboardingService.ts'; import { fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { createTestApp } from '../helpers/app.ts'; + +const app = createTestApp(); const okImage = async () => new AttachmentBuilder(Buffer.from('x')).setName('welcome-image.png'); const failImage = async () => { @@ -30,7 +33,7 @@ test('completedScreening only for pending true to false', () => { test('unconfigured server: audit done, roles and welcome skipped, no debug audit', async () => { const env = fakeOnboarding(); - const result = await complete(env.client, env.member, env.config, {}, { renderImage: okImage }); + const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: okImage }); assert.equal(result.audit, 'done'); assert.match(result.roles, /^skipped:/); assert.match(result.welcome, /^skipped:/); @@ -43,7 +46,7 @@ test('guest roles are added in one call and unassignable ones filtered', async ( roles: [role('ok1', 1), role('ok2', 2), role('managed', 1, true), role('high', 10), role('guild-1', 0)], config: { guestRoleIds: 'ok1,ok2,managed,high,guild-1,gone' }, }); - const result = await complete(env.client, env.member, env.config, {}, { renderImage: okImage }); + const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: okImage }); assert.equal(result.roles, 'done'); assert.deepEqual(env.roleAdds, [['ok1', 'ok2']]); assert.deepEqual( @@ -54,7 +57,7 @@ test('guest roles are added in one call and unassignable ones filtered', async ( test('no assignable roles skips the roles step without calling add', async () => { const env = fakeOnboarding({ roles: [role('high', 10)], config: { guestRoleIds: 'high' } }); - const result = await complete(env.client, env.member, env.config); + const result = await complete(app, env.client, env.member, env.config); assert.match(result.roles, /^skipped:/); assert.equal(env.roleAdds.length, 0); }); @@ -69,7 +72,7 @@ test('a failing roles step is audited and does not stop the welcome step', async welcomeMessage: 'Hi {member}', }, }); - const result = await complete(env.client, env.member, env.config); + const result = await complete(app, env.client, env.member, env.config); assert.match(result.roles, /^failed:missing permissions/); assert.equal(result.welcome, 'done'); assert.equal(env.systemSends.length, 1); @@ -82,7 +85,7 @@ test('audit failure does not stop later steps', async () => { failAuditSend: true, config: { guestRoleIds: 'r1' }, }); - const result = await complete(env.client, env.member, env.config); + const result = await complete(app, env.client, env.member, env.config); assert.match(result.audit, /^skipped:/); assert.equal(result.roles, 'done'); }); @@ -91,7 +94,7 @@ test('welcome replaces every {member} and does not ping others', async () => { const env = fakeOnboarding({ config: { systemMessagesEnabled: true, welcomeMessage: '{member} hello {member}!' }, }); - const result = await complete(env.client, env.member, env.config); + const result = await complete(app, env.client, env.member, env.config); assert.equal(result.welcome, 'done'); assert.equal(env.systemSends[0].content, '<@member-1> hello <@member-1>!'); assert.deepEqual(env.systemSends[0].allowedMentions, { users: ['member-1'] }); @@ -99,16 +102,16 @@ test('welcome replaces every {member} and does not ping others', async () => { test('welcome skip reasons', async () => { const disabled = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); - assert.match((await complete(disabled.client, disabled.member, disabled.config)).welcome, /disabled/); + assert.match((await complete(app, disabled.client, disabled.member, disabled.config)).welcome, /disabled/); const empty = fakeOnboarding({ config: { systemMessagesEnabled: true } }); - assert.match((await complete(empty.client, empty.member, empty.config)).welcome, /^skipped:/); + assert.match((await complete(app, empty.client, empty.member, empty.config)).welcome, /^skipped:/); const noChannel = fakeOnboarding({ systemChannel: false, config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, }); - assert.match((await complete(noChannel.client, noChannel.member, noChannel.config)).welcome, /no system channel/); + assert.match((await complete(app, noChannel.client, noChannel.member, noChannel.config)).welcome, /no system channel/); }); test('image failure still sends text and audits the image failure', async () => { @@ -119,7 +122,7 @@ test('image failure still sends text and audits the image failure', async () => welcomeMessageBackgroundUrl: 'https://example.com/bg.png', }, }); - const result = await complete(env.client, env.member, env.config, {}, { renderImage: failImage }); + const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: failImage }); assert.equal(result.welcome, 'done'); assert.equal(env.systemSends.length, 1); assert.equal(env.systemSends[0].files, undefined); @@ -130,7 +133,7 @@ test('image failure with no text fails the welcome step', async () => { const env = fakeOnboarding({ config: { systemMessagesEnabled: true, welcomeMessageBackgroundUrl: 'https://example.com/bg.png' }, }); - const result = await complete(env.client, env.member, env.config, {}, { renderImage: failImage }); + const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: failImage }); assert.match(result.welcome, /^failed:/); assert.equal(env.systemSends.length, 0); }); @@ -140,7 +143,7 @@ test('welcome send failure is audited and reported', async () => { failSystemSend: true, config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, }); - const result = await complete(env.client, env.member, env.config); + const result = await complete(app, env.client, env.member, env.config); assert.match(result.welcome, /^failed:cannot send/); assert.ok(env.auditSends.some((a) => /welcome message/.test(JSON.stringify(a.embeds[0])))); }); @@ -156,7 +159,7 @@ test('dryRun makes no role, send or audit calls and returns the payload', async welcomeMessageBackgroundUrl: 'https://example.com/bg.png', }, }); - const result = await complete(env.client, env.member, env.config, { dryRun: true }, { renderImage: okImage }); + const result = await complete(app, env.client, env.member, env.config, { dryRun: true }, { renderImage: okImage }); assert.equal(env.roleAdds.length, 0); assert.equal(env.systemSends.length, 0); assert.equal(env.auditSends.length, 0); @@ -168,11 +171,11 @@ test('dryRun makes no role, send or audit calls and returns the payload', async test('debug summary audit is sent only when debug is enabled', async () => { const off = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); - await complete(off.client, off.member, off.config); + await complete(app, off.client, off.member, off.config); assert.equal(off.auditSends.length, 1); const on = fakeOnboarding({ config: { debug: true } }); - const result = await complete(on.client, on.member, on.config); + const result = await complete(app, on.client, on.member, on.config); assert.equal(result.debug, 'done'); assert.equal(on.auditSends.length, 2); const summary = JSON.stringify(on.auditSends[1].embeds[0]); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index c12a2dad..0a8b4998 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/db/db.ts'; +import { after, beforeEach, test } from 'node:test'; import type { Handler } from '../../src/framework/command.ts'; import NewRolesGroup from '../../src/commands/config/groups/newroles.ts'; import WelcomeGroup, { @@ -9,16 +8,14 @@ import WelcomeGroup, { validateWelcomeMessage, } from '../../src/commands/config/groups/welcome.ts'; import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; -import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; -let db: DatabaseHandle; - -before(() => { - db = createTestDb(); -}); +const app = createTestApp(); +const { clearConfigs, createConfig } = dbFixtures(app.db); after(() => { - closeTestDb(db); + app.close(); }); beforeEach(() => { @@ -31,7 +28,7 @@ const handler = (group: typeof NewRolesGroup, name: string): Handler => test('newroles set rejects @everyone, managed and too-high roles and saves nothing', async () => { const env = fakeOnboarding({ roles: [role('ok', 1), role('m', 1, true), role('hi', 10)] }); const { ctx, replies } = fakeCommandContext( - { + app, { 'role-one': env.guild.roles.cache.get('ok'), 'role-two': env.guild.roles.cache.get('m'), 'role-three': env.guild.roles.cache.get('hi'), @@ -43,7 +40,7 @@ test('newroles set rejects @everyone, managed and too-high roles and saves nothi assert.match(replies[0].content, /managed/); assert.match(replies[0].content, /highest role/); - const everyone = fakeCommandContext({ 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, env); + const everyone = fakeCommandContext(app, { 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, env); await handler(NewRolesGroup, 'set')(everyone.ctx); assert.match(everyone.replies[0].content, /everyone/); assert.equal(everyone.ctx.config, env.config); @@ -54,7 +51,7 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1' }); const a = env.guild.roles.cache.get('a'); const { ctx, replies } = fakeCommandContext( - { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, + app, { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, env ); await handler(NewRolesGroup, 'set')(ctx); @@ -65,14 +62,14 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { test('newroles get is read-only: no audit, marks missing roles', async () => { const env = fakeOnboarding({ roles: [role('a')], config: { guestRoleIds: 'a,gone' } }); - const { ctx, replies } = fakeCommandContext({}, env); + const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(NewRolesGroup, 'get')(ctx); assert.equal(env.auditSends.length, 0); assert.match(replies[0].content, /<@&a>/); assert.match(replies[0].content, /gone \(this role no longer exists\)/); const empty = fakeOnboarding(); - const e = fakeCommandContext({}, empty); + const e = fakeCommandContext(app, {}, empty); await handler(NewRolesGroup, 'get')(e.ctx); assert.equal(e.replies[0].content, 'No new-member roles are configured.'); }); @@ -80,7 +77,7 @@ test('newroles get is read-only: no audit, marks missing roles', async () => { test('newroles clear nulls roles and audits', async () => { const env = fakeOnboarding({ config: { guestRoleIds: 'a' } }); createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1', guestRoleIds: 'a' }); - const { ctx } = fakeCommandContext({}, env); + const { ctx } = fakeCommandContext(app, {}, env); await handler(NewRolesGroup, 'clear')(ctx); assert.equal(ctx.config.guestRoleIds, null); assert.equal(env.auditSends.length, 1); @@ -99,7 +96,7 @@ test('welcome validators', () => { test('welcome set-image rejects non-http URLs without saving', async () => { const env = fakeOnboarding(); - const { ctx, replies } = fakeCommandContext({ url: 'ftp://example.com/a.png' }, env); + const { ctx, replies } = fakeCommandContext(app, { url: 'ftp://example.com/a.png' }, env); await handler(WelcomeGroup as never, 'set-image')(ctx); assert.match(replies[0].content, /http/); assert.equal(env.config.welcomeMessageBackgroundUrl, null); @@ -107,7 +104,7 @@ test('welcome set-image rejects non-http URLs without saving', async () => { test('welcome set-image rejects an unreachable image and does not save', async () => { const env = fakeOnboarding(); - const { ctx, replies } = fakeCommandContext({ url: 'http://127.0.0.1:1/bg.png' }, env); + const { ctx, replies } = fakeCommandContext(app, { url: 'http://127.0.0.1:1/bg.png' }, env); await handler(WelcomeGroup as never, 'set-image')(ctx); assert.match(replies[0].content, /could not be used/); assert.equal(env.config.welcomeMessageBackgroundUrl, null); @@ -115,23 +112,23 @@ test('welcome set-image rejects an unreachable image and does not save', async ( test('welcome set-message, get and clear', async () => { const env = fakeOnboarding(); - const set = fakeCommandContext({ text: 'Hi {member}' }, env); + const set = fakeCommandContext(app, { text: 'Hi {member}' }, env); await handler(WelcomeGroup as never, 'set-message')(set.ctx); assert.equal(set.ctx.config.welcomeMessage, 'Hi {member}'); - const get = fakeCommandContext({}, { ...env, config: set.ctx.config }); + const get = fakeCommandContext(app, {}, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'get')(get.ctx); assert.match(get.replies[0].content, /Hi \{member\}/); assert.match(get.replies[0].content, /Welcome image: Not set/); - const clear = fakeCommandContext({ which: 'all' }, { ...env, config: set.ctx.config }); + const clear = fakeCommandContext(app, { which: 'all' }, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'clear')(clear.ctx); assert.equal(clear.ctx.config.welcomeMessage, null); }); test('welcome preview explains why nothing would be sent', async () => { const env = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); - const { ctx, replies } = fakeCommandContext({}, env); + const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); assert.match(replies[0].content, /disabled/); assert.equal(env.systemSends.length, 0); @@ -139,7 +136,7 @@ test('welcome preview explains why nothing would be sent', async () => { test('welcome preview shows the text without sending', async () => { const env = fakeOnboarding({ config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true } }); - const { ctx, replies } = fakeCommandContext({}, env); + const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); assert.match(replies[0].content, /hi <@member-1> <@member-1>/); assert.equal(env.systemSends.length, 0); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 9ec30eb7..97460d0e 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -1,19 +1,16 @@ import assert from 'node:assert/strict'; -import { after, before, beforeEach, test } from 'node:test'; -import type { DatabaseHandle } from '../../src/db/db.ts'; +import { after, beforeEach, test } from 'node:test'; import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; -import { clearConfigs, closeTestDb, createConfig, createTestDb } from '../helpers/db.ts'; import { fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; -let db: DatabaseHandle; +const app = createTestApp(); +const { clearConfigs, createConfig } = dbFixtures(app.db); -before(async () => { - db = createTestDb(); -}); - -after(async () => { - closeTestDb(db); +after(() => { + app.close(); }); beforeEach(async () => { @@ -25,7 +22,7 @@ const update = async (oldState: Record, newPending: boolean | n const env = fakeOnboarding({ roles: [role('r1')] }); const newMember = Object.assign(env.member, { pending: newPending }); const oldMember = { guild: env.guild, id: 'member-1', ...oldState } as any; - await guildMemberUpdate.run(env.client as any, oldMember, newMember); + await guildMemberUpdate.run(app, env.client as any, oldMember, newMember); return env.roleAdds.length; }; @@ -44,7 +41,7 @@ test('guildMemberUpdate skips when the old member is partial', async () => { test('guildMemberAdd audits the join and onboards immediately when not pending', async () => { const env = fakeOnboarding({ roles: [role('r1')] }); Object.assign(env.member, { pending: false }); - await guildMemberAdd.run(env.client as any, env.member); + await guildMemberAdd.run(app, env.client as any, env.member); assert.equal(env.roleAdds.length, 1); assert.match(JSON.stringify(env.auditSends[0].embeds[0]), /New member joined/); }); @@ -52,7 +49,7 @@ test('guildMemberAdd audits the join and onboards immediately when not pending', test('guildMemberAdd waits for screening when the member is pending', async () => { const env = fakeOnboarding({ roles: [role('r1')] }); Object.assign(env.member, { pending: true }); - await guildMemberAdd.run(env.client as any, env.member); + await guildMemberAdd.run(app, env.client as any, env.member); assert.equal(env.roleAdds.length, 0); assert.equal(env.auditSends.length, 1); }); diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts index 41bf55fb..6c155a78 100644 --- a/tests/services/AuditEmbed.test.ts +++ b/tests/services/AuditEmbed.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { GuildMember } from 'discord.js'; -import { AuditEmbed } from '../../src/services/AuditEmbed.ts'; +import { AuditEmbed, memberAuditEmbed } from '../../src/services/AuditEmbed.ts'; import { EmbedColours } from '../../src/resources/EmbedColours.ts'; const total = (json: ReturnType): number => @@ -92,13 +92,13 @@ test('toJSON does not mutate the builder', () => { assert.equal(embed.data.fields?.length, 8); }); -test('forMember sets author, colour, description and timestamp', () => { +test('memberAuditEmbed sets author, colour, description and timestamp', () => { const member = { displayName: 'Alice', user: { tag: 'alice#0' }, displayAvatarURL: () => 'https://cdn.example/a.png', } as unknown as GuildMember; - const json = AuditEmbed.forMember(member, EmbedColours.info, 'hello').toJSON(); + const json = memberAuditEmbed(member, EmbedColours.info, 'hello').toJSON(); assert.equal(json.author?.name, 'Alice'); assert.equal(json.author?.icon_url, 'https://cdn.example/a.png'); assert.equal(json.description, 'hello'); diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index c651929a..bfa6c34a 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -3,8 +3,9 @@ import { test } from 'node:test'; import { Client, EmbedBuilder } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; import { sendAudit } from '../../src/services/AuditService.ts'; +import { createTestApp } from '../helpers/app.ts'; -console.warn = (): void => undefined; +const app = createTestApp(); const config = (auditChannelId: string | null): ServerConfig => ({ serverId: 'g1', auditChannelId }) as unknown as ServerConfig; @@ -18,7 +19,7 @@ test('returns false without fetching when no audit channel configured', async () fetched = true; return null; }); - assert.equal(await sendAudit(client, config(null), embed), false); + assert.equal(await sendAudit(app, client, config(null), embed), false); assert.equal(fetched, false); }); @@ -26,13 +27,13 @@ test('returns false when fetch rejects', async () => { const client = clientWith(async () => { throw new Error('Missing Access'); }); - assert.equal(await sendAudit(client, config('c1'), embed), false); + assert.equal(await sendAudit(app, client, config('c1'), embed), false); }); test('returns false when channel is missing or not sendable', async () => { - assert.equal(await sendAudit(clientWith(async () => null), config('c1'), embed), false); + assert.equal(await sendAudit(app, clientWith(async () => null), config('c1'), embed), false); const unsendable = clientWith(async () => ({ isSendable: () => false })); - assert.equal(await sendAudit(unsendable, config('c1'), embed), false); + assert.equal(await sendAudit(app, unsendable, config('c1'), embed), false); }); test('returns false when send rejects', async () => { @@ -42,7 +43,7 @@ test('returns false when send rejects', async () => { throw new Error('Missing Permissions'); }, })); - assert.equal(await sendAudit(client, config('c1'), embed), false); + assert.equal(await sendAudit(app, client, config('c1'), embed), false); }); test('sends embed and files, returns true', async () => { @@ -51,6 +52,6 @@ test('sends embed and files, returns true', async () => { isSendable: () => true, send: async (payload: unknown) => void sent.push(payload), })); - assert.equal(await sendAudit(client, config('c1'), embed), true); + assert.equal(await sendAudit(app, client, config('c1'), embed), true); assert.deepEqual(sent, [{ embeds: [embed], files: undefined }]); }); diff --git a/tests/services/Scheduler.test.ts b/tests/services/Scheduler.test.ts index a209904d..91d9ac2f 100644 --- a/tests/services/Scheduler.test.ts +++ b/tests/services/Scheduler.test.ts @@ -1,12 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { type ScheduleFn, Scheduler } from '../../src/services/Scheduler.ts'; +import { fakeLogger } from '../helpers/app.ts'; -const silence = (): void => { - console.warn = (): void => undefined; - console.error = (): void => undefined; -}; -silence(); +const { logger } = fakeLogger(); const fakeSchedule = (): { fn: ScheduleFn; calls: unknown[]; cancelled: () => number; fire: () => void } => { const calls: unknown[] = []; @@ -22,7 +19,7 @@ const fakeSchedule = (): { fn: ScheduleFn; calls: unknown[]; cancelled: () => nu test('tasks run sequentially in order with error isolation', async () => { const order: string[] = []; - const scheduler = new Scheduler('0 0 * * *', 'utc', [ + const scheduler = new Scheduler(logger, '0 0 * * *', 'utc', [ { name: 'a', run: async () => void order.push('a') }, { name: 'boom', @@ -43,7 +40,7 @@ test('overlapping runNow is skipped, then allowed again', async () => { const gate = new Promise((resolve) => { release = resolve; }); - const scheduler = new Scheduler('0 0 * * *', 'utc', [ + const scheduler = new Scheduler(logger, '0 0 * * *', 'utc', [ { name: 'slow', run: async () => { @@ -63,7 +60,7 @@ test('overlapping runNow is skipped, then allowed again', async () => { test('start schedules with cron and tz; second start is a no-op', () => { const fake = fakeSchedule(); - const scheduler = new Scheduler('0 9 * * *', 'Europe/London', [], fake.fn); + const scheduler = new Scheduler(logger, '0 9 * * *', 'Europe/London', [], fake.fn); scheduler.start(); scheduler.start(); assert.deepEqual(fake.calls, [{ rule: '0 9 * * *', tz: 'Europe/London' }]); @@ -73,7 +70,7 @@ test('start schedules with cron and tz; second start is a no-op', () => { test('fired job triggers tasks', async () => { const fake = fakeSchedule(); let ran = 0; - const scheduler = new Scheduler('* * * * *', 'utc', [{ name: 't', run: async () => void (ran += 1) }], fake.fn); + const scheduler = new Scheduler(logger, '* * * * *', 'utc', [{ name: 't', run: async () => void (ran += 1) }], fake.fn); scheduler.start(); fake.fire(); await new Promise((resolve) => setImmediate(resolve)); @@ -81,13 +78,13 @@ test('fired job triggers tasks', async () => { }); test('start throws when the schedule is invalid', () => { - const scheduler = new Scheduler('nope', 'utc', [], () => null); + const scheduler = new Scheduler(logger, 'nope', 'utc', [], () => null); assert.throws(() => scheduler.start(), /Invalid cron/); }); test('stop cancels the job and clears nextInvocation', async () => { const fake = fakeSchedule(); - const scheduler = new Scheduler('* * * * *', 'utc', [], fake.fn); + const scheduler = new Scheduler(logger, '* * * * *', 'utc', [], fake.fn); scheduler.start(); await scheduler.stop(); assert.equal(fake.cancelled(), 1); @@ -97,7 +94,7 @@ test('stop cancels the job and clears nextInvocation', async () => { }); test('real croner schedule rejects an invalid cron string', () => { - const scheduler = new Scheduler('not a cron', 'utc', []); + const scheduler = new Scheduler(logger, 'not a cron', 'utc', []); assert.throws(() => scheduler.start()); }); @@ -107,7 +104,7 @@ test('stop during a run waits for it to finish', async () => { release = resolve; }); let finished = false; - const scheduler = new Scheduler('* * * * *', 'utc', [ + const scheduler = new Scheduler(logger, '* * * * *', 'utc', [ { name: 'slow', run: async () => { @@ -132,7 +129,7 @@ test('stop during a run waits for it to finish', async () => { }); test('real croner schedule accepts a valid cron and reports the next run', async () => { - const scheduler = new Scheduler('0 9 * * *', 'Europe/London', []); + const scheduler = new Scheduler(logger, '0 9 * * *', 'Europe/London', []); scheduler.start(); assert.ok(scheduler.nextInvocation() instanceof Date); await scheduler.stop(); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index fa09f33e..38a685b9 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,10 +1,8 @@ import assert from 'node:assert/strict'; -import { after, afterEach, before, beforeEach, test } from 'node:test'; +import { after, beforeEach, test } from 'node:test'; import { ChannelType } from 'discord.js'; -import type { DatabaseHandle } from '../src/db/db.ts'; -import { closeTestDb, clearConfigs, createTestDb } from './helpers/db.ts'; import type { ServerConfig } from '../src/db/schema.ts'; -import { ConfigService } from '../src/services/ConfigService.ts'; +import { getConfig, updateConfig } from '../src/services/ConfigService.ts'; import { type CommandContext, defineCommand, type Handler } from '../src/framework/command.ts'; import { booleanSetting, @@ -15,34 +13,22 @@ import { channelSetting, } from '../src/framework/settings.ts'; import { fakeGuild, fakeInteraction } from './fakes/interaction.ts'; +import { createTestApp } from './helpers/app.ts'; +import { dbFixtures } from './helpers/db.ts'; -let db: DatabaseHandle; +const app = createTestApp(); +const { clearConfigs, findConfig } = dbFixtures(app.db); let config: ServerConfig; -let events: string[]; +let persistedAtReply: (ServerConfig | null)[]; -before(async () => { - db = createTestDb(); +after(() => { + app.close(); }); -after(async () => { - closeTestDb(db); -}); - -const originalUpdate = ConfigService.updateConfig; - beforeEach(async () => { clearConfigs(); - config = await ConfigService.getConfig('guild-1'); - events = []; - ConfigService.updateConfig = (async (...args: Parameters) => { - const result = await originalUpdate.apply(ConfigService, args); - events.push('save'); - return result; - }) as typeof originalUpdate; -}); - -afterEach(() => { - ConfigService.updateConfig = originalUpdate; + config = await getConfig(app.db, 'guild-1'); + persistedAtReply = []; }); const channelGroup = channelSetting({ @@ -64,12 +50,13 @@ const makeCtx = (options: Record, channelIds: string[] = []) => const guild = fakeGuild(channelIds); const replies: string[] = []; const ctx = { + app, interaction, guild, member: {}, config, reply: async (response: string | { content?: string }) => { - events.push('reply'); + persistedAtReply.push(findConfig('guild-1')); replies.push(typeof response === 'string' ? response : (response.content ?? '')); }, } as unknown as CommandContext; @@ -93,9 +80,9 @@ test('channelSetting set saves before replying and persists', async () => { const { ctx, replies } = makeCtx({ channel: { id: '123' } }); await run(channelGroup, 'set')(ctx); - assert.deepEqual(events, ['save', 'reply']); + assert.equal(persistedAtReply[0]?.auditChannelId, '123'); assert.deepEqual(replies, ['Audit channel set to <#123>.']); - config = await ConfigService.getConfig('guild-1'); + config = await getConfig(app.db, 'guild-1'); assert.equal(config.auditChannelId, '123'); }); @@ -115,15 +102,14 @@ test('channelSetting get reports set, missing and not-set channels', async () => }); test('channelSetting clear nulls the field, saves, then replies', async () => { - config = await ConfigService.updateConfig('guild-1', { auditChannelId: '123' }); - events.length = 0; + config = await updateConfig(app.db, 'guild-1', { auditChannelId: '123' }); const { ctx, replies } = makeCtx({}); await run(channelGroup, 'clear')(ctx); - assert.deepEqual(events, ['save', 'reply']); + assert.equal(persistedAtReply[0]?.auditChannelId, null); assert.deepEqual(replies, ['Audit channel cleared.']); - config = await ConfigService.getConfig('guild-1'); + config = await getConfig(app.db, 'guild-1'); assert.equal(config.auditChannelId, null); }); @@ -142,9 +128,9 @@ test('channelSetting restricts the channel option types', () => { test('booleanSetting set saves before replying; get reads the stored value', async () => { const set = makeCtx({ enabled: true }); await run(boolGroup, 'set')(set.ctx); - assert.deepEqual(events, ['save', 'reply']); + assert.equal(persistedAtReply[0]?.systemMessagesEnabled, true); assert.deepEqual(set.replies, ['Bot system messages: enabled']); - config = await ConfigService.getConfig('guild-1'); + config = await getConfig(app.db, 'guild-1'); assert.equal(config.systemMessagesEnabled, true); const get = makeCtx({}); From 9bc7be7dc564bea9863b5b105d862d9e73fa442e Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:15:18 +0100 Subject: [PATCH 22/77] Add UserError and throw it for command validation failures The dispatcher replies with a UserError's message and logs it at info, while other errors keep the generic reply and error log. Co-Authored-By: Claude Sonnet 5.5 --- src/commands/admin/post.ts | 13 +++----- src/commands/admin/simulate.ts | 11 ++----- src/commands/config/groups/birthdays.ts | 10 +++--- src/commands/config/groups/newroles.ts | 7 ++--- src/commands/config/groups/welcome.ts | 13 ++++---- src/commands/info/mybirthday.ts | 4 +-- src/commands/info/profile.ts | 4 +-- src/events/interactionCreate.ts | 11 +++++-- src/framework/errors.ts | 7 +++++ tests/birthdays/commands.test.ts | 13 +++++--- tests/dispatcher.test.ts | 42 +++++++++++++++++++++++++ tests/helpers/assertions.ts | 14 +++++++++ tests/onboarding/commands.test.ts | 17 +++++----- 13 files changed, 113 insertions(+), 53 deletions(-) create mode 100644 src/framework/errors.ts create mode 100644 tests/helpers/assertions.ts diff --git a/src/commands/admin/post.ts b/src/commands/admin/post.ts index c73206a4..75e71680 100644 --- a/src/commands/admin/post.ts +++ b/src/commands/admin/post.ts @@ -1,5 +1,6 @@ import { ChannelType, Message, PermissionFlagsBits } from 'discord.js'; import { defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; import { EmbedColours } from '../../resources/EmbedColours.ts'; import { memberAuditEmbed } from '../../services/AuditEmbed.ts'; import { sendAudit } from '../../services/AuditService.ts'; @@ -32,14 +33,12 @@ export default defineCommand({ const attachment = options.getAttachment('attachment'); if (!content && !attachment) { - await ctx.reply('You must provide at least text or an attachment.'); - return; + throw new UserError('You must provide at least text or an attachment.'); } const channel = await resolveTextChannel(ctx.guild, target.id); if (!channel) { - await ctx.reply('The provided channel is not valid.'); - return; + throw new UserError('The provided channel is not valid.'); } const required = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]; @@ -52,8 +51,7 @@ export default defineCommand({ const names = Object.entries(PermissionFlagsBits) .filter(([, flag]) => missing.includes(flag)) .map(([name]) => name); - await ctx.reply(`I am missing permissions in ${channel.toString()}: ${names.join(', ')}.`); - return; + throw new UserError(`I am missing permissions in ${channel.toString()}: ${names.join(', ')}.`); } let sent: Message; @@ -69,8 +67,7 @@ export default defineCommand({ { guildId: ctx.guild.id, channelId: channel.id }, error ); - await ctx.reply('An error was encountered sending this message.'); - return; + throw new UserError('An error was encountered sending this message.', { cause: error }); } const audit = memberAuditEmbed(ctx.member, EmbedColours.neutral, 'Post created via Dingir') diff --git a/src/commands/admin/simulate.ts b/src/commands/admin/simulate.ts index 3a48ec70..0cf72878 100644 --- a/src/commands/admin/simulate.ts +++ b/src/commands/admin/simulate.ts @@ -1,18 +1,19 @@ import { GuildMember, SlashCommandSubcommandBuilder } from 'discord.js'; import { type CommandContext, defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; import { resolveMember } from '../../services/MemberResolver.ts'; import { auditJoin, complete, formatOnboardingSummary } from '../../services/OnboardingService.ts'; const NO_MENTIONS = { parse: [] }; -const targetOf = async (ctx: CommandContext): Promise => { +const targetOf = async (ctx: CommandContext): Promise => { const user = ctx.interaction.options.getUser('member'); if (!user) { return ctx.member; } const member = await resolveMember(ctx.guild, user); if (!member) { - await ctx.reply(`Could not find ${user.toString()} in this server.`); + throw new UserError(`Could not find ${user.toString()} in this server.`); } return member; }; @@ -32,9 +33,6 @@ export default defineCommand({ options: memberOption('Member to simulate joining as (defaults to you)'), run: async (ctx) => { const target = await targetOf(ctx); - if (!target) { - return; - } const sent = await auditJoin(ctx.app, ctx.interaction.client, target, ctx.config); await ctx.reply({ content: sent @@ -51,9 +49,6 @@ export default defineCommand({ options: memberOption('Member to simulate completing onboarding as (defaults to you)'), run: async (ctx) => { const target = await targetOf(ctx); - if (!target) { - return; - } const result = await complete(ctx.app, ctx.interaction.client, target, ctx.config, { dryRun: true, }); diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index 4a127272..05050c2a 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -1,5 +1,6 @@ import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { defineSubcommandGroup } from '../../../framework/command.ts'; +import { UserError } from '../../../framework/errors.ts'; import { calendarMessageUrl, deleteCalendarMessage, @@ -28,8 +29,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ const { id } = ctx.interaction.options.getChannel('channel', true); const channel = await resolveTextChannel(ctx.guild, id); if (!channel) { - await ctx.reply('I cannot post in that channel. Pick a text channel.'); - return; + throw new UserError('I cannot post in that channel. Pick a text channel.'); } const me = ctx.guild.members.me; const perms = me ? channel.permissionsFor(me) : null; @@ -37,8 +37,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ !perms?.has(PermissionFlagsBits.ViewChannel) || !perms.has(PermissionFlagsBits.SendMessages) ) { - await ctx.reply('I need permission to view and send messages in that channel.'); - return; + throw new UserError('I need permission to view and send messages in that channel.'); } const client = ctx.interaction.client; @@ -87,8 +86,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ defer: 'ephemeral', run: async (ctx) => { if (!ctx.config.birthdayCalendarMessagePath) { - await ctx.reply('There is no birthday calendar configured.'); - return; + throw new UserError('There is no birthday calendar configured.'); } await deleteCalendarMessage(ctx.interaction.client, ctx.config.birthdayCalendarMessagePath); ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts index 5cc0647a..fd502e26 100644 --- a/src/commands/config/groups/newroles.ts +++ b/src/commands/config/groups/newroles.ts @@ -1,5 +1,6 @@ import { Role } from 'discord.js'; import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; +import { UserError } from '../../../framework/errors.ts'; import { EmbedColours } from '../../../resources/EmbedColours.ts'; import { memberAuditEmbed } from '../../../services/AuditEmbed.ts'; import { sendAudit } from '../../../services/AuditService.ts'; @@ -72,11 +73,7 @@ export const NewRolesGroup = defineSubcommandGroup({ return problem ? [`${role.name}: ${problem}`] : []; }); if (problems.length > 0) { - await ctx.reply({ - content: `Those roles cannot be used:\n${problems.join('\n')}`, - allowedMentions: NO_MENTIONS, - }); - return; + throw new UserError(`Those roles cannot be used:\n${problems.join('\n')}`); } const ids = [...roles.keys()]; diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index 44a67e2a..a6f81f08 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -1,4 +1,5 @@ import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; +import { UserError } from '../../../framework/errors.ts'; import { type ServerConfigPatch, updateConfig } from '../../../services/ConfigService.ts'; import { complete } from '../../../services/OnboardingService.ts'; import { isHttpUrl, render } from '../../../services/WelcomeImage.ts'; @@ -21,8 +22,7 @@ const setMessage = async (ctx: CommandContext) => { const text = ctx.interaction.options.getString('text', true); const problem = validateWelcomeMessage(text); if (problem) { - await ctx.reply(problem); - return; + throw new UserError(problem); } ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { welcomeMessage: text }); await ctx.reply({ @@ -35,16 +35,15 @@ const setImage = async (ctx: CommandContext) => { const url = ctx.interaction.options.getString('url', true).trim(); const problem = validateImageUrl(url); if (problem) { - await ctx.reply(problem); - return; + throw new UserError(problem); } try { await render(ctx.app, ctx.member, url); } catch (error) { - await ctx.reply( - `That image could not be used: ${error instanceof Error ? error.message : String(error)}` + throw new UserError( + `That image could not be used: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } ); - return; } ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { welcomeMessageBackgroundUrl: url, diff --git a/src/commands/info/mybirthday.ts b/src/commands/info/mybirthday.ts index 780fc3e4..1b37a043 100644 --- a/src/commands/info/mybirthday.ts +++ b/src/commands/info/mybirthday.ts @@ -1,5 +1,6 @@ import { DateTime } from 'luxon'; import { type CommandContext, defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates.ts'; import { refreshCalendar } from '../../services/BirthdayService.ts'; import { clearBirthday, setBirthday } from '../../services/UserProfileService.ts'; @@ -45,8 +46,7 @@ export default defineCommand({ const month = ctx.interaction.options.getInteger('month', true); if (!isValidBirthday(month, day)) { - await ctx.reply('That date is invalid; check the day and month and try again.'); - return; + throw new UserError('That date is invalid; check the day and month and try again.'); } await setBirthday(ctx.app.db, ctx.guild.id, ctx.interaction.user.id, month, day); diff --git a/src/commands/info/profile.ts b/src/commands/info/profile.ts index 553491f0..8bc66bf0 100644 --- a/src/commands/info/profile.ts +++ b/src/commands/info/profile.ts @@ -1,6 +1,7 @@ import { EmbedBuilder } from 'discord.js'; import { DateTime } from 'luxon'; import { defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; import { EmbedColours } from '../../resources/EmbedColours.ts'; import { resolveMember } from '../../services/MemberResolver.ts'; import { findUserProfile } from '../../services/UserProfileService.ts'; @@ -17,8 +18,7 @@ export default defineCommand({ const user = ctx.interaction.options.getUser('member', true); const member = await resolveMember(ctx.guild, user); if (!member) { - await ctx.reply("That user isn't a member of this server."); - return; + throw new UserError("That user isn't a member of this server."); } const profile = await findUserProfile(ctx.app.db, ctx.guild.id, member.id); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 0c67bb54..8526d98b 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,5 +1,6 @@ import { MessageFlags } from 'discord.js'; import { type CommandContext, createReply } from '../framework/command.ts'; +import { UserError } from '../framework/errors.ts'; import { defineEvent } from '../framework/event.ts'; import { getConfig } from '../services/ConfigService.ts'; @@ -55,9 +56,15 @@ export default defineEvent({ guild: interaction.guildId, user: interaction.user.id, }; - app.logger.error('Command failed', context, error); + let message = 'Something went wrong running this command.'; + if (error instanceof UserError) { + message = error.message; + app.logger.info('Command rejected', { ...context, reason: message }); + } else { + app.logger.error('Command failed', context, error); + } try { - await reply('Something went wrong running this command.'); + await reply(message); } catch (replyError) { app.logger.error('Could not send command error reply', { path: resolved.path }, replyError); } diff --git a/src/framework/errors.ts b/src/framework/errors.ts new file mode 100644 index 00000000..44ed808b --- /dev/null +++ b/src/framework/errors.ts @@ -0,0 +1,7 @@ +/** An expected failure whose message is safe to show to the user as the command's reply. */ +export class UserError extends Error { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'UserError'; + } +} diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 7aae5cc9..eb1ca0b5 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -6,6 +6,7 @@ import profile from '../../src/commands/info/profile.ts'; import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { createTestApp } from '../helpers/app.ts'; +import { rejectsUserError } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); @@ -56,8 +57,10 @@ test('mybirthday set stores Feb 29 as 29', async () => { }); test('mybirthday set rejects invalid dates', async () => { - const calls = await exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }); - assert.match(lastContent(calls), /invalid/); + await rejectsUserError( + exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }), + /invalid/ + ); assert.equal(await countProfiles(), 0); }); @@ -74,8 +77,10 @@ test('mybirthday clear removes the birthday but keeps the profile', async () => test('profile replies for a non-member without creating a profile', async () => { const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: [] }); - const calls = await exec(profile as any, { options: { member: { id: 'ghost' } }, guild }); - assert.equal(lastContent(calls), "That user isn't a member of this server."); + await rejectsUserError( + exec(profile as any, { options: { member: { id: 'ghost' } }, guild }), + "That user isn't a member of this server." + ); assert.equal(await countProfiles(), 0); }); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index 46050c58..55589c3f 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -3,6 +3,7 @@ import { after, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; import type { DingirClient } from '../src/client/DingirClient.ts'; import { type Command, defineCommand } from '../src/framework/command.ts'; +import { UserError } from '../src/framework/errors.ts'; import interactionCreate from '../src/events/interactionCreate.ts'; import { fakeInteraction } from './fakes/interaction.ts'; import { createTestApp } from './helpers/app.ts'; @@ -152,3 +153,44 @@ test('a failing error reply is logged and does not throw', async () => { await dispatch(clientWith(cmd), interaction); assert.ok(app.logsAt('error').some((entry) => entry.message === 'Could not send command error reply')); }); + +test('a UserError replies ephemerally with its message and is not logged as an error', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + throw new UserError('That is not allowed.'); + }, + }); + const { interaction, calls } = fakeInteraction(); + const before = app.logs.length; + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual(calls, [ + { method: 'reply', payload: { content: 'That is not allowed.', flags: MessageFlags.Ephemeral } }, + ]); + const entries = app.logs.slice(before); + assert.equal(entries.filter((entry) => entry.level === 'error').length, 0); + assert.ok( + entries.some((entry) => entry.level === 'info' && entry.context?.reason === 'That is not allowed.') + ); +}); + +test('a UserError thrown after deferring edits the deferred reply', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async () => { + throw new UserError('Nope.'); + }, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual( + calls.map((c) => c.method), + ['deferReply', 'editReply'] + ); + assert.equal(calls[1].payload.content, 'Nope.'); +}); diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts new file mode 100644 index 00000000..ed38cbf8 --- /dev/null +++ b/tests/helpers/assertions.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { UserError } from '../../src/framework/errors.ts'; + +/** Asserts the promise rejects with a UserError whose message matches. */ +export const rejectsUserError = (promise: Promise, message: string | RegExp): Promise => + assert.rejects(promise, (error: unknown) => { + assert.ok(error instanceof UserError, `expected UserError, got ${String(error)}`); + if (typeof message === 'string') { + assert.equal(error.message, message); + } else { + assert.match(error.message, message); + } + return true; + }); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 0a8b4998..e698827d 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -9,6 +9,7 @@ import WelcomeGroup, { } from '../../src/commands/config/groups/welcome.ts'; import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; +import { rejectsUserError } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); @@ -35,14 +36,12 @@ test('newroles set rejects @everyone, managed and too-high roles and saves nothi }, env ); - await handler(NewRolesGroup, 'set')(ctx); + await rejectsUserError(handler(NewRolesGroup, 'set')(ctx), /managed[\s\S]*highest role/); assert.equal(ctx.config, env.config); - assert.match(replies[0].content, /managed/); - assert.match(replies[0].content, /highest role/); + assert.equal(replies.length, 0); const everyone = fakeCommandContext(app, { 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, env); - await handler(NewRolesGroup, 'set')(everyone.ctx); - assert.match(everyone.replies[0].content, /everyone/); + await rejectsUserError(handler(NewRolesGroup, 'set')(everyone.ctx), /everyone/); assert.equal(everyone.ctx.config, env.config); }); @@ -97,16 +96,16 @@ test('welcome validators', () => { test('welcome set-image rejects non-http URLs without saving', async () => { const env = fakeOnboarding(); const { ctx, replies } = fakeCommandContext(app, { url: 'ftp://example.com/a.png' }, env); - await handler(WelcomeGroup as never, 'set-image')(ctx); - assert.match(replies[0].content, /http/); + await rejectsUserError(handler(WelcomeGroup as never, 'set-image')(ctx), /http/); + assert.equal(replies.length, 0); assert.equal(env.config.welcomeMessageBackgroundUrl, null); }); test('welcome set-image rejects an unreachable image and does not save', async () => { const env = fakeOnboarding(); const { ctx, replies } = fakeCommandContext(app, { url: 'http://127.0.0.1:1/bg.png' }, env); - await handler(WelcomeGroup as never, 'set-image')(ctx); - assert.match(replies[0].content, /could not be used/); + await rejectsUserError(handler(WelcomeGroup as never, 'set-image')(ctx), /could not be used/); + assert.equal(replies.length, 0); assert.equal(env.config.welcomeMessageBackgroundUrl, null); }); From bd2136ef1cf66138d42acb964d7e361a99206dce Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:17:02 +0100 Subject: [PATCH 23/77] Consolidate test fakes into typed factories Add fakeUser, fakeMember, fakeGuild, fakeConfig, fakeAuditChannel and a shared fakeCommandContext, centralise structural casts in stub(), and drop the duplicated ad-hoc builders across test files. Co-Authored-By: Claude Sonnet 5.5 --- tests/birthdays/BirthdayService.test.ts | 8 +- tests/birthdays/commands.test.ts | 45 +++++----- tests/dispatcher.test.ts | 5 +- tests/fakes/command.ts | 38 +++++++++ tests/fakes/discord.ts | 108 ++++++++++++++++++++++++ tests/fakes/guild.ts | 27 +++--- tests/fakes/interaction.ts | 25 +----- tests/fakes/messages.ts | 50 ++--------- tests/fakes/onboarding.ts | 69 +++------------ tests/onboarding/commands.test.ts | 16 ++-- tests/services/AuditEmbed.test.ts | 8 +- tests/services/AuditService.test.ts | 6 +- tests/services/MemberResolver.test.ts | 15 ++-- tests/settings.test.ts | 42 +++++---- 14 files changed, 250 insertions(+), 212 deletions(-) create mode 100644 tests/fakes/command.ts create mode 100644 tests/fakes/discord.ts diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index c700d9a6..0d4b83c1 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -7,7 +7,7 @@ import { apiError, fakeClient, fakeGuildWithMembers, - fakeMessage, + fakeEditableMessage, fakeTextChannel, } from '../fakes/guild.ts'; import { createTestApp, type TestApp } from '../helpers/app.ts'; @@ -53,7 +53,7 @@ test('refreshCalendar: message missing', async () => { test('refreshCalendar: edit failure reports failed', async () => { const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - const message = fakeMessage('m1'); + const message = fakeEditableMessage('m1'); message.edit = async () => { throw new Error('boom'); }; @@ -68,7 +68,7 @@ test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', asy { serverId: 'g1', userId: 'today', birthdayMonth: 2, birthdayDay: 27 }, { serverId: 'g1', userId: 'nobday' }, ]); - const message = fakeMessage('m1'); + const message = fakeEditableMessage('m1'); const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); const status = await refreshCalendar(appAt('2027-02-27T10:00'), client, config); @@ -85,7 +85,7 @@ test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', asy test('refreshCalendar: empty state', async () => { const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - const message = fakeMessage('m1'); + const message = fakeEditableMessage('m1'); const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); assert.equal(await refreshCalendar(appAt(), client, config), 'updated'); assert.match(message.edits[0].content, /There are no birthdays in this server/); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index eb1ca0b5..a8d92ae8 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; -import { type CommandContext, createReply } from '../../src/framework/command.ts'; import mybirthday from '../../src/commands/info/mybirthday.ts'; import profile from '../../src/commands/info/profile.ts'; +import type { EmbedBuilder, Guild } from 'discord.js'; +import type { ReplyOptions } from '../../src/framework/command.ts'; +import { fakeCommandContext } from '../fakes/command.ts'; +import { stub } from '../fakes/discord.ts'; import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; -import { fakeInteraction } from '../fakes/interaction.ts'; import { createTestApp } from '../helpers/app.ts'; import { rejectsUserError } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; @@ -24,36 +26,27 @@ beforeEach(() => { const exec = async ( command: typeof mybirthday, - opts: { subcommand?: string; options?: Record; guild?: unknown } + opts: { subcommand?: string; options?: Record; guild?: object } ) => { - const config = await createConfig({ serverId: 'guild-1' }); - const fake = fakeInteraction({ subcommand: opts.subcommand, options: opts.options }); - const raw = fake.interaction as any; - raw.options.getInteger = raw.options.getNumber; - raw.client = fakeClient(); - const guild = opts.guild ?? { id: 'guild-1', name: 'Test Guild' }; - raw.guild = guild; - const resolved = command.resolve(fake.interaction)!; - const ctx = { - app, - interaction: fake.interaction, - guild, - member: raw.member, + const config = createConfig({ serverId: 'guild-1' }); + const { ctx, replies, interaction } = fakeCommandContext(app, opts.options, { + guild: opts.guild ? stub(opts.guild) : undefined, config, - reply: createReply(fake.interaction), - } as unknown as CommandContext; - await resolved.run(ctx); - return fake.calls; + client: fakeClient(), + subcommand: opts.subcommand, + }); + await command.resolve(interaction)!.run(ctx); + return replies; }; -const lastContent = (calls: { payload: any }[]): string => calls[calls.length - 1].payload.content; +const lastContent = (replies: ReplyOptions[]): string => replies[replies.length - 1].content!; test('mybirthday set stores Feb 29 as 29', async () => { - const calls = await exec(mybirthday, { subcommand: 'set', options: { day: 29, month: 2 } }); + const replies = await exec(mybirthday, { subcommand: 'set', options: { day: 29, month: 2 } }); const stored = findProfile('user-1'); assert.equal(stored?.birthdayDay, 29); assert.equal(stored?.birthdayMonth, 2); - assert.match(lastContent(calls), /^Saved. Your next birthday is /); + assert.match(lastContent(replies), /^Saved. Your next birthday is /); }); test('mybirthday set rejects invalid dates', async () => { @@ -94,9 +87,9 @@ test('profile for a member without a profile does not create one', async () => { user: { username: 'someone' }, displayAvatarURL: () => 'https://example.com/a.png', }); - const calls = await exec(profile as any, { options: { member: { id: 'm1' } }, guild }); - const embed = calls[calls.length - 1].payload.embeds[0].toJSON(); - const field = (name: string): string => embed.fields.find((f: any) => f.name === name).value; + const replies = await exec(profile as any, { options: { member: { id: 'm1' } }, guild }); + const embed = (replies[replies.length - 1].embeds![0] as EmbedBuilder).toJSON(); + const field = (name: string): string => embed.fields!.find((f: any) => f.name === name)!.value; assert.equal(field('Onboarding'), 'Not completed'); assert.equal(field('Activity Score'), '0'); assert.equal(await countProfiles(), 0); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index 55589c3f..72cc77e4 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -5,6 +5,7 @@ import type { DingirClient } from '../src/client/DingirClient.ts'; import { type Command, defineCommand } from '../src/framework/command.ts'; import { UserError } from '../src/framework/errors.ts'; import interactionCreate from '../src/events/interactionCreate.ts'; +import { stub } from './fakes/discord.ts'; import { fakeInteraction } from './fakes/interaction.ts'; import { createTestApp } from './helpers/app.ts'; @@ -15,9 +16,7 @@ after(() => { }); const clientWith = (...commands: Command[]) => - ({ - slashCommands: new Collection(commands.map((c) => [c.name, c])), - }) as unknown as DingirClient; + stub({ slashCommands: new Collection(commands.map((c) => [c.name, c])) }); const dispatch = (client: DingirClient, interaction: unknown) => interactionCreate.run(app, client, interaction as never); diff --git a/tests/fakes/command.ts b/tests/fakes/command.ts new file mode 100644 index 00000000..6a3e191c --- /dev/null +++ b/tests/fakes/command.ts @@ -0,0 +1,38 @@ +import type { Client, Guild, GuildMember } from 'discord.js'; +import type { App } from '../../src/app.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; +import type { CommandContext, ReplyOptions } from '../../src/framework/command.ts'; +import { fakeConfig, fakeGuild, fakeMember } from './discord.ts'; +import { fakeInteraction } from './interaction.ts'; + +export interface FakeCommandParts { + guild?: Guild; + member?: GuildMember; + config?: ServerConfig; + client?: Client; + subcommand?: string; +} + +/** A CommandContext whose reply() records normalised ReplyOptions instead of calling Discord. */ +export function fakeCommandContext( + app: App, + options: Record = {}, + parts: FakeCommandParts = {} +) { + const { interaction } = fakeInteraction({ options, subcommand: parts.subcommand }); + if (parts.client) { + Object.assign(interaction, { client: parts.client }); + } + const replies: ReplyOptions[] = []; + const ctx: CommandContext = { + app, + interaction, + guild: parts.guild ?? fakeGuild(), + member: parts.member ?? fakeMember('user-1'), + config: parts.config ?? fakeConfig(), + reply: async (response) => { + replies.push(typeof response === 'string' ? { content: response } : response); + }, + }; + return { ctx, replies, interaction }; +} diff --git a/tests/fakes/discord.ts b/tests/fakes/discord.ts new file mode 100644 index 00000000..ff09f762 --- /dev/null +++ b/tests/fakes/discord.ts @@ -0,0 +1,108 @@ +import { Collection, User } from 'discord.js'; +import type { Client, Guild, GuildMember } from 'discord.js'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; + +/** The one place tests cast structural stubs to discord.js types. */ +export const stub = (partial: object): T => partial as unknown as T; + +export const apiError = (code: number): Error => + Object.assign(new Error(`Discord error ${code}`), { code }); + +export function fakeUser(id: string, overrides: Record = {}): User { + return Object.assign(Object.create(User.prototype), { + id, + bot: false, + username: `user${id}`, + globalName: null, + discriminator: '0', + displayAvatarURL: () => 'https://example.com/a.png', + toString: () => `<@${id}>`, + ...overrides, + }) as User; +} + +export function fakeMember(id: string, overrides: Record = {}): GuildMember { + const user = (overrides.user as User | undefined) ?? fakeUser(id); + return stub({ + id, + user, + displayName: `Member ${id}`, + joinedTimestamp: Date.now(), + permissions: { has: () => false }, + roles: { cache: new Collection([['everyone', {}]]) }, + bannable: true, + ban: async () => undefined, + displayAvatarURL: () => 'https://example.com/a.png', + toString: () => `<@${id}>`, + ...overrides, + }); +} + +export interface FakeGuildOverrides extends Record { + /** Convenience: channels that exist in the guild cache and can be fetched. */ + channelIds?: string[]; +} + +export function fakeGuild(overrides: FakeGuildOverrides = {}): Guild { + const { channelIds = [], ...rest } = overrides; + const cache = new Collection(channelIds.map((id) => [id, { id }])); + return stub({ + id: 'guild-1', + name: 'Test Guild', + channels: { + cache, + fetch: async (id: string) => { + const channel = cache.get(id); + if (!channel) { + throw new Error('Unknown Channel'); + } + return channel; + }, + }, + ...rest, + }); +} + +export function fakeConfig(overrides: Partial> = {}): ServerConfig { + return stub({ + serverId: 'guild-1', + guestRoleIds: null, + welcomeMessage: null, + welcomeMessageBackgroundUrl: null, + systemMessagesEnabled: false, + debug: false, + auditChannelId: null, + ...overrides, + }); +} + +export interface AuditChannel { + channel: { isSendable(): boolean; send(payload: unknown): Promise }; + sent: any[]; +} + +/** A sendable channel that records payloads, or rejects every send when `failWith` is given. */ +export function fakeAuditChannel(failWith?: string): AuditChannel { + const sent: any[] = []; + return { + sent, + channel: { + isSendable: () => true, + send: async (payload) => { + if (failWith) { + throw new Error(failWith); + } + sent.push(payload); + }, + }, + }; +} + +/** A client whose channel cache resolves only `channelId` (to `channel`). */ +export function fakeChannelClient(channelId: string, channel: object, extra: object = {}): DingirClient { + return stub({ + channels: { fetch: async (id: string) => (id === channelId ? channel : null) }, + ...extra, + }); +} diff --git a/tests/fakes/guild.ts b/tests/fakes/guild.ts index 5eb5886f..8a1c4930 100644 --- a/tests/fakes/guild.ts +++ b/tests/fakes/guild.ts @@ -1,19 +1,19 @@ import { Collection } from 'discord.js'; import type { Client } from 'discord.js'; +import { apiError, fakeMember, stub } from './discord.ts'; -export const apiError = (code: number): Error => - Object.assign(new Error(`Discord error ${code}`), { code }); +export { apiError }; -export interface FakeMessage { +export interface FakeEditableMessage { id: string; edits: any[]; deleted: boolean; - edit(payload: unknown): Promise; + edit(payload: unknown): Promise; delete(): Promise; } -export const fakeMessage = (id: string): FakeMessage => { - const message: FakeMessage = { +export const fakeEditableMessage = (id: string): FakeEditableMessage => { + const message: FakeEditableMessage = { id, edits: [], deleted: false, @@ -31,14 +31,14 @@ export const fakeMessage = (id: string): FakeMessage => { export interface FakeChannel { id: string; sent: any[]; - messages: { fetch(id: string): Promise }; + messages: { fetch(id: string): Promise }; isTextBased(): boolean; isDMBased(): boolean; isSendable(): boolean; send(payload: unknown): Promise<{ id: string; channelId: string }>; } -export const fakeTextChannel = (id: string, messages: FakeMessage[] = []): FakeChannel => { +export const fakeTextChannel = (id: string, messages: FakeEditableMessage[] = []): FakeChannel => { const channel: FakeChannel = { id, sent: [], @@ -69,13 +69,8 @@ export interface FakeGuildOptions { membersFetchError?: Error; } -export const fakeMember = (id: string): { id: string; toString(): string } => ({ - id, - toString: () => `<@${id}>`, -}); - export const fakeGuildWithMembers = (opts: FakeGuildOptions) => { - const members = new Collection>( + const members = new Collection( (opts.memberIds ?? []).map((id) => [id, fakeMember(id)]) ); const channels = new Collection((opts.channels ?? []).map((c) => [c.id, c])); @@ -119,7 +114,7 @@ export const fakeClient = (opts: FakeClientOptions = {}): Client => { cache.set(id, value); } } - return { + return stub({ guilds: { cache, fetch: async (id: string) => { @@ -141,5 +136,5 @@ export const fakeClient = (opts: FakeClientOptions = {}): Client => { return channels.find((c) => c.id === id) ?? null; }, }, - } as unknown as Client; + }); }; diff --git a/tests/fakes/interaction.ts b/tests/fakes/interaction.ts index ea8152ec..454e3436 100644 --- a/tests/fakes/interaction.ts +++ b/tests/fakes/interaction.ts @@ -1,5 +1,5 @@ -import { Collection } from 'discord.js'; import type { ChatInputCommandInteraction } from 'discord.js'; +import { fakeGuild, stub } from './discord.ts'; export interface Call { method: 'reply' | 'deferReply' | 'editReply' | 'followUp'; @@ -17,7 +17,7 @@ export interface FakeInteractionOptions { replied?: boolean; guildId?: string; userId?: string; - guild?: Record; + guild?: unknown; } export interface FakeInteraction { @@ -25,24 +25,6 @@ export interface FakeInteraction { calls: Call[]; } -export function fakeGuild(channelIds: string[] = []) { - const cache = new Collection(channelIds.map((id) => [id, { id }])); - return { - id: 'guild-1', - name: 'Test Guild', - channels: { - cache, - fetch: async (id: string) => { - const channel = cache.get(id); - if (!channel) { - throw new Error('Unknown Channel'); - } - return channel; - }, - }, - }; -} - export function fakeInteraction(opts: FakeInteractionOptions = {}): FakeInteraction { const calls: Call[] = []; const values = opts.options ?? {}; @@ -86,6 +68,7 @@ export function fakeInteraction(opts: FakeInteractionOptions = {}): FakeInteract getUser: required, getRole: required, getNumber: required, + getInteger: required, getAttachment: required, }, reply: async (payload: unknown) => { @@ -105,5 +88,5 @@ export function fakeInteraction(opts: FakeInteractionOptions = {}): FakeInteract }, }; - return { interaction: state as unknown as ChatInputCommandInteraction<'cached'>, calls }; + return { interaction: stub>(state), calls }; } diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts index 0225046e..d342ebed 100644 --- a/tests/fakes/messages.ts +++ b/tests/fakes/messages.ts @@ -1,60 +1,28 @@ -import { Collection, User } from 'discord.js'; +import { Collection } from 'discord.js'; import type { App } from '../../src/app.ts'; import type { DingirClient } from '../../src/client/DingirClient.ts'; import type { Command } from '../../src/framework/command.ts'; import interactionCreate from '../../src/events/interactionCreate.ts'; +import { fakeAuditChannel, fakeChannelClient, fakeGuild, fakeMember, fakeUser } from './discord.ts'; import type { FakeInteraction } from './interaction.ts'; +export { fakeMember, fakeUser }; + export interface AuditSink { client: DingirClient; sent: any[]; } export function fakeAuditClient(channelId = 'audit-1', commands: Command[] = []): AuditSink { - const sent: any[] = []; - const channel = { - isSendable: () => true, - send: async (payload: unknown) => void sent.push(payload), - }; - const client = { - channels: { fetch: async (id: string) => (id === channelId ? channel : null) }, - slashCommands: new Collection(commands.map((c) => [c.name, c])), - } as unknown as DingirClient; - return { client, sent }; + const { channel, sent } = fakeAuditChannel(); + const slashCommands = new Collection(commands.map((c) => [c.name, c])); + return { client: fakeChannelClient(channelId, channel, { slashCommands }), sent }; } export const auditJson = (sent: any[], index = 0): any => sent[index].embeds[0].toJSON(); -export function fakeUser(id: string, overrides: Record = {}) { - return Object.assign(Object.create(User.prototype), { - id, - bot: false, - username: `user${id}`, - globalName: null, - discriminator: '0', - displayAvatarURL: () => 'https://example.com/a.png', - ...overrides, - }) as User; -} - -export function fakeMember(id: string, overrides: Record = {}) { - const user = (overrides.user as User) ?? fakeUser(id); - return { - id, - user, - displayName: `Member ${id}`, - joinedTimestamp: Date.now(), - permissions: { has: () => false }, - roles: { cache: new Collection([['everyone', {}]]) }, - bannable: true, - ban: async () => undefined, - displayAvatarURL: () => 'https://example.com/a.png', - ...overrides, - }; -} - export function fakeMessage(overrides: Record = {}) { - const author = (overrides.author as User | null | undefined) ?? fakeUser('u1'); + const author = (overrides.author as ReturnType | null | undefined) ?? fakeUser('u1'); const base = { id: 'm1', partial: false, @@ -65,7 +33,7 @@ export function fakeMessage(overrides: Record = {}) { content: 'hello', author, member: fakeMember(author.id, { user: author }), - guild: { id: 'guild-1', members: { fetch: async () => null } }, + guild: fakeGuild({ members: { fetch: async () => null } }), attachments: new Collection(), embeds: [] as unknown[], url: 'https://discord.com/channels/guild-1/chan-1/m1', diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index 94f5110f..ddfececa 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,8 +1,8 @@ -import type { Client, GuildMember } from 'discord.js'; -import type { App } from '../../src/app.ts'; +import type { Client, Guild, GuildMember } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; -import type { CommandContext } from '../../src/framework/command.ts'; -import { fakeInteraction } from './interaction.ts'; +import { fakeAuditChannel, fakeConfig, fakeGuild, fakeMember, stub } from './discord.ts'; + +export { fakeCommandContext } from './command.ts'; export interface FakeRole { id: string; @@ -32,12 +32,9 @@ export const role = (id: string, position = 1, managed = false): FakeRole => ({ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { const roleAdds: string[][] = []; const systemSends: any[] = []; - const auditSends: any[] = []; const roles = new Map((opts.roles ?? []).map((r) => [r.id, r])); - const guild: any = { - id: 'guild-1', - name: 'Test Guild', + const guild: Guild = fakeGuild({ roles: { cache: roles, fetch: async () => null }, members: { me: { roles: { highest: { position: opts.botPosition ?? 10 } } } }, systemChannel: @@ -51,15 +48,12 @@ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { systemSends.push(payload); }, }, - }; + }); - const member = { - id: 'member-1', + const member: GuildMember = fakeMember('member-1', { displayName: 'Member', - user: { id: 'member-1', tag: 'member#0001' }, + user: stub({ id: 'member-1', tag: 'member#0001' }), guild, - displayAvatarURL: () => 'https://example.com/a.png', - toString: () => '<@member-1>', roles: { add: async (ids: string[]) => { if (opts.failRolesAdd) { @@ -68,52 +62,15 @@ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { roleAdds.push(ids); }, }, - } as unknown as GuildMember; + }); - const auditChannel = { - isSendable: () => true, - send: async (payload: unknown) => { - if (opts.failAuditSend) { - throw new Error('audit down'); - } - auditSends.push(payload); - }, - }; - const client = { - channels: { fetch: async () => auditChannel }, - } as unknown as Client; + const { channel, sent: auditSends } = fakeAuditChannel(opts.failAuditSend ? 'audit down' : undefined); + const client = stub({ channels: { fetch: async () => channel } }); - const config = { - serverId: 'guild-1', + const config = fakeConfig({ auditChannelId: opts.auditChannel === false ? null : 'audit-1', - guestRoleIds: null, - welcomeMessage: null, - welcomeMessageBackgroundUrl: null, - systemMessagesEnabled: false, - debug: false, ...opts.config, - } as unknown as ServerConfig; + }); return { client, member, guild, config, roleAdds, systemSends, auditSends }; } - -export function fakeCommandContext( - app: App, - options: Record, - env: ReturnType -) { - const { interaction } = fakeInteraction({ options }); - (interaction as any).client = env.client; - const replies: any[] = []; - const ctx = { - app, - interaction, - guild: env.guild, - member: env.member, - config: env.config, - reply: async (response: unknown) => { - replies.push(typeof response === 'string' ? { content: response } : response); - }, - } as unknown as CommandContext; - return { ctx, replies }; -} diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index e698827d..f079c44a 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -56,7 +56,7 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { await handler(NewRolesGroup, 'set')(ctx); assert.equal(ctx.config.guestRoleIds, 'a,b'); assert.equal(env.auditSends.length, 1); - assert.match(replies[0].content, /set to/); + assert.match(replies[0].content!, /set to/); }); test('newroles get is read-only: no audit, marks missing roles', async () => { @@ -64,13 +64,13 @@ test('newroles get is read-only: no audit, marks missing roles', async () => { const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(NewRolesGroup, 'get')(ctx); assert.equal(env.auditSends.length, 0); - assert.match(replies[0].content, /<@&a>/); - assert.match(replies[0].content, /gone \(this role no longer exists\)/); + assert.match(replies[0].content!, /<@&a>/); + assert.match(replies[0].content!, /gone \(this role no longer exists\)/); const empty = fakeOnboarding(); const e = fakeCommandContext(app, {}, empty); await handler(NewRolesGroup, 'get')(e.ctx); - assert.equal(e.replies[0].content, 'No new-member roles are configured.'); + assert.equal(e.replies[0].content!, 'No new-member roles are configured.'); }); test('newroles clear nulls roles and audits', async () => { @@ -117,8 +117,8 @@ test('welcome set-message, get and clear', async () => { const get = fakeCommandContext(app, {}, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'get')(get.ctx); - assert.match(get.replies[0].content, /Hi \{member\}/); - assert.match(get.replies[0].content, /Welcome image: Not set/); + assert.match(get.replies[0].content!, /Hi \{member\}/); + assert.match(get.replies[0].content!, /Welcome image: Not set/); const clear = fakeCommandContext(app, { which: 'all' }, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'clear')(clear.ctx); @@ -129,7 +129,7 @@ test('welcome preview explains why nothing would be sent', async () => { const env = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); - assert.match(replies[0].content, /disabled/); + assert.match(replies[0].content!, /disabled/); assert.equal(env.systemSends.length, 0); }); @@ -137,6 +137,6 @@ test('welcome preview shows the text without sending', async () => { const env = fakeOnboarding({ config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true } }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); - assert.match(replies[0].content, /hi <@member-1> <@member-1>/); + assert.match(replies[0].content!, /hi <@member-1> <@member-1>/); assert.equal(env.systemSends.length, 0); }); diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts index 6c155a78..7acfe382 100644 --- a/tests/services/AuditEmbed.test.ts +++ b/tests/services/AuditEmbed.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { GuildMember } from 'discord.js'; +import { fakeMember, stub } from '../fakes/discord.ts'; import { AuditEmbed, memberAuditEmbed } from '../../src/services/AuditEmbed.ts'; import { EmbedColours } from '../../src/resources/EmbedColours.ts'; @@ -93,11 +93,11 @@ test('toJSON does not mutate the builder', () => { }); test('memberAuditEmbed sets author, colour, description and timestamp', () => { - const member = { + const member = fakeMember('1', { displayName: 'Alice', - user: { tag: 'alice#0' }, + user: stub({ tag: 'alice#0' }), displayAvatarURL: () => 'https://cdn.example/a.png', - } as unknown as GuildMember; + }); const json = memberAuditEmbed(member, EmbedColours.info, 'hello').toJSON(); assert.equal(json.author?.name, 'Alice'); assert.equal(json.author?.icon_url, 'https://cdn.example/a.png'); diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index bfa6c34a..c805b3e2 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -3,14 +3,14 @@ import { test } from 'node:test'; import { Client, EmbedBuilder } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; import { sendAudit } from '../../src/services/AuditService.ts'; +import { fakeConfig, stub } from '../fakes/discord.ts'; import { createTestApp } from '../helpers/app.ts'; const app = createTestApp(); const config = (auditChannelId: string | null): ServerConfig => - ({ serverId: 'g1', auditChannelId }) as unknown as ServerConfig; -const clientWith = (fetch: () => Promise): Client => - ({ channels: { fetch } }) as unknown as Client; + fakeConfig({ serverId: 'g1', auditChannelId }); +const clientWith = (fetch: () => Promise): Client => stub({ channels: { fetch } }); const embed = new EmbedBuilder().setDescription('x'); test('returns false without fetching when no audit channel configured', async () => { diff --git a/tests/services/MemberResolver.test.ts b/tests/services/MemberResolver.test.ts index b17c3507..7eb5c590 100644 --- a/tests/services/MemberResolver.test.ts +++ b/tests/services/MemberResolver.test.ts @@ -1,18 +1,17 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Guild, GuildMember } from 'discord.js'; +import type { Guild } from 'discord.js'; +import { apiError, fakeMember, stub } from '../fakes/discord.ts'; import { resolveMember, resolveTextChannel } from '../../src/services/MemberResolver.ts'; const guildWith = (cached: Record, fetch: (id: string) => Promise): Guild => - ({ + stub({ members: { cache: new Map(Object.entries(cached)), fetch }, channels: { cache: new Map(Object.entries(cached)), fetch }, - }) as unknown as Guild; - -const apiError = (code: number): Error => Object.assign(new Error(`code ${code}`), { code }); + }); test('cache hit does not fetch', async () => { - const member = { id: 'u1' } as unknown as GuildMember; + const member = fakeMember('u1'); const guild = guildWith({ u1: member }, async () => { throw new Error('should not fetch'); }); @@ -21,9 +20,9 @@ test('cache hit does not fetch', async () => { }); test('cache miss fetches', async () => { - const member = { id: 'u2' }; + const member = fakeMember('u2'); const guild = guildWith({}, async () => member); - assert.equal(await resolveMember(guild, 'u2'), member as unknown as GuildMember); + assert.equal(await resolveMember(guild, 'u2'), member); }); test('Unknown Member and Unknown User resolve to null', async () => { diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 38a685b9..6babf49c 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -3,7 +3,7 @@ import { after, beforeEach, test } from 'node:test'; import { ChannelType } from 'discord.js'; import type { ServerConfig } from '../src/db/schema.ts'; import { getConfig, updateConfig } from '../src/services/ConfigService.ts'; -import { type CommandContext, defineCommand, type Handler } from '../src/framework/command.ts'; +import { defineCommand, type Handler, type ReplyOptions } from '../src/framework/command.ts'; import { booleanSetting, booleanText, @@ -12,7 +12,8 @@ import { channelSetText, channelSetting, } from '../src/framework/settings.ts'; -import { fakeGuild, fakeInteraction } from './fakes/interaction.ts'; +import { fakeCommandContext } from './fakes/command.ts'; +import { fakeGuild } from './fakes/discord.ts'; import { createTestApp } from './helpers/app.ts'; import { dbFixtures } from './helpers/db.ts'; @@ -46,23 +47,20 @@ const boolGroup = booleanSetting({ }); const makeCtx = (options: Record, channelIds: string[] = []) => { - const { interaction } = fakeInteraction({ options }); - const guild = fakeGuild(channelIds); - const replies: string[] = []; - const ctx = { - app, - interaction, - guild, - member: {}, + const { ctx, replies } = fakeCommandContext(app, options, { + guild: fakeGuild({ channelIds }), config, - reply: async (response: string | { content?: string }) => { - persistedAtReply.push(findConfig('guild-1')); - replies.push(typeof response === 'string' ? response : (response.content ?? '')); - }, - } as unknown as CommandContext; + }); + const reply = ctx.reply; + ctx.reply = async (response) => { + persistedAtReply.push(findConfig('guild-1')); + await reply(response); + }; return { ctx, replies }; }; +const texts = (replies: ReplyOptions[]): (string | undefined)[] => replies.map((r) => r.content); + const run = (group: typeof channelGroup, name: string): Handler => group.subcommands.find((s) => s.name === name)!.run; @@ -81,7 +79,7 @@ test('channelSetting set saves before replying and persists', async () => { await run(channelGroup, 'set')(ctx); assert.equal(persistedAtReply[0]?.auditChannelId, '123'); - assert.deepEqual(replies, ['Audit channel set to <#123>.']); + assert.deepEqual(texts(replies), ['Audit channel set to <#123>.']); config = await getConfig(app.db, 'guild-1'); assert.equal(config.auditChannelId, '123'); }); @@ -89,16 +87,16 @@ test('channelSetting set saves before replying and persists', async () => { test('channelSetting get reports set, missing and not-set channels', async () => { const notSet = makeCtx({}, []); await run(channelGroup, 'get')(notSet.ctx); - assert.deepEqual(notSet.replies, ['Audit channel: not set']); + assert.deepEqual(texts(notSet.replies), ['Audit channel: not set']); config.auditChannelId = '123'; const present = makeCtx({}, ['123']); await run(channelGroup, 'get')(present.ctx); - assert.deepEqual(present.replies, ['Audit channel: <#123>']); + assert.deepEqual(texts(present.replies), ['Audit channel: <#123>']); const missing = makeCtx({}, []); await run(channelGroup, 'get')(missing.ctx); - assert.match(missing.replies[0], /no longer exists/); + assert.match(missing.replies[0].content!, /no longer exists/); }); test('channelSetting clear nulls the field, saves, then replies', async () => { @@ -108,7 +106,7 @@ test('channelSetting clear nulls the field, saves, then replies', async () => { await run(channelGroup, 'clear')(ctx); assert.equal(persistedAtReply[0]?.auditChannelId, null); - assert.deepEqual(replies, ['Audit channel cleared.']); + assert.deepEqual(texts(replies), ['Audit channel cleared.']); config = await getConfig(app.db, 'guild-1'); assert.equal(config.auditChannelId, null); }); @@ -129,13 +127,13 @@ test('booleanSetting set saves before replying; get reads the stored value', asy const set = makeCtx({ enabled: true }); await run(boolGroup, 'set')(set.ctx); assert.equal(persistedAtReply[0]?.systemMessagesEnabled, true); - assert.deepEqual(set.replies, ['Bot system messages: enabled']); + assert.deepEqual(texts(set.replies), ['Bot system messages: enabled']); config = await getConfig(app.db, 'guild-1'); assert.equal(config.systemMessagesEnabled, true); const get = makeCtx({}); await run(boolGroup, 'get')(get.ctx); - assert.deepEqual(get.replies, ['Bot system messages: enabled']); + assert.deepEqual(texts(get.replies), ['Bot system messages: enabled']); }); test('booleanSetting requires the enabled option', () => { From 2d805833ece62a9daaaaa7fcd7863c874c517ef6 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:18:46 +0100 Subject: [PATCH 24/77] Defer command replies before loading server config Co-Authored-By: Claude Sonnet 5.5 --- src/events/interactionCreate.ts | 2 +- tests/dispatcher.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 8526d98b..24481406 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -34,12 +34,12 @@ export default defineEvent({ const reply = createReply(interaction); try { - const config = await getConfig(app.db, interaction.guildId); if (resolved.defer) { await interaction.deferReply( resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {} ); } + const config = await getConfig(app.db, interaction.guildId); const ctx: CommandContext = { app, interaction, diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index 72cc77e4..c5834f82 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -193,3 +193,31 @@ test('a UserError thrown after deferring edits the deferred reply', async () => ); assert.equal(calls[1].payload.content, 'Nope.'); }); + +test('defers the reply before looking up the server config', async () => { + const order: string[] = []; + const spied = createTestApp(); + const select = spied.db.select.bind(spied.db); + spied.db.select = ((...args: Parameters) => { + order.push('config'); + return select(...args); + }) as typeof spied.db.select; + try { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async () => {}, + }); + const { interaction } = fakeInteraction(); + const original = interaction.deferReply.bind(interaction); + interaction.deferReply = (async (...args: Parameters) => { + order.push('defer'); + return original(...args); + }) as typeof interaction.deferReply; + await interactionCreate.run(spied, clientWith(cmd), interaction as never); + assert.deepEqual(order.slice(0, 2), ['defer', 'config']); + } finally { + spied.close(); + } +}); From c7fa062349e6b521bb3ae42adead8a31aab7e54d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:19:35 +0100 Subject: [PATCH 25/77] Cache server configs per app and invalidate on writes Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 3 + src/commands/config/groups/birthdays.ts | 4 +- src/commands/config/groups/newroles.ts | 4 +- src/commands/config/groups/welcome.ts | 6 +- src/events/guildCreate.ts | 4 +- src/events/guildDelete.ts | 2 +- src/events/guildMemberAdd.ts | 2 +- src/events/guildMemberRemove.ts | 2 +- src/events/guildMemberUpdate.ts | 2 +- src/events/interactionCreate.ts | 2 +- src/events/messageCreate.ts | 2 +- src/events/messageDelete.ts | 2 +- src/events/messageUpdate.ts | 2 +- src/framework/settings.ts | 6 +- src/services/ConfigService.ts | 49 +++++++++++++--- src/services/DataCheckService.ts | 4 +- tests/birthdays/BirthdayService.test.ts | 2 +- tests/birthdays/commands.test.ts | 2 +- tests/database/purgeGuild.test.ts | 16 +++-- tests/database/services.test.ts | 75 +++++++++++++++++++++--- tests/datacheck/DataCheckService.test.ts | 2 +- tests/helpers/db.ts | 17 ++++-- tests/moderation/events.test.ts | 4 +- tests/moderation/post.test.ts | 2 +- tests/onboarding/commands.test.ts | 2 +- tests/onboarding/events.test.ts | 2 +- tests/settings.test.ts | 12 ++-- 27 files changed, 172 insertions(+), 60 deletions(-) diff --git a/src/app.ts b/src/app.ts index 8ec7efdc..da1b9f8e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import { loadEnv, type Env } from './config/env.ts'; import { openDb, type Db } from './db/db.ts'; import { migrate } from './db/migrator.ts'; import { createShutdownRegistry, type ShutdownRegistry } from './framework/shutdown.ts'; +import { type ConfigCache, createConfigCache } from './services/ConfigService.ts'; import { createHoneypotTracker, type HoneypotTracker } from './services/HoneypotTracker.ts'; import { createConsoleLogger, type Logger } from './utilities/Logger.ts'; @@ -12,6 +13,7 @@ export interface App { logger: Logger; clock: () => Date; honeypot: HoneypotTracker; + configCache: ConfigCache; shutdown: ShutdownRegistry; } @@ -38,6 +40,7 @@ export function createApp(overrides: Partial = {}): App { logger, clock: overrides.clock ?? (() => new Date()), honeypot: overrides.honeypot ?? createHoneypotTracker(), + configCache: overrides.configCache ?? createConfigCache(), shutdown, }; } diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index 05050c2a..cceed39f 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -48,7 +48,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ allowedMentions: { parse: [] }, }); const path = `${message.channelId}/${message.id}`; - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { birthdayCalendarMessagePath: path, }); @@ -89,7 +89,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ throw new UserError('There is no birthday calendar configured.'); } await deleteCalendarMessage(ctx.interaction.client, ctx.config.birthdayCalendarMessagePath); - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { birthdayCalendarMessagePath: null, }); await ctx.reply('Birthday calendar removed.'); diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts index fd502e26..1c35412d 100644 --- a/src/commands/config/groups/newroles.ts +++ b/src/commands/config/groups/newroles.ts @@ -77,7 +77,7 @@ export const NewRolesGroup = defineSubcommandGroup({ } const ids = [...roles.keys()]; - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { guestRoleIds: ids.join(','), }); await auditChange(ctx, 'New member roles updated', ids); @@ -91,7 +91,7 @@ export const NewRolesGroup = defineSubcommandGroup({ name: 'clear', description: 'Clear the roles given to members when they complete onboarding', run: async (ctx) => { - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { guestRoleIds: null }); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { guestRoleIds: null }); await auditChange(ctx, 'New member roles cleared', []); await ctx.reply('New-member roles cleared.'); }, diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index a6f81f08..70a93731 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -24,7 +24,7 @@ const setMessage = async (ctx: CommandContext) => { if (problem) { throw new UserError(problem); } - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { welcomeMessage: text }); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { welcomeMessage: text }); await ctx.reply({ content: 'Welcome message saved. Use `{member}` in the text to mention the new member.', allowedMentions: NO_MENTIONS, @@ -45,7 +45,7 @@ const setImage = async (ctx: CommandContext) => { { cause: error } ); } - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { welcomeMessageBackgroundUrl: url, }); await ctx.reply('Welcome image saved. Use /config welcome preview to see how it looks.'); @@ -71,7 +71,7 @@ const clear = async (ctx: CommandContext) => { if (which === 'image' || which === 'all') { patch.welcomeMessageBackgroundUrl = null; } - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, patch); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, patch); await ctx.reply(`Cleared welcome ${which === 'all' ? 'message and image' : which}.`); }; diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 2acec9d2..2d1b99b7 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -4,8 +4,8 @@ import { getConfig, resetAccessFailures } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildCreate', run: async (app, _client, guild) => { - const config = await getConfig(app.db, guild.id); - await resetAccessFailures(app.db, config); + const config = await getConfig(app, guild.id); + await resetAccessFailures(app, config); app.logger.info('Bot added to guild', { guild: guild.id, name: guild.name }); }, }); diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 048a5379..0da5e679 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -4,7 +4,7 @@ import { purgeGuild } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildDelete', run: async (app, _client, guild) => { - const result = await purgeGuild(app.db, guild.id); + const result = await purgeGuild(app, guild.id); app.logger.info('Bot removed from guild', { guild: guild.id, name: guild.name, diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 7700ff4e..1dc5bca1 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -5,7 +5,7 @@ import { auditJoin, complete } from '../services/OnboardingService.ts'; export default defineEvent({ name: 'guildMemberAdd', run: async (app, client, member) => { - const config = await getConfig(app.db, member.guild.id); + const config = await getConfig(app, member.guild.id); await auditJoin(app, client, member, config); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 14b6f874..b4058a4f 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -22,7 +22,7 @@ export default defineEvent({ .addField('ID', user.id) .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data'); - const config = await getConfig(app.db, member.guild.id); + const config = await getConfig(app, member.guild.id); await sendAudit(app, client, config, audit); }, }); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 7910add9..ce07fc78 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -16,7 +16,7 @@ export default defineEvent({ return; } - const config = await getConfig(app.db, newMember.guild.id); + const config = await getConfig(app, newMember.guild.id); await complete(app, client, newMember, config); }, }); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 24481406..b64c2673 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -39,7 +39,7 @@ export default defineEvent({ resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {} ); } - const config = await getConfig(app.db, interaction.guildId); + const config = await getConfig(app, interaction.guildId); const ctx: CommandContext = { app, interaction, diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 999b8504..eba64a5e 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -99,7 +99,7 @@ export default defineEvent({ return; } - const config = await getConfig(app.db, message.guildId); + const config = await getConfig(app, message.guildId); if (await handleHoneypot(app, client, message, config)) { return; } diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 54fc930f..92aeb97f 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -17,7 +17,7 @@ export default defineEvent({ return; } - const config = await getConfig(app.db, message.guildId); + const config = await getConfig(app, message.guildId); if (message.partial || !message.author) { const audit = new AuditEmbed() diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 754c8916..829710d2 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -35,7 +35,7 @@ export default defineEvent({ .addField('Current', newMessage.content) .addField('Jump to message', newMessage.url); - const config = await getConfig(app.db, newMessage.guildId); + const config = await getConfig(app, newMessage.guildId); await sendAudit(app, client, config, audit); }, }); diff --git a/src/framework/settings.ts b/src/framework/settings.ts index 8e52618c..7a21bf60 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -68,7 +68,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi ), run: async (ctx) => { const channel = ctx.interaction.options.getChannel('channel', true); - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { [field]: channel.id, }); await ctx.reply(channelSetText(label, channel.id)); @@ -87,7 +87,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi name: 'clear', description: `Clear the ${label.toLowerCase()}`, run: async (ctx) => { - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { [field]: null }); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { [field]: null }); await ctx.reply(channelClearText(label)); }, }, @@ -110,7 +110,7 @@ export function booleanSetting(opts: BooleanSettingOptions): SubcommandGroupDefi ), run: async (ctx) => { const enabled = ctx.interaction.options.getBoolean('enabled', true); - ctx.config = await updateConfig(ctx.app.db, ctx.config.serverId, { [field]: enabled }); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { [field]: enabled }); await ctx.reply(booleanText(label, enabled)); }, }, diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 50ef6550..6a9788f1 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -3,11 +3,33 @@ import { eq } from 'drizzle-orm'; import type { Db } from '../db/db.ts'; import { serverConfigs, userProfiles, type ServerConfig } from '../db/schema.ts'; +/** Per-app cache of server configs, kept coherent by the functions below. */ +export type ConfigCache = Map; + +export interface ConfigDeps { + db: Db; + configCache: ConfigCache; +} + +export const createConfigCache = (): ConfigCache => new Map(); + export type ServerConfigPatch = Partial>; -export async function getConfig(db: Db, serverId: Snowflake): Promise { +export async function getConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake +): Promise { + const cached = configCache.get(serverId); + if (cached) { + return cached; + } db.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); - return db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get()!; + const config = db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get(); + if (!config) { + throw new Error(`Server config for ${serverId} is missing after insert`); + } + configCache.set(serverId, config); + return config; } export async function getConfigs(db: Db): Promise { @@ -15,11 +37,12 @@ export async function getConfigs(db: Db): Promise { } export async function updateConfig( - db: Db, + { db, configCache }: ConfigDeps, serverId: Snowflake, patch: ServerConfigPatch ): Promise { - return db.transaction((tx) => { + configCache.delete(serverId); + const updated = db.transaction((tx) => { tx.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); return tx .update(serverConfigs) @@ -28,24 +51,34 @@ export async function updateConfig( .returning() .get(); }); + configCache.set(serverId, updated); + return updated; } -export async function deleteConfig(db: Db, serverId: Snowflake): Promise { +export async function deleteConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake +): Promise { + configCache.delete(serverId); const result = db.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); return result.changes > 0; } -export async function resetAccessFailures(db: Db, config: ServerConfig): Promise { +export async function resetAccessFailures( + deps: ConfigDeps, + config: ServerConfig +): Promise { if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { return config; } - return updateConfig(db, config.serverId, { accessFailureCount: 0, firstAccessFailureAt: null }); + return updateConfig(deps, config.serverId, { accessFailureCount: 0, firstAccessFailureAt: null }); } export async function purgeGuild( - db: Db, + { db, configCache }: ConfigDeps, serverId: Snowflake ): Promise<{ config: boolean; profiles: number }> { + configCache.delete(serverId); return db.transaction((tx) => { const profiles = tx.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); const configs = tx.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index f8e73872..111c52ae 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -58,7 +58,7 @@ export const runDataCheck = async ( ); if (next.purge) { - const result = await purgeGuild(app.db, guild); + const result = await purgeGuild(app, guild); logger.warn('Purged guild after sustained loss of access', { guild, failures: next.accessFailureCount, @@ -72,7 +72,7 @@ export const runDataCheck = async ( next.accessFailureCount !== config.accessFailureCount || next.firstAccessFailureAt?.getTime() !== config.firstAccessFailureAt?.getTime() ) { - await updateConfig(app.db, config.serverId, { + await updateConfig(app, config.serverId, { accessFailureCount: next.accessFailureCount, firstAccessFailureAt: next.firstAccessFailureAt, }); diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index 0d4b83c1..ff28d79f 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -17,7 +17,7 @@ const zone = 'Europe/London'; const at = (iso: string): Date => DateTime.fromISO(iso, { zone }).toJSDate(); const base = createTestApp(); -const { clearConfigs, clearProfiles, createConfig, createProfiles } = dbFixtures(base.db); +const { clearConfigs, clearProfiles, createConfig, createProfiles } = dbFixtures(base); const appAt = (iso?: string): TestApp => createTestApp({ db: base.db, ...(iso ? { clock: () => at(iso) } : {}) }); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index a8d92ae8..19df3d97 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -13,7 +13,7 @@ import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); const { clearConfigs, clearProfiles, countProfiles, createConfig, createProfiles, findProfile } = - dbFixtures(app.db); + dbFixtures(app); after(() => { app.close(); diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index d5ede858..ac95f43c 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; import { count, eq } from 'drizzle-orm'; import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; -import { purgeGuild } from '../../src/services/ConfigService.ts'; +import { getConfig, purgeGuild } from '../../src/services/ConfigService.ts'; import { createTestApp } from '../helpers/app.ts'; const app = createTestApp(); @@ -13,6 +13,7 @@ after(() => { }); beforeEach(() => { + app.configCache.clear(); db.delete(userProfiles).run(); db.delete(serverConfigs).run(); db.insert(serverConfigs).values([{ serverId: 's1' }, { serverId: 's2' }]).run(); @@ -33,7 +34,7 @@ const profileCount = (serverId?: string): number => { const configCount = (): number => db.select({ n: count() }).from(serverConfigs).get()!.n; test('purgeGuild deletes only the target server and reports counts', async () => { - assert.deepEqual(await purgeGuild(db, 's1'), { config: true, profiles: 2 }); + assert.deepEqual(await purgeGuild(app, 's1'), { config: true, profiles: 2 }); assert.equal(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), undefined); assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's2')).get()); @@ -42,7 +43,7 @@ test('purgeGuild deletes only the target server and reports counts', async () => }); test('purgeGuild on an unknown server deletes nothing', async () => { - assert.deepEqual(await purgeGuild(db, 'nope'), { config: false, profiles: 0 }); + assert.deepEqual(await purgeGuild(app, 'nope'), { config: false, profiles: 0 }); assert.equal(configCount(), 2); assert.equal(profileCount(), 3); }); @@ -52,7 +53,7 @@ test('purgeGuild rolls back the profile delete when the config delete fails', as "CREATE TRIGGER block_config_delete BEFORE DELETE ON `ServerConfigs` BEGIN SELECT RAISE(ABORT, 'boom'); END" ); try { - await assert.rejects(purgeGuild(db, 's1'), /boom/); + await assert.rejects(purgeGuild(app, 's1'), /boom/); } finally { db.$client.exec('DROP TRIGGER block_config_delete'); } @@ -60,3 +61,10 @@ test('purgeGuild rolls back the profile delete when the config delete fails', as assert.equal(profileCount('s1'), 2); assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get()); }); + +test('purgeGuild drops the cached config', async () => { + await getConfig(app, 's1'); + assert.ok(app.configCache.has('s1')); + await purgeGuild(app, 's1'); + assert.equal(app.configCache.has('s1'), false); +}); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index 2082274a..c32feaa7 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; -import { getConfig, getConfigs, updateConfig } from '../../src/services/ConfigService.ts'; +import { + deleteConfig, + getConfig, + getConfigs, + resetAccessFailures, + updateConfig, +} from '../../src/services/ConfigService.ts'; import { clearBirthday, getServerBirthdays, @@ -12,34 +18,89 @@ import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); const { db } = app; -const { allProfiles, clearConfigs, clearProfiles } = dbFixtures(db); +const { allProfiles, clearConfigs, clearProfiles } = dbFixtures(app); after(() => { app.close(); }); beforeEach(() => { + app.configCache.clear(); clearProfiles(); clearConfigs(); }); test('getConfig creates the row once and returns the same one afterwards', async () => { - const first = await getConfig(db, 's1'); - const second = await getConfig(db, 's1'); + const first = await getConfig(app, 's1'); + const second = await getConfig(app, 's1'); assert.equal((await getConfigs(db)).length, 1); assert.equal(first.createdAt.getTime(), second.createdAt.getTime()); assert.equal(first.debug, false); }); test('updateConfig patches fields, bumps updatedAt and creates a missing row', async () => { - const created = await updateConfig(db, 's1', { auditChannelId: 'c1' }); + const created = await updateConfig(app, 's1', { auditChannelId: 'c1' }); assert.equal(created.auditChannelId, 'c1'); - const updated = await updateConfig(db, 's1', { debug: true, auditChannelId: null }); + const updated = await updateConfig(app, 's1', { debug: true, auditChannelId: null }); assert.equal(updated.debug, true); assert.equal(updated.auditChannelId, null); assert.ok(updated.updatedAt.getTime() >= created.updatedAt.getTime()); - assert.equal((await getConfig(db, 's1')).debug, true); + assert.equal((await getConfig(app, 's1')).debug, true); +}); + +const countSelects = (): { count: () => number; restore: () => void } => { + const select = db.select.bind(db); + let calls = 0; + db.select = ((...args: Parameters) => { + calls += 1; + return select(...args); + }) as typeof db.select; + return { + count: () => calls, + restore: () => { + db.select = select as typeof db.select; + }, + }; +}; + +test('getConfig serves repeat lookups from the cache without touching the database', async () => { + const spy = countSelects(); + try { + const first = await getConfig(app, 's1'); + assert.equal(spy.count(), 1); + assert.equal(await getConfig(app, 's1'), first); + assert.equal(spy.count(), 1); + } finally { + spy.restore(); + } +}); + +test('updateConfig refreshes the cached config', async () => { + await getConfig(app, 's1'); + const updated = await updateConfig(app, 's1', { debug: true }); + const spy = countSelects(); + try { + assert.equal(await getConfig(app, 's1'), updated); + assert.equal(spy.count(), 0); + } finally { + spy.restore(); + } + assert.equal((await getConfig(app, 's1')).debug, true); +}); + +test('resetAccessFailures updates the cache', async () => { + const config = await updateConfig(app, 's1', { accessFailureCount: 2, firstAccessFailureAt: new Date() }); + const reset = await resetAccessFailures(app, config); + assert.equal(reset.accessFailureCount, 0); + assert.equal((await getConfig(app, 's1')).accessFailureCount, 0); +}); + +test('deleteConfig invalidates the cache so the next lookup recreates defaults', async () => { + await updateConfig(app, 's1', { debug: true }); + assert.equal(await deleteConfig(app, 's1'), true); + assert.equal(app.configCache.has('s1'), false); + assert.equal((await getConfig(app, 's1')).debug, false); }); test('incrementActivityScore upserts and increments atomically', async () => { diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index f531d666..75ba98e4 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -12,7 +12,7 @@ const t0 = new Date('2027-01-01T00:00:00Z'); const base = createTestApp(); const { allProfiles, clearConfigs, clearProfiles, countProfiles, createConfig, createConfigs, createProfiles, findConfig } = - dbFixtures(base.db); + dbFixtures(base); const run = (client: ReturnType, now: Date, p = policy): Promise => runDataCheck(createTestApp({ db: base.db, clock: () => now }), client, p); diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index f7fdd5cb..98e6ba21 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -1,5 +1,5 @@ import { eq } from 'drizzle-orm'; -import type { Db } from '../../src/db/db.ts'; +import type { ConfigDeps } from '../../src/services/ConfigService.ts'; import { serverConfigs, userProfiles, @@ -10,20 +10,27 @@ import { type ConfigInsert = typeof serverConfigs.$inferInsert; type ProfileInsert = typeof userProfiles.$inferInsert; -/** Direct table access for arranging and asserting database state in tests. */ -export function dbFixtures(db: Db) { +/** + * Direct table access for arranging and asserting database state in tests. + * Config mutations bypass ConfigService, so they also reset the config cache. + */ +export function dbFixtures({ db, configCache }: ConfigDeps) { const allProfiles = (): UserProfile[] => db.select().from(userProfiles).all(); return { clearConfigs: (): void => { db.delete(serverConfigs).run(); + configCache.clear(); }, clearProfiles: (): void => { db.delete(userProfiles).run(); }, - createConfig: (values: ConfigInsert): ServerConfig => - db.insert(serverConfigs).values(values).returning().get(), + createConfig: (values: ConfigInsert): ServerConfig => { + configCache.delete(values.serverId); + return db.insert(serverConfigs).values(values).returning().get(); + }, createConfigs: (values: ConfigInsert[]): void => { db.insert(serverConfigs).values(values).run(); + configCache.clear(); }, findConfig: (serverId: string): ServerConfig | null => db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? null, diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index a4d0ba01..688b937e 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -12,14 +12,14 @@ import { dbFixtures } from '../helpers/db.ts'; import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages.ts'; const app = createTestApp(); -const { clearProfiles, findProfile } = dbFixtures(app.db); +const { clearProfiles, findProfile } = dbFixtures(app); after(() => { app.close(); }); beforeEach(async () => { - await updateConfig(app.db, 'guild-1', { auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); + await updateConfig(app, 'guild-1', { auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); }); afterEach(() => { diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index 2c0e550e..a27b39c8 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -10,7 +10,7 @@ import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messa const app = createTestApp(); before(async () => { - await updateConfig(app.db, 'guild-1', { auditChannelId: 'audit-1' }); + await updateConfig(app, 'guild-1', { auditChannelId: 'audit-1' }); }); after(() => { diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index f079c44a..92bb1e0e 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -13,7 +13,7 @@ import { rejectsUserError } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); -const { clearConfigs, createConfig } = dbFixtures(app.db); +const { clearConfigs, createConfig } = dbFixtures(app); after(() => { app.close(); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 97460d0e..efef2786 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -7,7 +7,7 @@ import { createTestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); -const { clearConfigs, createConfig } = dbFixtures(app.db); +const { clearConfigs, createConfig } = dbFixtures(app); after(() => { app.close(); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 6babf49c..9eb4710e 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -18,7 +18,7 @@ import { createTestApp } from './helpers/app.ts'; import { dbFixtures } from './helpers/db.ts'; const app = createTestApp(); -const { clearConfigs, findConfig } = dbFixtures(app.db); +const { clearConfigs, findConfig } = dbFixtures(app); let config: ServerConfig; let persistedAtReply: (ServerConfig | null)[]; @@ -28,7 +28,7 @@ after(() => { beforeEach(async () => { clearConfigs(); - config = await getConfig(app.db, 'guild-1'); + config = await getConfig(app, 'guild-1'); persistedAtReply = []; }); @@ -80,7 +80,7 @@ test('channelSetting set saves before replying and persists', async () => { assert.equal(persistedAtReply[0]?.auditChannelId, '123'); assert.deepEqual(texts(replies), ['Audit channel set to <#123>.']); - config = await getConfig(app.db, 'guild-1'); + config = await getConfig(app, 'guild-1'); assert.equal(config.auditChannelId, '123'); }); @@ -100,14 +100,14 @@ test('channelSetting get reports set, missing and not-set channels', async () => }); test('channelSetting clear nulls the field, saves, then replies', async () => { - config = await updateConfig(app.db, 'guild-1', { auditChannelId: '123' }); + config = await updateConfig(app, 'guild-1', { auditChannelId: '123' }); const { ctx, replies } = makeCtx({}); await run(channelGroup, 'clear')(ctx); assert.equal(persistedAtReply[0]?.auditChannelId, null); assert.deepEqual(texts(replies), ['Audit channel cleared.']); - config = await getConfig(app.db, 'guild-1'); + config = await getConfig(app, 'guild-1'); assert.equal(config.auditChannelId, null); }); @@ -128,7 +128,7 @@ test('booleanSetting set saves before replying; get reads the stored value', asy await run(boolGroup, 'set')(set.ctx); assert.equal(persistedAtReply[0]?.systemMessagesEnabled, true); assert.deepEqual(texts(set.replies), ['Bot system messages: enabled']); - config = await getConfig(app.db, 'guild-1'); + config = await getConfig(app, 'guild-1'); assert.equal(config.systemMessagesEnabled, true); const get = makeCtx({}); From dd2e7ad13a2a5ac58b057400fe2b0fc0e399553d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:20:16 +0100 Subject: [PATCH 26/77] Register slash commands only when their hash changes and add a deploy script Co-Authored-By: Claude Sonnet 5.5 --- README.md | 9 ++++ package.json | 1 + src/db/migrations/004-bot-state.ts | 7 +++ src/db/migrator.ts | 2 + src/db/schema.ts | 6 +++ src/events/ready.ts | 4 +- src/framework/registrar.ts | 82 ++++++++++++++++++++++++++---- src/scripts/deploy-commands.ts | 19 +++++++ src/services/BotState.ts | 14 +++++ tests/database/migrations.test.ts | 2 +- tests/database/schema.test.ts | 4 +- tests/registrar.test.ts | 77 ++++++++++++++++++++++++++++ 12 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 src/db/migrations/004-bot-state.ts create mode 100644 src/scripts/deploy-commands.ts create mode 100644 src/services/BotState.ts create mode 100644 tests/registrar.test.ts diff --git a/README.md b/README.md index 8f46d728..10d58572 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ The invite URL should include all these permissions. You can generate one in the Use `DEV_GUILD_ID` in `.env` to test commands instantly in a single guild instead of waiting 1 hour for global registration. +On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). To force a registration without starting the bot, run: + +```bash +npm run deploy:commands +``` + ## Commands Commands are organized into categories. Only **admin-only** commands require the Administrator permission; all others are available to any member. @@ -250,6 +256,7 @@ Migrations run automatically on startup. Current migrations: - **001-baseline** — Initial schema (ServerConfigs and UserProfiles tables) - **002-userprofile-unique** — Adds a unique constraint on (serverId, userId). If duplicates exist, they are merged: the profile with the most recent birthday is kept, and activity scores are summed. **Backups are strongly recommended before upgrading from v2 to v3** because this migration is permanent and irreversible. - **003-serverconfig-access-tracking** — Adds access failure tracking for orphaned guild detection +- **004-bot-state** — Adds the `BotState` key/value table (currently stores the registered command hash) ### What Gets Deleted @@ -282,6 +289,8 @@ src/ command.ts # Command definition and resolution event.ts # Event definition settings.ts # Reusable /config setting groups (channel, boolean) + scripts/ + deploy-commands.ts # One-off slash command registration (npm run deploy:commands) services/ # Business logic (onboarding, birthdays, audit, etc.) utilities/ # Logging, formatting index.ts # Entry point diff --git a/package.json b/package.json index e9be7adc..26b39c47 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "node --test \"tests/**/*.test.ts\"", "dev": "node --watch --env-file-if-exists=.env src/index.ts", "start": "node --env-file-if-exists=.env src/index.ts", + "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/src/db/migrations/004-bot-state.ts b/src/db/migrations/004-bot-state.ts new file mode 100644 index 00000000..3773b4bb --- /dev/null +++ b/src/db/migrations/004-bot-state.ts @@ -0,0 +1,7 @@ +import type { Database } from 'better-sqlite3'; + +export function up(db: Database): void { + db.exec( + 'CREATE TABLE IF NOT EXISTS `BotState` (`key` TEXT PRIMARY KEY, `value` TEXT NOT NULL, `updatedAt` DATETIME NOT NULL)' + ); +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 7e42d0f5..70e84d46 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -3,6 +3,7 @@ import type { Logger } from '../utilities/Logger.ts'; import * as baseline from './migrations/001-baseline.ts'; import * as userProfileUnique from './migrations/002-userprofile-unique.ts'; import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking.ts'; +import * as botState from './migrations/004-bot-state.ts'; export interface Migration { name: string; @@ -13,6 +14,7 @@ export const migrations: Migration[] = [ { name: '001-baseline', up: baseline.up }, { name: '002-userprofile-unique', up: userProfileUnique.up }, { name: '003-serverconfig-access-tracking', up: serverConfigAccessTracking.up }, + { name: '004-bot-state', up: botState.up }, ]; /** Applies pending migrations in order, each in its own transaction, and returns their names. */ diff --git a/src/db/schema.ts b/src/db/schema.ts index 2d6c0f3b..3ed7c6f9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -60,5 +60,11 @@ export const userProfiles = sqliteTable( (table) => [uniqueIndex('user_profiles_server_user_unique').on(table.serverId, table.userId)] ); +export const botState = sqliteTable('BotState', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: datetime('updatedAt').notNull(), +}); + export type ServerConfig = typeof serverConfigs.$inferSelect; export type UserProfile = typeof userProfiles.$inferSelect; diff --git a/src/events/ready.ts b/src/events/ready.ts index f7e18e07..817c5fda 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,5 +1,5 @@ import { defineEvent } from '../framework/event.ts'; -import { registerCommands } from '../framework/registrar.ts'; +import { syncCommands } from '../framework/registrar.ts'; import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService.ts'; import { runDataCheck } from '../services/DataCheckService.ts'; import { Scheduler } from '../services/Scheduler.ts'; @@ -12,7 +12,7 @@ export default defineEvent({ client.user!.setPresence({ status: 'online' }); logger.info('Online'); - await registerCommands(app, [...client.slashCommands.values()]); + await syncCommands(app, [...client.slashCommands.values()]); try { const scheduler = new Scheduler(logger, env.jobSchedule, env.timezone, [ diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts index 16b7e67d..d9caef8e 100644 --- a/src/framework/registrar.ts +++ b/src/framework/registrar.ts @@ -1,22 +1,84 @@ +import { createHash } from 'node:crypto'; import { REST, Routes } from 'discord.js'; import type { App } from '../app.ts'; +import type { Env } from '../config/env.ts'; +import { getState, setState } from '../services/BotState.ts'; import type { Command } from './command.ts'; -export async function registerCommands(app: App, commands: Command[]): Promise { - const { env, logger } = app; - const guildId = env.devGuildId; - const scope = guildId ? `dev guild ${guildId}` : 'globally'; - logger.info(`Registering ${commands.length} application commands ${scope}`); +export interface CommandScope { + /** Human-readable target, for logs. */ + label: string; + /** BotState key under which the last registered hash is stored. */ + stateKey: string; +} + +export const commandScope = (env: Pick): CommandScope => + env.devGuildId + ? { label: `dev guild ${env.devGuildId}`, stateKey: `commandsHash:guild:${env.devGuildId}` } + : { label: 'globally', stateKey: 'commandsHash:global' }; + +const canonical = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(canonical); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entry]) => [key, canonical(entry)]) + ); + } + return value; +}; + +/** SHA-256 of the commands' JSON with object keys sorted, so it only changes with their content. */ +export function hashCommands(commands: readonly Command[]): string { + const body = commands.map((command) => canonical(command.toJSON())); + return createHash('sha256').update(JSON.stringify(body)).digest('hex'); +} +/** Bulk-overwrites the application's commands in the env's scope and returns how many were set. */ +export async function putCommands( + env: Pick, + commands: readonly Command[] +): Promise { const rest = new REST({ version: '10' }).setToken(env.token); - const route = guildId - ? Routes.applicationGuildCommands(env.clientId, guildId) + const route = env.devGuildId + ? Routes.applicationGuildCommands(env.clientId, env.devGuildId) : Routes.applicationCommands(env.clientId); + const data = (await rest.put(route, { body: commands.map((c) => c.toJSON()) })) as unknown[]; + return data.length; +} + +export type PutCommands = typeof putCommands; + +/** Registers commands only when they differ from what was last registered for this scope. */ +export async function syncCommands( + app: Pick, + commands: readonly Command[], + put: PutCommands = putCommands +): Promise<'registered' | 'unchanged' | 'failed'> { + const { env, db, logger, clock } = app; + const scope = commandScope(env); + const hash = hashCommands(commands); + + if (getState(db, scope.stateKey) === hash) { + logger.info(`Application commands unchanged, skipping registration ${scope.label}`, { + hash: hash.slice(0, 12), + }); + return 'unchanged'; + } + logger.info(`Registering ${commands.length} application commands ${scope.label}`); try { - const data = (await rest.put(route, { body: commands.map((c) => c.toJSON()) })) as unknown[]; - logger.info(`Registered ${data.length} application commands ${scope}`); + const count = await put(env, commands); + setState(db, scope.stateKey, hash, clock()); + logger.info(`Registered ${count} application commands ${scope.label}`, { + hash: hash.slice(0, 12), + }); + return 'registered'; } catch (error) { - logger.error('Failed to register application commands', { scope }, error); + logger.error('Failed to register application commands', { scope: scope.label }, error); + return 'failed'; } } diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts new file mode 100644 index 00000000..7196d844 --- /dev/null +++ b/src/scripts/deploy-commands.ts @@ -0,0 +1,19 @@ +import { commands } from '../commands/index.ts'; +import { loadEnv } from '../config/env.ts'; +import { commandScope, putCommands } from '../framework/registrar.ts'; +import { validateRegistry } from '../framework/registry.ts'; +import { createConsoleLogger } from '../utilities/Logger.ts'; + +const logger = createConsoleLogger(); + +try { + const env = loadEnv(); + validateRegistry('command', commands); + const { label } = commandScope(env); + logger.info(`Deploying ${commands.length} application commands ${label}`); + const count = await putCommands(env, commands); + logger.info(`Deployed ${count} application commands ${label}`); +} catch (error) { + logger.error('Command deployment failed', undefined, error); + process.exitCode = 1; +} diff --git a/src/services/BotState.ts b/src/services/BotState.ts new file mode 100644 index 00000000..9962c67e --- /dev/null +++ b/src/services/BotState.ts @@ -0,0 +1,14 @@ +import { eq } from 'drizzle-orm'; +import type { Db } from '../db/db.ts'; +import { botState } from '../db/schema.ts'; + +export function getState(db: Db, key: string): string | undefined { + return db.select().from(botState).where(eq(botState.key, key)).get()?.value; +} + +export function setState(db: Db, key: string, value: string, now: Date = new Date()): void { + db.insert(botState) + .values({ key, value, updatedAt: now }) + .onConflictDoUpdate({ target: botState.key, set: { value, updatedAt: now } }) + .run(); +} diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index 9ed23f54..6bb771d7 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -9,7 +9,7 @@ const { logger, logs } = fakeLogger(); type Db = Database.Database; const INDEX = 'user_profiles_server_user_unique'; -const ALL = ['001-baseline', '002-userprofile-unique', '003-serverconfig-access-tracking']; +const ALL = ['001-baseline', '002-userprofile-unique', '003-serverconfig-access-tracking', '004-bot-state']; const LEGACY_SERVER_CONFIGS = "CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `prefix` VARCHAR(255) DEFAULT '^', `rulesMessagePath` VARCHAR(255), `rulesMessage` VARCHAR(255), `guestRoleIds` VARCHAR(255), `adminRoleId` VARCHAR(255), `welcomeMessage` VARCHAR(255), `debug` TINYINT(1) DEFAULT 0, `auditChannelId` VARCHAR(255), `welcomeMessageBackgroundUrl` VARCHAR(255), `systemMessagesEnabled` TINYINT(1) DEFAULT 0, `announcementsChannelId` VARCHAR(255), `birthdayCalendarMessagePath` VARCHAR(255), `honeyPotChannelId` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)"; diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts index 2c9e47d9..952aaa0a 100644 --- a/tests/database/schema.test.ts +++ b/tests/database/schema.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { getTableColumns, getTableName } from 'drizzle-orm'; import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; -import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; +import { botState, serverConfigs, userProfiles } from '../../src/db/schema.ts'; import { createTestApp } from '../helpers/app.ts'; const columnNames = (table: SQLiteTable): string[] => @@ -11,7 +11,7 @@ const columnNames = (table: SQLiteTable): string[] => test('Drizzle schema columns exist in the migrated database', () => { const app = createTestApp(); try { - for (const table of [serverConfigs, userProfiles]) { + for (const table of [serverConfigs, userProfiles, botState]) { const actual = ( app.db.$client.pragma(`table_info(\`${getTableName(table)}\`)`) as { name: string }[] ).map((column) => column.name); diff --git a/tests/registrar.test.ts b/tests/registrar.test.ts new file mode 100644 index 00000000..fd2e7406 --- /dev/null +++ b/tests/registrar.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { defineCommand, type Command } from '../src/framework/command.ts'; +import { commandScope, hashCommands, syncCommands } from '../src/framework/registrar.ts'; +import { getState } from '../src/services/BotState.ts'; +import { createTestApp } from './helpers/app.ts'; + +const app = createTestApp(); + +after(() => { + app.close(); +}); + +const command = (name: string, description = 'd'): Command => + defineCommand({ name, description, run: async () => {} }); + +const recorder = () => { + const calls: number[] = []; + return { + calls, + put: async (_env: unknown, commands: readonly Command[]) => { + calls.push(commands.length); + return commands.length; + }, + }; +}; + +beforeEach(() => { + app.db.$client.exec('DELETE FROM `BotState`'); +}); + +test('hashCommands is stable and changes with command content', () => { + const a = [command('one'), command('two')]; + assert.equal(hashCommands(a), hashCommands([command('one'), command('two')])); + assert.notEqual(hashCommands(a), hashCommands([command('one'), command('two', 'changed')])); + assert.notEqual(hashCommands(a), hashCommands([command('one')])); +}); + +test('commandScope distinguishes global and dev guild targets', () => { + assert.equal(commandScope({ devGuildId: undefined }).stateKey, 'commandsHash:global'); + assert.equal(commandScope({ devGuildId: '42' }).stateKey, 'commandsHash:guild:42'); +}); + +test('syncCommands registers once, then skips while the hash is unchanged', async () => { + const { calls, put } = recorder(); + const commands = [command('one')]; + + assert.equal(await syncCommands(app, commands, put), 'registered'); + assert.equal(await syncCommands(app, commands, put), 'unchanged'); + assert.deepEqual(calls, [1]); + assert.equal(getState(app.db, 'commandsHash:global'), hashCommands(commands)); + assert.ok(app.logsAt('info').some((entry) => /unchanged, skipping/.test(entry.message))); + + assert.equal(await syncCommands(app, [command('one'), command('two')], put), 'registered'); + assert.deepEqual(calls, [1, 2]); +}); + +test('syncCommands tracks the hash per scope', async () => { + const { calls, put } = recorder(); + const commands = [command('one')]; + await syncCommands(app, commands, put); + + const dev = { ...app, env: Object.freeze({ ...app.env, devGuildId: '42' }) }; + assert.equal(await syncCommands(dev, commands, put), 'registered'); + assert.equal(await syncCommands(dev, commands, put), 'unchanged'); + assert.deepEqual(calls, [1, 1]); + assert.equal(getState(app.db, 'commandsHash:guild:42'), hashCommands(commands)); +}); + +test('syncCommands does not store the hash when registration fails', async () => { + const failing = async (): Promise => { + throw new Error('discord down'); + }; + assert.equal(await syncCommands(app, [command('one')], failing), 'failed'); + assert.equal(getState(app.db, 'commandsHash:global'), undefined); + assert.equal(app.logsAt('error').length, 1); +}); From 6565550dae7888c07f1409698d0d0f7a1d01f8d4 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:21:03 +0100 Subject: [PATCH 27/77] Install process-level crash and signal handlers with bounded shutdown Co-Authored-By: Claude Sonnet 5.5 --- src/client/DingirClient.ts | 26 +---------- src/framework/process.ts | 88 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 15 +++++-- src/utilities/Logger.ts | 6 ++- tests/helpers/app.ts | 4 +- tests/process.test.ts | 82 +++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 src/framework/process.ts create mode 100644 tests/process.test.ts diff --git a/src/client/DingirClient.ts b/src/client/DingirClient.ts index e9fba1bb..a3174c04 100644 --- a/src/client/DingirClient.ts +++ b/src/client/DingirClient.ts @@ -9,7 +9,6 @@ import { events } from '../events/index.ts'; class DingirClient extends Client { public slashCommands: Collection = new Collection(); private readonly app: App; - private shuttingDown = false; public constructor(app: App) { super({ @@ -39,34 +38,11 @@ class DingirClient extends Client { this.on('error', (error) => logger.error('Discord client error', undefined, error)); this.on('warn', (message) => logger.warn(message)); - process.on('unhandledRejection', (reason) => - logger.error('Unhandled promise rejection', undefined, reason) - ); - for (const signal of ['SIGTERM', 'SIGINT'] as const) { - process.on(signal, () => { - void this.shutdown(signal); - }); - } + this.app.shutdown.register(() => this.destroy()); await this.login(env.token); logger.info('Logged in'); } - - private async shutdown(signal: string): Promise { - const { logger } = this.app; - if (this.shuttingDown) { - return; - } - this.shuttingDown = true; - logger.info(`${signal} received, shutting down`); - try { - await this.destroy(); - } catch (error) { - logger.error('Error destroying client', undefined, error); - } - await this.app.shutdown.run(); - process.exit(0); - } } export { DingirClient }; diff --git a/src/framework/process.ts b/src/framework/process.ts new file mode 100644 index 00000000..2b1d127e --- /dev/null +++ b/src/framework/process.ts @@ -0,0 +1,88 @@ +import type { Logger } from '../utilities/Logger.ts'; + +export interface ProcessHandlerDeps { + proc: Pick; + logger: Logger; + /** Runs the registered shutdown hooks; called lazily because the app may not exist yet. */ + runShutdown: () => Promise; + exit: (code: number) => void; + /** How long shutdown hooks may take after an uncaught exception. */ + crashTimeoutMs?: number; + /** How long shutdown hooks may take after SIGTERM/SIGINT. */ + signalTimeoutMs?: number; +} + +export interface ProcessHandlers { + onUnhandledRejection(reason: unknown): void; + onUncaughtException(error: unknown): Promise; + onSignal(signal: string): Promise; +} + +/** Resolves true if `run` finished (even by throwing) before the timeout, false if it timed out. */ +async function runBounded(run: () => Promise, ms: number, logger: Logger): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ms); + }); + const finished = run().then( + () => true as const, + (error: unknown) => { + logger.error('Shutdown failed', undefined, error); + return true as const; + } + ); + try { + return await Promise.race([finished, timeout]); + } finally { + clearTimeout(timer); + } +} + +/** Registers process-wide crash and signal handlers; call before anything else runs. */ +export function installProcessHandlers(deps: ProcessHandlerDeps): ProcessHandlers { + const { proc, logger, runShutdown, exit } = deps; + const crashTimeoutMs = deps.crashTimeoutMs ?? 5_000; + const signalTimeoutMs = deps.signalTimeoutMs ?? 10_000; + let stopping = false; + + const handlers: ProcessHandlers = { + onUnhandledRejection: (reason) => { + logger.error('Unhandled promise rejection', undefined, reason); + }, + onUncaughtException: async (error) => { + if (stopping) { + return; + } + stopping = true; + logger.fatal('Uncaught exception, shutting down', undefined, error); + if (!(await runBounded(runShutdown, crashTimeoutMs, logger))) { + logger.error('Shutdown hooks timed out, forcing exit', { timeoutMs: crashTimeoutMs }); + } + exit(1); + }, + onSignal: async (signal) => { + if (stopping) { + return; + } + stopping = true; + logger.info(`${signal} received, shutting down`); + if (await runBounded(runShutdown, signalTimeoutMs, logger)) { + exit(0); + } else { + logger.error('Shutdown hooks timed out, forcing exit', { timeoutMs: signalTimeoutMs }); + exit(1); + } + }, + }; + + proc.on('unhandledRejection', handlers.onUnhandledRejection); + proc.on('uncaughtException', (error) => { + void handlers.onUncaughtException(error); + }); + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + proc.on(signal, () => { + void handlers.onSignal(signal); + }); + } + return handlers; +} diff --git a/src/index.ts b/src/index.ts index 6fddf7c7..a6f29860 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,18 @@ -import { createApp } from './app.ts'; +import { type App, createApp } from './app.ts'; import { DingirClient } from './client/DingirClient.ts'; import { type Env, loadEnv } from './config/env.ts'; +import { installProcessHandlers } from './framework/process.ts'; import { createConsoleLogger } from './utilities/Logger.ts'; const logger = createConsoleLogger(); +let app: App | undefined; + +installProcessHandlers({ + proc: process, + logger, + runShutdown: async () => app?.shutdown.run(), + exit: (code) => process.exit(code), +}); async function main(): Promise { let env: Env; @@ -15,11 +24,11 @@ async function main(): Promise { process.exit(1); } - const app = createApp({ env, logger }); + app = createApp({ env, logger }); await new DingirClient(app).start(); } main().catch((error: unknown) => { - logger.error('Fatal error during startup.', undefined, error); + logger.fatal('Fatal error during startup.', undefined, error); process.exit(1); }); diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index d1f8b4d9..8316402e 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -1,5 +1,5 @@ type Context = Record; -type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; +type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; const timestamp = (): string => new Date().toISOString(); @@ -35,12 +35,13 @@ export interface Logger { info(message: string, context?: Context, error?: unknown): void; warn(message: string, context?: Context, error?: unknown): void; error(message: string, context?: Context, error?: unknown): void; + fatal(message: string, context?: Context, error?: unknown): void; } const emit = (level: Level, message: string, context?: Context, error?: unknown): void => { const ctx = formatContext(context); const line = `[${timestamp()}] ${level}: ${message}${ctx ? ` ${ctx}` : ''}`; - const write = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log; + const write = level === 'ERROR' || level === 'FATAL' ? console.error : level === 'WARN' ? console.warn : console.log; write(line); if (error !== undefined && error !== null) { write(`[${timestamp()}] ${level}: ${formatError(error)}`); @@ -52,4 +53,5 @@ export const createConsoleLogger = (): Logger => ({ info: (message, context, error) => emit('INFO', message, context, error), warn: (message, context, error) => emit('WARN', message, context, error), error: (message, context, error) => emit('ERROR', message, context, error), + fatal: (message, context, error) => emit('FATAL', message, context, error), }); diff --git a/tests/helpers/app.ts b/tests/helpers/app.ts index 9f705074..c5bc276c 100644 --- a/tests/helpers/app.ts +++ b/tests/helpers/app.ts @@ -4,7 +4,7 @@ import { openDb } from '../../src/db/db.ts'; import { migrate } from '../../src/db/migrator.ts'; import type { Logger } from '../../src/utilities/Logger.ts'; -export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; export interface LogEntry { level: LogLevel; @@ -31,7 +31,7 @@ export function fakeLogger(): { logger: Logger; logs: LogEntry[] } { logs.push({ level, message, context, error }); }; return { - logger: { debug: at('debug'), info: at('info'), warn: at('warn'), error: at('error') }, + logger: { debug: at('debug'), info: at('info'), warn: at('warn'), error: at('error'), fatal: at('fatal') }, logs, }; } diff --git a/tests/process.test.ts b/tests/process.test.ts new file mode 100644 index 00000000..7fd83b7e --- /dev/null +++ b/tests/process.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { installProcessHandlers, type ProcessHandlerDeps } from '../src/framework/process.ts'; +import { fakeLogger } from './helpers/app.ts'; + +const setup = (runShutdown: () => Promise, timeouts = {}) => { + const proc = new EventEmitter(); + const { logger, logs } = fakeLogger(); + const exits: number[] = []; + const handlers = installProcessHandlers({ + proc: proc as unknown as ProcessHandlerDeps['proc'], + logger, + runShutdown, + exit: (code) => { + exits.push(code); + }, + ...timeouts, + }); + return { proc, logs, exits, handlers }; +}; + +const never = (): Promise => new Promise(() => {}); + +test('unhandled rejections are logged and do not exit', () => { + const { proc, logs, exits } = setup(async () => {}); + proc.emit('unhandledRejection', new Error('nope')); + assert.equal(logs.length, 1); + assert.equal(logs[0]?.level, 'error'); + assert.deepEqual(exits, []); +}); + +test('uncaught exceptions log fatal, run shutdown, then exit 1', async () => { + const order: string[] = []; + const { logs, exits, handlers } = setup(async () => { + order.push('shutdown'); + }); + await handlers.onUncaughtException(new Error('boom')); + assert.equal(logs[0]?.level, 'fatal'); + assert.deepEqual(order, ['shutdown']); + assert.deepEqual(exits, [1]); +}); + +test('uncaught exceptions force exit when shutdown hangs', async () => { + const { logs, exits, handlers } = setup(never, { crashTimeoutMs: 10 }); + await handlers.onUncaughtException(new Error('boom')); + assert.deepEqual(exits, [1]); + assert.ok(logs.some((l) => /timed out/.test(l.message))); +}); + +test('signals run shutdown and exit 0, only once', async () => { + let runs = 0; + const { exits, handlers } = setup(async () => { + runs += 1; + }); + await handlers.onSignal('SIGTERM'); + await handlers.onSignal('SIGINT'); + assert.equal(runs, 1); + assert.deepEqual(exits, [0]); +}); + +test('signals force exit 1 when shutdown hangs', async () => { + const { exits, handlers } = setup(never, { signalTimeoutMs: 10 }); + await handlers.onSignal('SIGINT'); + assert.deepEqual(exits, [1]); +}); + +test('a throwing shutdown still exits', async () => { + const { logs, exits, handlers } = setup(async () => { + throw new Error('hook'); + }); + await handlers.onSignal('SIGTERM'); + assert.deepEqual(exits, [0]); + assert.ok(logs.some((l) => l.message === 'Shutdown failed')); +}); + +test('real process events are wired to the handlers', () => { + const { proc } = setup(async () => {}); + for (const event of ['unhandledRejection', 'uncaughtException', 'SIGTERM', 'SIGINT']) { + assert.equal(proc.listenerCount(event), 1, event); + } +}); From f1ad314ded159186dd3b164fecca8d2c7f7c3a18 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:21:56 +0100 Subject: [PATCH 28/77] Replace console logger with pino structured logging and add LOG_LEVEL Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 3 + README.md | 3 + package-lock.json | 293 ++++++++++++++++++++++++++++++++- package.json | 5 +- src/app.ts | 2 +- src/config/env.ts | 14 ++ src/index.ts | 5 +- src/scripts/deploy-commands.ts | 4 +- src/utilities/Logger.ts | 82 +++++---- tests/env.test.ts | 6 + tests/logger.test.ts | 92 +++++++---- 11 files changed, 426 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index 059f87cf..003ecb1f 100644 --- a/.env.example +++ b/.env.example @@ -16,5 +16,8 @@ PURGE_MIN_FAILURES=3 # ...spanning at least this many days PURGE_GRACE_DAYS=7 +# Log verbosity: fatal, error, warn, info, debug or trace +LOG_LEVEL=info + # Optional: register slash commands to this guild only (instant updates during development) # DEV_GUILD_ID= diff --git a/README.md b/README.md index 10d58572..132eaff1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ For watch mode during development: npm run dev ``` +Logs are structured JSON. For human-readable output while developing, use `npm run dev:pretty` (pipes through `pino-pretty`, a dev dependency). In production, pipe `docker logs` or `npm start` through `npx pino-pretty` if you want the same. + Test and lint: ```bash npm test # Run the test suite @@ -51,6 +53,7 @@ All configuration is managed through environment variables in `.env`. See `.env. | `DB_PATH` | No | `data/dingir.sqlite` | Path to SQLite database file. Use `:memory:` for throwaway runs (no persistence) | | `PURGE_MIN_FAILURES` | No | `3` | Minimum number of failed guild access checks before purging orphaned data (see Data & Retention) | | `PURGE_GRACE_DAYS` | No | `7` | Grace period (days) after first failure before purging. Data is purged only after both thresholds are met | +| `LOG_LEVEL` | No | `info` | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug` or `trace`. Logs are JSON, one object per line | | `DEV_GUILD_ID` | No | — | Guild ID for instant slash command registration during development (commands update immediately instead of within 1 hour). Omit in production | ## Discord Setup diff --git a/package-lock.json b/package-lock.json index 7fd26773..feae83d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "croner": "^10.0.1", "discord.js": "^14.26.5", "drizzle-orm": "^0.45.3", - "luxon": "^3.7.2" + "luxon": "^3.7.2", + "pino": "^10.4.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -21,6 +22,7 @@ "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "eslint": "^10.6.0", + "pino-pretty": "^13.2.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0" }, @@ -665,6 +667,12 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -1073,6 +1081,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/better-sqlite3": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", @@ -1085,6 +1102,13 @@ "node": ">=22" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/croner": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", @@ -1119,6 +1143,16 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1309,6 +1343,16 @@ } } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1516,6 +1560,13 @@ "node": ">=0.10.0" } }, + "node_modules/fast-copy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.2.tgz", + "integrity": "sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1536,6 +1587,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1618,6 +1676,13 @@ "node": ">=10.13.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1668,6 +1733,16 @@ "dev": true, "license": "ISC" }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1756,6 +1831,16 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1779,6 +1864,25 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1862,6 +1966,78 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.2.0.tgz", + "integrity": "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.2", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.3", + "secure-json-parse": "^4.1.0", + "sonic-boom": "^5.0.0", + "strip-json-comments": "^5.0.3" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/sonic-boom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-5.0.1.tgz", + "integrity": "sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1872,6 +2048,33 @@ "node": ">= 0.8.0" } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1882,6 +2085,44 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1918,6 +2159,49 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2062,6 +2346,13 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 26b39c47..6c5e72a2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint": "eslint .", "test": "node --test \"tests/**/*.test.ts\"", "dev": "node --watch --env-file-if-exists=.env src/index.ts", + "dev:pretty": "node --watch --env-file-if-exists=.env src/index.ts | pino-pretty", "start": "node --env-file-if-exists=.env src/index.ts", "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", "typecheck": "tsc --noEmit" @@ -23,7 +24,8 @@ "croner": "^10.0.1", "discord.js": "^14.26.5", "drizzle-orm": "^0.45.3", - "luxon": "^3.7.2" + "luxon": "^3.7.2", + "pino": "^10.4.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -31,6 +33,7 @@ "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", "eslint": "^10.6.0", + "pino-pretty": "^13.2.0", "typescript": "^6.0.3", "typescript-eslint": "^8.63.0" }, diff --git a/src/app.ts b/src/app.ts index da1b9f8e..75a48a4e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,7 +20,7 @@ export interface App { /** Builds the app, opening and migrating the database unless one is supplied. */ export function createApp(overrides: Partial = {}): App { const env = overrides.env ?? loadEnv(); - const logger = overrides.logger ?? createConsoleLogger(); + const logger = overrides.logger ?? createConsoleLogger({ level: env.logLevel }); const shutdown = overrides.shutdown ?? createShutdownRegistry(logger); let db = overrides.db; diff --git a/src/config/env.ts b/src/config/env.ts index 5351c78d..694c6a30 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,4 +1,5 @@ import { IANAZone } from 'luxon'; +import { DEFAULT_LOG_LEVEL, LOG_LEVELS, type LogLevel, parseLogLevel } from '../utilities/Logger.ts'; export interface Env { readonly token: string; @@ -8,6 +9,7 @@ export interface Env { readonly dbPath: string; readonly purgeMinFailures: number; readonly purgeGraceDays: number; + readonly logLevel: LogLevel; readonly devGuildId?: string; } @@ -78,6 +80,17 @@ export function loadEnv(source: Source = process.env): Readonly { } } + let logLevel = DEFAULT_LOG_LEVEL; + const rawLogLevel = read('LOG_LEVEL'); + if (rawLogLevel !== undefined) { + const parsed = parseLogLevel(rawLogLevel); + if (parsed === undefined) { + problems.push(`LOG_LEVEL must be one of ${LOG_LEVELS.join(', ')} (got "${rawLogLevel}")`); + } else { + logLevel = parsed; + } + } + const devGuildId = read('DEV_GUILD_ID'); if (problems.length > 0) { @@ -92,6 +105,7 @@ export function loadEnv(source: Source = process.env): Readonly { dbPath, purgeMinFailures, purgeGraceDays, + logLevel, devGuildId, }); } diff --git a/src/index.ts b/src/index.ts index a6f29860..1bd71910 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,10 @@ import { type App, createApp } from './app.ts'; import { DingirClient } from './client/DingirClient.ts'; import { type Env, loadEnv } from './config/env.ts'; import { installProcessHandlers } from './framework/process.ts'; -import { createConsoleLogger } from './utilities/Logger.ts'; +import { createConsoleLogger, parseLogLevel } from './utilities/Logger.ts'; -const logger = createConsoleLogger(); +// Read leniently: loadEnv() reports an invalid LOG_LEVEL once the logger exists. +const logger = createConsoleLogger({ level: parseLogLevel(process.env.LOG_LEVEL?.trim()) }); let app: App | undefined; installProcessHandlers({ diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts index 7196d844..5a738231 100644 --- a/src/scripts/deploy-commands.ts +++ b/src/scripts/deploy-commands.ts @@ -2,9 +2,9 @@ import { commands } from '../commands/index.ts'; import { loadEnv } from '../config/env.ts'; import { commandScope, putCommands } from '../framework/registrar.ts'; import { validateRegistry } from '../framework/registry.ts'; -import { createConsoleLogger } from '../utilities/Logger.ts'; +import { createConsoleLogger, parseLogLevel } from '../utilities/Logger.ts'; -const logger = createConsoleLogger(); +const logger = createConsoleLogger({ level: parseLogLevel(process.env.LOG_LEVEL?.trim()) }); try { const env = loadEnv(); diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index 8316402e..9d4a0e14 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -1,34 +1,14 @@ -type Context = Record; -type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; +import { pino, type DestinationStream } from 'pino'; -const timestamp = (): string => new Date().toISOString(); +type Context = Record; -const formatError = (error: unknown): string => - error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error); +export const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const; +export type LogLevel = (typeof LOG_LEVELS)[number]; -const formatValue = (value: unknown): string => { - if (value instanceof Error) { - return value.message; - } - if (typeof value === 'string') { - return /[\s="]/.test(value) || value === '' ? JSON.stringify(value) : value; - } - if (typeof value === 'object' && value !== null) { - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - return String(value); -}; +export const DEFAULT_LOG_LEVEL: LogLevel = 'info'; -export const formatContext = (context?: Context): string => - context - ? Object.entries(context) - .map(([key, value]) => `${key}=${formatValue(value)}`) - .join(' ') - : ''; +export const parseLogLevel = (value: string | undefined): LogLevel | undefined => + LOG_LEVELS.find((level) => level === value); export interface Logger { debug(message: string, context?: Context, error?: unknown): void; @@ -38,20 +18,36 @@ export interface Logger { fatal(message: string, context?: Context, error?: unknown): void; } -const emit = (level: Level, message: string, context?: Context, error?: unknown): void => { - const ctx = formatContext(context); - const line = `[${timestamp()}] ${level}: ${message}${ctx ? ` ${ctx}` : ''}`; - const write = level === 'ERROR' || level === 'FATAL' ? console.error : level === 'WARN' ? console.warn : console.log; - write(line); - if (error !== undefined && error !== null) { - write(`[${timestamp()}] ${level}: ${formatError(error)}`); - } -}; +export interface LoggerOptions { + level?: LogLevel; + /** Defaults to stdout. */ + destination?: DestinationStream; +} -export const createConsoleLogger = (): Logger => ({ - debug: (message, context, error) => emit('DEBUG', message, context, error), - info: (message, context, error) => emit('INFO', message, context, error), - warn: (message, context, error) => emit('WARN', message, context, error), - error: (message, context, error) => emit('ERROR', message, context, error), - fatal: (message, context, error) => emit('FATAL', message, context, error), -}); +/** Structured JSON logger (pino) writing one line per entry; pipe through pino-pretty for humans. */ +export const createConsoleLogger = ({ + level = DEFAULT_LOG_LEVEL, + destination, +}: LoggerOptions = {}): Logger => { + const base = pino( + { level, timestamp: pino.stdTimeFunctions.isoTime, base: undefined }, + destination ?? pino.destination(1) + ); + const at = + (method: keyof Logger): Logger[keyof Logger] => + (message, context, error) => { + const fields = error === undefined || error === null ? context : { ...context, err: error }; + if (fields) { + base[method](fields, message); + } else { + base[method](message); + } + }; + return { + debug: at('debug'), + info: at('info'), + warn: at('warn'), + error: at('error'), + fatal: at('fatal'), + }; +}; diff --git a/tests/env.test.ts b/tests/env.test.ts index 9dc42139..6809c1a2 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -13,6 +13,7 @@ test('applies defaults when only required variables are set', () => { assert.equal(env.dbPath, 'data/dingir.sqlite'); assert.equal(env.purgeMinFailures, 3); assert.equal(env.purgeGraceDays, 7); + assert.equal(env.logLevel, 'info'); assert.equal(env.devGuildId, undefined); }); @@ -79,3 +80,8 @@ test('rejects non-integer purge settings', () => { assert.throws(() => loadEnv({ ...valid, PURGE_MIN_FAILURES: '2.5' }), /PURGE_MIN_FAILURES/); assert.throws(() => loadEnv({ ...valid, PURGE_GRACE_DAYS: 'abc' }), /PURGE_GRACE_DAYS/); }); + +test('parses and validates LOG_LEVEL', () => { + assert.equal(loadEnv({ ...valid, LOG_LEVEL: 'debug' }).logLevel, 'debug'); + assert.throws(() => loadEnv({ ...valid, LOG_LEVEL: 'bogus' }), /LOG_LEVEL must be one of/); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts index 51ced3a1..ba50a29a 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -1,43 +1,69 @@ import assert from 'node:assert/strict'; -import { afterEach, beforeEach, test } from 'node:test'; -import { createConsoleLogger, formatContext } from '../src/utilities/Logger.ts'; - -type Method = 'log' | 'warn' | 'error'; -const original = { log: console.log, warn: console.warn, error: console.error }; -let lines: Record; - -beforeEach(() => { - lines = { log: [], warn: [], error: [] }; - for (const method of Object.keys(lines) as Method[]) { - console[method] = (msg: unknown) => { - lines[method].push(String(msg)); - }; - } +import { test } from 'node:test'; +import { createConsoleLogger, parseLogLevel, type LogLevel } from '../src/utilities/Logger.ts'; + +const capture = (level?: LogLevel) => { + const lines: string[] = []; + const logger = createConsoleLogger({ + level, + destination: { + write: (line: string) => { + lines.push(line); + }, + }, + }); + return { logger, entries: () => lines.map((line) => JSON.parse(line) as Record) }; +}; + +test('writes JSON lines with level, message and merged context', () => { + const { logger, entries } = capture(); + logger.info('hello', { guild: '1' }); + logger.warn('careful'); + logger.fatal('dead'); + + const [info, warn, fatal] = entries(); + assert.equal(info?.msg, 'hello'); + assert.equal(info?.guild, '1'); + assert.equal(info?.level, 30); + assert.equal(typeof info?.time, 'string'); + assert.equal(warn?.msg, 'careful'); + assert.equal(warn?.level, 40); + assert.equal(fatal?.level, 60); }); -afterEach(() => { - Object.assign(console, original); +test('serialises errors under err with a stack', () => { + const { logger, entries } = capture(); + logger.error('bad', { event: 'x' }, new Error('nope')); + logger.error('plain failure', undefined, 'just a string'); + + const [first, second] = entries(); + const err = first?.err as { message: string; stack: string }; + assert.equal(first?.event, 'x'); + assert.equal(err.message, 'nope'); + assert.match(err.stack, /Error: nope/); + assert.equal(second?.err, 'just a string'); }); -test('formatContext renders key=value pairs', () => { - assert.equal( - formatContext({ guild: '123', name: 'a b', count: 3, ok: true, none: null }), - 'guild=123 name="a b" count=3 ok=true none=null' +test('respects the configured level', () => { + const { logger, entries } = capture('warn'); + logger.debug('hidden'); + logger.info('hidden'); + logger.warn('shown'); + assert.deepEqual( + entries().map((e) => e.msg), + ['shown'] ); - assert.equal(formatContext(), ''); }); -const Logger = createConsoleLogger(); - -test('level helpers include level, message, context and error', () => { - Logger.info('hello', { guild: '1' }); - Logger.warn('careful'); - Logger.error('bad', { event: 'x' }, new Error('nope')); - Logger.debug('detail'); +test('defaults to info', () => { + const { logger, entries } = capture(); + logger.debug('hidden'); + logger.info('shown'); + assert.equal(entries().length, 1); +}); - assert.match(lines.log[0], /INFO: hello guild=1$/); - assert.match(lines.warn[0], /WARN: careful$/); - assert.match(lines.error[0], /ERROR: bad event=x$/); - assert.match(lines.error[1], /ERROR: Error: nope/); - assert.match(lines.log[1], /DEBUG: detail$/); +test('parseLogLevel accepts only known levels', () => { + assert.equal(parseLogLevel('trace'), 'trace'); + assert.equal(parseLogLevel('bogus'), undefined); + assert.equal(parseLogLevel(undefined), undefined); }); From dbd18ae0f624bc15c73bef8b31524b43db67df4a Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:24:22 +0100 Subject: [PATCH 29/77] Enable strict TypeScript options and disallow explicit any Co-Authored-By: Claude Sonnet 5.5 --- eslint.config.mjs | 1 - src/db/migrations/002-userprofile-unique.ts | 5 ++- src/resources/EmbedColours.ts | 10 ++---- src/services/AuditEmbed.ts | 6 ++-- tests/birthdays/BirthdayService.test.ts | 26 ++++++++------- tests/birthdays/commands.test.ts | 19 +++++------ tests/command.test.ts | 13 ++++---- tests/database/migrations.test.ts | 3 +- tests/database/services.test.ts | 3 +- tests/dispatcher.test.ts | 23 +++++++------ tests/fakes/discord.ts | 22 +++++++++++-- tests/fakes/guild.ts | 10 +++--- tests/fakes/interaction.ts | 12 +++---- tests/fakes/messages.ts | 29 ++++++++++++++--- tests/fakes/onboarding.ts | 6 ++-- tests/helpers/assertions.ts | 36 +++++++++++++++++++++ tests/moderation/events.test.ts | 36 ++++++++++----------- tests/moderation/memberListing.test.ts | 15 +++++---- tests/moderation/post.test.ts | 23 +++++++------ tests/onboarding/OnboardingService.test.ts | 21 ++++++------ tests/onboarding/commands.test.ts | 18 +++++------ tests/onboarding/events.test.ts | 14 +++++--- tests/services/AuditEmbed.test.ts | 14 ++++---- tests/settings.test.ts | 21 ++++++------ tsconfig.json | 6 ++-- 25 files changed, 238 insertions(+), 154 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index db421a2c..924bb05f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,7 +24,6 @@ export default defineConfig([ 'semi': ['error', 'always'], 'brace-style': 'error', 'curly': ['error', 'all'], - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', }, diff --git a/src/db/migrations/002-userprofile-unique.ts b/src/db/migrations/002-userprofile-unique.ts index fa2c09f9..b46b01fb 100644 --- a/src/db/migrations/002-userprofile-unique.ts +++ b/src/db/migrations/002-userprofile-unique.ts @@ -55,7 +55,10 @@ export function up(db: Database, logger: Logger): void { } const withBirthday = group.filter(hasBirthday).sort(newestFirst); - const keep = withBirthday.length > 0 ? withBirthday[0] : [...group].sort(newestFirst)[0]; + const keep = withBirthday[0] ?? [...group].sort(newestFirst)[0]; + if (!keep) { + continue; + } if (new Set(withBirthday.map(birthdayKey)).size > 1) { logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { diff --git a/src/resources/EmbedColours.ts b/src/resources/EmbedColours.ts index eda6504a..d428015c 100644 --- a/src/resources/EmbedColours.ts +++ b/src/resources/EmbedColours.ts @@ -1,12 +1,6 @@ -import type { ColorResolvable } from 'discord.js'; - -interface EmbedColors { - [key: string]: ColorResolvable; -} - -export const EmbedColours: EmbedColors = { +export const EmbedColours = { positive: '#30d70c', negative: '#de2525', neutral: '#cc37f1', info: '#30bcf6', -}; +} as const; diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts index 79eab28b..5f62b0a7 100644 --- a/src/services/AuditEmbed.ts +++ b/src/services/AuditEmbed.ts @@ -20,11 +20,11 @@ export class AuditEmbed extends EmbedBuilder { private cut = false; private storedFields = 0; - public setTitle(title: string | null): this { + public override setTitle(title: string | null): this { return super.setTitle(title === null ? null : this.limit(title, LIMITS.title)); } - public setDescription(description: string | null): this { + public override setDescription(description: string | null): this { return super.setDescription( description === null ? null : this.limit(description, LIMITS.description) ); @@ -42,7 +42,7 @@ export class AuditEmbed extends EmbedBuilder { return this; } - public toJSON(): ReturnType { + public override toJSON(): ReturnType { const data = super.toJSON(); const fields = [...(data.fields ?? [])]; const textLength = (): number => diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index ff28d79f..bc6080b6 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -12,6 +12,7 @@ import { } from '../fakes/guild.ts'; import { createTestApp, type TestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; +import { contentOf, nth } from '../helpers/assertions.ts'; const zone = 'Europe/London'; const at = (iso: string): Date => DateTime.fromISO(iso, { zone }).toJSDate(); @@ -73,14 +74,15 @@ test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', asy const status = await refreshCalendar(appAt('2027-02-27T10:00'), client, config); assert.equal(status, 'updated'); - const [edit] = message.edits; + const edit = nth(message.edits); + const text = contentOf(edit); assert.deepEqual(edit.allowedMentions, { parse: [] }); - assert.match(edit.content, /Upcoming Birthdays/); - assert.match(edit.content, /Set yours with `\/mybirthday set`/); - assert.match(edit.content, /\*\*27 February 2027\*\*\n<@today>/); - assert.match(edit.content, /\*\*28 February 2027\*\*\n<@leap>/); - assert.doesNotMatch(edit.content, /nobday/); - assert.ok(edit.content.indexOf('<@today>') < edit.content.indexOf('<@leap>')); + assert.match(text, /Upcoming Birthdays/); + assert.match(text, /Set yours with `\/mybirthday set`/); + assert.match(text, /\*\*27 February 2027\*\*\n<@today>/); + assert.match(text, /\*\*28 February 2027\*\*\n<@leap>/); + assert.doesNotMatch(text, /nobday/); + assert.ok(text.indexOf('<@today>') < text.indexOf('<@leap>')); }); test('refreshCalendar: empty state', async () => { @@ -88,7 +90,7 @@ test('refreshCalendar: empty state', async () => { const message = fakeEditableMessage('m1'); const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); assert.equal(await refreshCalendar(appAt(), client, config), 'updated'); - assert.match(message.edits[0].content, /There are no birthdays in this server/); + assert.match(contentOf(nth(message.edits)), /There are no birthdays in this server/); }); test('notifyBirthdays: skips departed members and mentions only present ones', async () => { @@ -104,8 +106,8 @@ test('notifyBirthdays: skips departed members and mentions only present ones', a await notifyBirthdays(appAt('2027-06-01T09:00'), client); assert.equal(channel.sent.length, 1); - assert.equal(channel.sent[0].content, 'Happy Birthday to <@here>!'); - assert.deepEqual(channel.sent[0].allowedMentions, { users: ['here'] }); + assert.equal(nth(channel.sent).content, 'Happy Birthday to <@here>!'); + assert.deepEqual(nth(channel.sent).allowedMentions, { users: ['here'] }); }); test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async () => { @@ -120,8 +122,8 @@ test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async await notifyBirthdays(appAt('2027-02-28T09:00'), client); assert.equal(channel.sent.length, 1); - assert.match(channel.sent[0].content, /<@a>/); - assert.match(channel.sent[0].content, /<@b>/); + assert.match(contentOf(nth(channel.sent)), /<@a>/); + assert.match(contentOf(nth(channel.sent)), /<@b>/); channel.sent.length = 0; await notifyBirthdays(appAt('2027-03-01T09:00'), client); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 19df3d97..2ea3619a 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -3,12 +3,12 @@ import { after, beforeEach, test } from 'node:test'; import mybirthday from '../../src/commands/info/mybirthday.ts'; import profile from '../../src/commands/info/profile.ts'; import type { EmbedBuilder, Guild } from 'discord.js'; -import type { ReplyOptions } from '../../src/framework/command.ts'; +import type { Command, ReplyOptions } from '../../src/framework/command.ts'; import { fakeCommandContext } from '../fakes/command.ts'; import { stub } from '../fakes/discord.ts'; import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; import { createTestApp } from '../helpers/app.ts'; -import { rejectsUserError } from '../helpers/assertions.ts'; +import { last, nth, rejectsUserError } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); @@ -25,7 +25,7 @@ beforeEach(() => { }); const exec = async ( - command: typeof mybirthday, + command: Command, opts: { subcommand?: string; options?: Record; guild?: object } ) => { const config = createConfig({ serverId: 'guild-1' }); @@ -39,7 +39,7 @@ const exec = async ( return replies; }; -const lastContent = (replies: ReplyOptions[]): string => replies[replies.length - 1].content!; +const lastContent = (replies: ReplyOptions[]): string => last(replies).content!; test('mybirthday set stores Feb 29 as 29', async () => { const replies = await exec(mybirthday, { subcommand: 'set', options: { day: 29, month: 2 } }); @@ -71,7 +71,7 @@ test('mybirthday clear removes the birthday but keeps the profile', async () => test('profile replies for a non-member without creating a profile', async () => { const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: [] }); await rejectsUserError( - exec(profile as any, { options: { member: { id: 'ghost' } }, guild }), + exec(profile, { options: { member: { id: 'ghost' } }, guild }), "That user isn't a member of this server." ); assert.equal(await countProfiles(), 0); @@ -79,7 +79,8 @@ test('profile replies for a non-member without creating a profile', async () => test('profile for a member without a profile does not create one', async () => { const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: ['m1'] }); - const member = guild.members.cache.get('m1') as any; + const member = guild.members.cache.get('m1'); + assert.ok(member); Object.assign(member, { nickname: null, pending: true, @@ -87,9 +88,9 @@ test('profile for a member without a profile does not create one', async () => { user: { username: 'someone' }, displayAvatarURL: () => 'https://example.com/a.png', }); - const replies = await exec(profile as any, { options: { member: { id: 'm1' } }, guild }); - const embed = (replies[replies.length - 1].embeds![0] as EmbedBuilder).toJSON(); - const field = (name: string): string => embed.fields!.find((f: any) => f.name === name)!.value; + const replies = await exec(profile, { options: { member: { id: 'm1' } }, guild }); + const embed = (nth(last(replies).embeds as EmbedBuilder[] | undefined)).toJSON(); + const field = (name: string): string => embed.fields?.find((f) => f.name === name)?.value ?? ''; assert.equal(field('Onboarding'), 'Not completed'); assert.equal(field('Activity Score'), '0'); assert.equal(await countProfiles(), 0); diff --git a/tests/command.test.ts b/tests/command.test.ts index d74a11a0..98422d44 100644 --- a/tests/command.test.ts +++ b/tests/command.test.ts @@ -8,6 +8,7 @@ import { createReply, } from '../src/framework/command.ts'; import { fakeInteraction } from './fakes/interaction.ts'; +import { nth } from './helpers/assertions.ts'; const noop = async () => {}; @@ -28,7 +29,7 @@ test('defineCommand applies leaf options', () => { options: (b) => b.addStringOption((o) => o.setName('text').setDescription('Text').setRequired(true)), run: noop, }).toJSON(); - assert.equal(json.options?.[0].name, 'text'); + assert.equal(nth(json.options).name, 'text'); }); test('defineCommand rejects invalid shapes with descriptive errors', () => { @@ -163,7 +164,7 @@ test('reply uses reply() when nothing has been sent, ephemeral by default', asyn test('reply omits the ephemeral flag when ephemeral is false', async () => { const { interaction, calls } = fakeInteraction(); await createReply(interaction)({ content: 'public', ephemeral: false }); - assert.deepEqual(calls[0].payload, { content: 'public' }); + assert.deepEqual(nth(calls).payload, { content: 'public' }); }); test('reply uses editReply() after deferring, then followUp() afterwards', async () => { @@ -175,13 +176,13 @@ test('reply uses editReply() after deferring, then followUp() afterwards', async calls.map((c) => c.method), ['editReply', 'followUp'] ); - assert.equal(calls[0].payload.flags, undefined); - assert.equal(calls[1].payload.flags, MessageFlags.Ephemeral); + assert.equal(nth(calls).payload.flags, undefined); + assert.equal(nth(calls, 1).payload.flags, MessageFlags.Ephemeral); }); test('reply uses followUp() when already replied', async () => { const { interaction, calls } = fakeInteraction({ replied: true }); await createReply(interaction)({ content: 'more', allowedMentions: { parse: [] } }); - assert.equal(calls[0].method, 'followUp'); - assert.deepEqual(calls[0].payload.allowedMentions, { parse: [] }); + assert.equal(nth(calls).method, 'followUp'); + assert.deepEqual(nth(calls).payload.allowedMentions, { parse: [] }); }); diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index 6bb771d7..d785efad 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test'; import Database from 'better-sqlite3'; import { migrate, migrations, type Migration } from '../../src/db/migrator.ts'; import { fakeLogger } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; const { logger, logs } = fakeLogger(); @@ -205,7 +206,7 @@ test('002 dedupes UserProfiles and enforces uniqueness afterwards', () => { const warnings = logs.filter((entry) => entry.level === 'warn'); assert.equal(warnings.length, 1); - assert.match(warnings[0].message, /Conflicting birthdays/); + assert.match(nth(warnings).message, /Conflicting birthdays/); const rows = db .prepare( diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index c32feaa7..e141c16e 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -14,6 +14,7 @@ import { setBirthday, } from '../../src/services/UserProfileService.ts'; import { createTestApp } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); @@ -118,7 +119,7 @@ test('setBirthday upserts without losing the activity score; clearBirthday repor await incrementActivityScore(db, 's1', 'u1'); await setBirthday(db, 's1', 'u1', 3, 4); await setBirthday(db, 's1', 'u1', 5, 6); - const [profile] = allProfiles(); + const profile = nth(allProfiles()); assert.deepEqual([profile.birthdayMonth, profile.birthdayDay, profile.activityScore], [5, 6, 1]); assert.equal(allProfiles().length, 1); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index c5834f82..addc3da5 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -2,12 +2,13 @@ import assert from 'node:assert/strict'; import { after, test } from 'node:test'; import { Collection, MessageFlags } from 'discord.js'; import type { DingirClient } from '../src/client/DingirClient.ts'; -import { type Command, defineCommand } from '../src/framework/command.ts'; +import { type Command, type CommandContext, defineCommand } from '../src/framework/command.ts'; import { UserError } from '../src/framework/errors.ts'; import interactionCreate from '../src/events/interactionCreate.ts'; import { stub } from './fakes/discord.ts'; import { fakeInteraction } from './fakes/interaction.ts'; import { createTestApp } from './helpers/app.ts'; +import { nth } from './helpers/assertions.ts'; const app = createTestApp(); @@ -64,11 +65,11 @@ test('replies "Unknown command." for unregistered commands and unresolvable subc const badSub = fakeInteraction({ commandName: 'tree', subcommand: 'zzz' }); await dispatch(clientWith(cmd), badSub.interaction); assert.equal(badSub.calls.length, 1); - assert.equal(badSub.calls[0].payload.content, 'Unknown command.'); + assert.equal(nth(badSub.calls).payload.content, 'Unknown command.'); }); test('runs the handler with context and applies the defer mode', async () => { - let seen: any; + let seen: CommandContext | undefined; const cmd = defineCommand({ name: 'test', description: 'd', @@ -85,9 +86,9 @@ test('runs the handler with context and applies the defer mode', async () => { calls.map((c) => c.method), ['deferReply', 'editReply'] ); - assert.deepEqual(calls[0].payload, { flags: MessageFlags.Ephemeral }); - assert.equal(seen.config.serverId, 'guild-9'); - assert.equal(seen.interaction, interaction); + assert.deepEqual(nth(calls).payload, { flags: MessageFlags.Ephemeral }); + assert.equal(seen?.config.serverId, 'guild-9'); + assert.equal(seen?.interaction, interaction); }); test('public defer does not set the ephemeral flag', async () => { @@ -146,9 +147,11 @@ test('a failing error reply is logged and does not throw', async () => { }, }); const { interaction } = fakeInteraction(); - (interaction as any).reply = async () => { - throw new Error('reply failed'); - }; + Object.assign(interaction, { + reply: async () => { + throw new Error('reply failed'); + }, + }); await dispatch(clientWith(cmd), interaction); assert.ok(app.logsAt('error').some((entry) => entry.message === 'Could not send command error reply')); }); @@ -191,7 +194,7 @@ test('a UserError thrown after deferring edits the deferred reply', async () => calls.map((c) => c.method), ['deferReply', 'editReply'] ); - assert.equal(calls[1].payload.content, 'Nope.'); + assert.equal(nth(calls, 1).payload.content, 'Nope.'); }); test('defers the reply before looking up the server config', async () => { diff --git a/tests/fakes/discord.ts b/tests/fakes/discord.ts index ff09f762..5e33662c 100644 --- a/tests/fakes/discord.ts +++ b/tests/fakes/discord.ts @@ -77,14 +77,30 @@ export function fakeConfig(overrides: Partial }; - sent: any[]; + sent: SentPayload[]; } /** A sendable channel that records payloads, or rejects every send when `failWith` is given. */ export function fakeAuditChannel(failWith?: string): AuditChannel { - const sent: any[] = []; + const sent: SentPayload[] = []; return { sent, channel: { @@ -93,7 +109,7 @@ export function fakeAuditChannel(failWith?: string): AuditChannel { if (failWith) { throw new Error(failWith); } - sent.push(payload); + sent.push(payload as SentPayload); }, }, }; diff --git a/tests/fakes/guild.ts b/tests/fakes/guild.ts index 8a1c4930..bac8a3e9 100644 --- a/tests/fakes/guild.ts +++ b/tests/fakes/guild.ts @@ -1,12 +1,12 @@ import { Collection } from 'discord.js'; import type { Client } from 'discord.js'; -import { apiError, fakeMember, stub } from './discord.ts'; +import { apiError, fakeMember, stub, type SentPayload } from './discord.ts'; export { apiError }; export interface FakeEditableMessage { id: string; - edits: any[]; + edits: SentPayload[]; deleted: boolean; edit(payload: unknown): Promise; delete(): Promise; @@ -18,7 +18,7 @@ export const fakeEditableMessage = (id: string): FakeEditableMessage => { edits: [], deleted: false, edit: async (payload) => { - message.edits.push(payload); + message.edits.push(payload as SentPayload); return message; }, delete: async () => { @@ -30,7 +30,7 @@ export const fakeEditableMessage = (id: string): FakeEditableMessage => { export interface FakeChannel { id: string; - sent: any[]; + sent: SentPayload[]; messages: { fetch(id: string): Promise }; isTextBased(): boolean; isDMBased(): boolean; @@ -55,7 +55,7 @@ export const fakeTextChannel = (id: string, messages: FakeEditableMessage[] = [] isDMBased: () => false, isSendable: () => true, send: async (payload) => { - channel.sent.push(payload); + channel.sent.push(payload as SentPayload); return { id: `sent-${channel.sent.length}`, channelId: id }; }, }; diff --git a/tests/fakes/interaction.ts b/tests/fakes/interaction.ts index 454e3436..db7c05b0 100644 --- a/tests/fakes/interaction.ts +++ b/tests/fakes/interaction.ts @@ -1,9 +1,9 @@ import type { ChatInputCommandInteraction } from 'discord.js'; -import { fakeGuild, stub } from './discord.ts'; +import { fakeGuild, type SentPayload, stub } from './discord.ts'; export interface Call { method: 'reply' | 'deferReply' | 'editReply' | 'followUp'; - payload: any; + payload: SentPayload; } export interface FakeInteractionOptions { @@ -71,19 +71,19 @@ export function fakeInteraction(opts: FakeInteractionOptions = {}): FakeInteract getInteger: required, getAttachment: required, }, - reply: async (payload: unknown) => { + reply: async (payload: SentPayload) => { calls.push({ method: 'reply', payload }); state.replied = true; }, - deferReply: async (payload: unknown) => { + deferReply: async (payload: SentPayload) => { calls.push({ method: 'deferReply', payload }); state.deferred = true; }, - editReply: async (payload: unknown) => { + editReply: async (payload: SentPayload) => { calls.push({ method: 'editReply', payload }); state.replied = true; }, - followUp: async (payload: unknown) => { + followUp: async (payload: SentPayload) => { calls.push({ method: 'followUp', payload }); }, }; diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts index d342ebed..b5ae7cec 100644 --- a/tests/fakes/messages.ts +++ b/tests/fakes/messages.ts @@ -1,16 +1,25 @@ -import { Collection } from 'discord.js'; +import assert from 'node:assert/strict'; +import { Collection, type Message, type OmitPartialGroupDMChannel } from 'discord.js'; import type { App } from '../../src/app.ts'; import type { DingirClient } from '../../src/client/DingirClient.ts'; import type { Command } from '../../src/framework/command.ts'; import interactionCreate from '../../src/events/interactionCreate.ts'; -import { fakeAuditChannel, fakeChannelClient, fakeGuild, fakeMember, fakeUser } from './discord.ts'; +import { + type EmbedJson, + fakeAuditChannel, + fakeChannelClient, + fakeGuild, + fakeMember, + fakeUser, + type SentPayload, +} from './discord.ts'; import type { FakeInteraction } from './interaction.ts'; export { fakeMember, fakeUser }; export interface AuditSink { client: DingirClient; - sent: any[]; + sent: SentPayload[]; } export function fakeAuditClient(channelId = 'audit-1', commands: Command[] = []): AuditSink { @@ -19,7 +28,17 @@ export function fakeAuditClient(channelId = 'audit-1', commands: Command[] = []) return { client: fakeChannelClient(channelId, channel, { slashCommands }), sent }; } -export const auditJson = (sent: any[], index = 0): any => sent[index].embeds[0].toJSON(); +export const auditJson = (sent: SentPayload[], index = 0): EmbedJson => { + const embed = sent[index]?.embeds?.[0]; + assert.ok(embed, `no embed in sent payload ${index}`); + return embed.toJSON(); +}; + +export const embedField = (embed: EmbedJson, name: string): { name: string; value: string } => { + const field = embed.fields.find((f) => f.name === name); + assert.ok(field, `embed has no "${name}" field`); + return field; +}; export function fakeMessage(overrides: Record = {}) { const author = (overrides.author as ReturnType | null | undefined) ?? fakeUser('u1'); @@ -41,7 +60,7 @@ export function fakeMessage(overrides: Record = {}) { return Boolean(this.guildId); }, }; - return { ...base, ...overrides } as any; + return { ...base, ...overrides } as unknown as OmitPartialGroupDMChannel; } export async function runSlash( diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index ddfececa..5fab2bac 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,6 +1,6 @@ import type { Client, Guild, GuildMember } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; -import { fakeAuditChannel, fakeConfig, fakeGuild, fakeMember, stub } from './discord.ts'; +import { fakeAuditChannel, fakeConfig, fakeGuild, fakeMember, type SentPayload, stub } from './discord.ts'; export { fakeCommandContext } from './command.ts'; @@ -31,7 +31,7 @@ export const role = (id: string, position = 1, managed = false): FakeRole => ({ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { const roleAdds: string[][] = []; - const systemSends: any[] = []; + const systemSends: SentPayload[] = []; const roles = new Map((opts.roles ?? []).map((r) => [r.id, r])); const guild: Guild = fakeGuild({ @@ -45,7 +45,7 @@ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { if (opts.failSystemSend) { throw new Error('cannot send'); } - systemSends.push(payload); + systemSends.push(payload as SentPayload); }, }, }); diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts index ed38cbf8..25101cfb 100644 --- a/tests/helpers/assertions.ts +++ b/tests/helpers/assertions.ts @@ -12,3 +12,39 @@ export const rejectsUserError = (promise: Promise, message: string | Re } return true; }); + +/** Returns the item at `index`, failing the test if the list is shorter. */ +export function nth(items: readonly T[] | undefined, index = 0): T { + const item = items?.[index]; + assert.ok(item !== undefined, `expected an item at index ${index}`); + return item; +} + +/** Returns the last item, failing the test if the list is empty. */ +export const last = (items: readonly T[]): T => nth(items, items.length - 1); + +/** Returns a payload's text content, failing the test if it has none. */ +export function contentOf(payload: { content?: string | null }): string { + assert.equal(typeof payload.content, 'string', 'expected payload content'); + return payload.content as string; +} + +/** The subset of an application command option's JSON that tests inspect. */ +export interface OptionJson { + name: string; + required?: boolean; + max_length?: number; + channel_types?: number[]; + options?: OptionJson[]; +} + +/** Finds a named option in a command's (or option's) JSON, failing the test if absent. */ +export function optionNamed(parent: { options?: unknown }, name: string): OptionJson { + const found = ((parent.options ?? []) as OptionJson[]).find((o) => o.name === name); + assert.ok(found, `no option named "${name}"`); + return found; +} + +/** The first top-level option of a command's JSON. */ +export const firstOption = (json: { options?: unknown }): OptionJson => + nth(json.options as OptionJson[] | undefined); diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index 688b937e..55a6057d 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, beforeEach, afterEach, test } from 'node:test'; -import { Collection } from 'discord.js'; +import { Collection, type PartialGuildMember } from 'discord.js'; import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; import messageCreate from '../../src/events/messageCreate.ts'; import messageDelete from '../../src/events/messageDelete.ts'; @@ -9,7 +9,8 @@ import { updateConfig } from '../../src/services/ConfigService.ts'; import { incrementActivityScore } from '../../src/services/UserProfileService.ts'; import { createTestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; -import { auditJson, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages.ts'; +import { auditJson, embedField, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages.ts'; +import { nth } from '../helpers/assertions.ts'; const app = createTestApp(); const { clearProfiles, findProfile } = dbFixtures(app); @@ -32,7 +33,7 @@ const score = async (): Promise => test('honeypot message bans the member and does not count activity', async () => { const sink = fakeAuditClient(); - let banned: any = null; + let banned: unknown = null; const message = fakeMessage({ channelId: 'honey-1', member: fakeMember('u1', { @@ -76,12 +77,11 @@ test('messageDelete never changes the activity score', async () => { test('messageDelete audits uncached messages without content', async () => { const sink = fakeAuditClient(); const message = fakeMessage({ partial: true, author: null, id: 'm42', channelId: 'chan-7' }); - message.author = null; await messageDelete.run(app, sink.client, message); const embed = auditJson(sink.sent); - assert.match(embed.description, /not cached/); + assert.match(embed.description ?? '', /not cached/); assert.deepEqual( - embed.fields.map((f: any) => f.value), + embed.fields.map((f) => f.value), ['<#chan-7>', 'm42'] ); }); @@ -95,10 +95,9 @@ test('messageDelete handles very long content and attachments', async () => { }); await messageDelete.run(app, sink.client, message); const embed = auditJson(sink.sent); - const text = embed.fields.find((f: any) => f.name === 'Message'); - assert.ok(text.value.length <= 1024); - assert.equal(embed.fields.find((f: any) => f.name === 'Attachments').value, '2: a.png, b.png'); - assert.equal(embed.fields.find((f: any) => f.name === 'Embeds').value, '1'); + assert.ok(embedField(embed, 'Message').value.length <= 1024); + assert.equal(embedField(embed, 'Attachments').value, '2: a.png, b.png'); + assert.equal(embedField(embed, 'Embeds').value, '1'); }); test('messageDelete skips bots and active honeypot enforcement', async () => { @@ -118,10 +117,9 @@ test('messageUpdate notes an uncached previous message', async () => { fakeMessage({ content: 'edited' }) ); const embed = auditJson(sink.sent); - const previous = embed.fields.find((f: any) => f.name === 'Previous'); - assert.equal(previous.value, '*(not cached)*'); - assert.ok(embed.fields.some((f: any) => f.name === 'Jump to message')); - assert.ok(embed.fields.some((f: any) => f.name === 'Channel')); + assert.equal(embedField(embed, 'Previous').value, '*(not cached)*'); + embedField(embed, 'Jump to message'); + embedField(embed, 'Channel'); }); test('messageUpdate fetches partial new messages and returns when fetching fails', async () => { @@ -162,26 +160,26 @@ test('messageUpdate truncates long content', async () => { fakeMessage({ content: 'b'.repeat(4000) }) ); assert.equal(sink.sent.length, 1); - assert.ok(auditJson(sink.sent).fields.every((f: any) => f.value.length <= 1024)); + assert.ok(auditJson(sink.sent).fields.every((f) => f.value.length <= 1024)); }); test('guildMemberRemove handles partial members and deletes profile data', async () => { const sink = fakeAuditClient(); await incrementActivityScore(app.db, 'guild-1', 'u1'); - const partial = { partial: true, guild: { id: 'guild-1' }, user: fakeUser('u1') } as any; + const partial = { partial: true, guild: { id: 'guild-1' }, user: fakeUser('u1') } as unknown as PartialGuildMember; await guildMemberRemove.run(app, sink.client, partial); assert.equal(await score(), null); const embed = auditJson(sink.sent); assert.equal(embed.description, 'Member left'); - assert.equal(embed.author.name, 'useru1'); - assert.equal(embed.fields[1].value, 'Deleted'); + assert.equal(embed.author?.name, 'useru1'); + assert.equal(nth(embed.fields, 1).value, 'Deleted'); }); test('guildMemberRemove during honeypot enforcement only deletes data', async () => { const sink = fakeAuditClient(); await incrementActivityScore(app.db, 'guild-1', 'u1'); app.honeypot.begin('guild-1', 'u1'); - const member = fakeMember('u1', { guild: { id: 'guild-1' } }) as any; + const member = fakeMember('u1', { guild: { id: 'guild-1' } }); await guildMemberRemove.run(app, sink.client, member); assert.equal(await score(), null); assert.equal(sink.sent.length, 0); diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 1d2ef7c7..41025492 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -7,6 +7,7 @@ import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListi import { createTestApp, FIXED_NOW } from '../helpers/app.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages.ts'; +import { last, nth } from '../helpers/assertions.ts'; const app = createTestApp(); after(() => { @@ -15,7 +16,7 @@ after(() => { const DAY = 24 * 60 * 60 * 1000; const members = (count: number, joined: number | null = Date.now()) => - Array.from({ length: count }, (_, i) => fakeMember(String(100000000000000000 + i), { joinedTimestamp: joined })) as any[]; + Array.from({ length: count }, (_, i) => fakeMember(String(100000000000000000 + i), { joinedTimestamp: joined })); test('small listings reply inline without mentions', () => { const result = buildMemberListing('**Header**', 'none', members(3)); @@ -30,7 +31,7 @@ test('large listings attach a text file with a summary line', () => { assert.ok(result.content!.length < INLINE_LIMIT); assert.match(result.content!, /200 members/); assert.equal(result.files!.length, 1); - const file = result.files![0] as any; + const file = nth(result.files as unknown[] | undefined) as { name: string; attachment: Buffer }; assert.equal(file.name, 'members.txt'); const text = file.attachment.toString('utf8'); assert.equal(text.split('\n').length, 200); @@ -41,7 +42,7 @@ test('null join dates are described as unknown', () => { const result = buildMemberListing('**Header**', 'none', members(1, null)); assert.match(result.content!, /join date unknown/); const big = buildMemberListing('**Header**', 'none', members(200, null)); - assert.match((big.files![0] as any).attachment.toString('utf8'), /join date unknown/); + assert.match((nth(big.files as unknown[] | undefined) as { attachment: Buffer }).attachment.toString('utf8'), /join date unknown/); }); test('empty listing returns the empty message', () => { @@ -65,8 +66,8 @@ test('rolesince filters by days in server and excludes bots', async () => { Object.assign(fake.interaction.options, { getInteger: () => 30 }); await runSlash(app, sink.client, fake); assert.equal(fetched, true); - assert.equal(fake.calls[0].method, 'deferReply'); - const content = fake.calls.at(-1)!.payload.content as string; + assert.equal(nth(fake.calls).method, 'deferReply'); + const content = last(fake.calls).payload.content as string; assert.match(content, /<@1>/); assert.doesNotMatch(content, /<@2>/); assert.doesNotMatch(content, /<@3>/); @@ -93,8 +94,8 @@ test('noroles lists only non-bot members with just the everyone role', async () const sink = fakeAuditClient('a', [noroles]); const fake = fakeInteraction({ commandName: 'noroles', guild }); await runSlash(app, sink.client, fake); - assert.equal(fake.calls[0].method, 'deferReply'); - const content = fake.calls.at(-1)!.payload.content as string; + assert.equal(nth(fake.calls).method, 'deferReply'); + const content = last(fake.calls).payload.content as string; assert.match(content, /<@1>/); assert.doesNotMatch(content, /<@2>/); assert.doesNotMatch(content, /<@3>/); diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index a27b39c8..386b734e 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -6,6 +6,7 @@ import { updateConfig } from '../../src/services/ConfigService.ts'; import { createTestApp } from '../helpers/app.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages.ts'; +import { contentOf, last, nth, optionNamed } from '../helpers/assertions.ts'; const app = createTestApp(); @@ -60,15 +61,15 @@ test('send failure replies with an error and neither succeeds nor audits', async { content: 'hi' } ); await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); - const reply = fake.calls.at(-1)!; + const reply = last(fake.calls); assert.equal(reply.method, 'editReply'); - assert.match(reply.payload.content, /error was encountered/); + assert.match(contentOf(reply.payload), /error was encountered/); assert.equal(sink.sent.length, 0); }); test('success audits then replies with the jump link', async () => { const sink = fakeAuditClient('audit-1', [post]); - const sentPayloads: any[] = []; + const sentPayloads: number[] = []; const fake = setup( target(async () => { sentPayloads.push(1); @@ -80,11 +81,11 @@ test('success audits then replies with the jump link', async () => { assert.equal(sentPayloads.length, 1); assert.equal(sink.sent.length, 1); const embed = auditJson(sink.sent); - const names = embed.fields.map((f: any) => f.name); + const names = embed.fields.map((f) => f.name); assert.deepEqual(names, ['Channel', 'Content', 'Attachment', 'Message']); - assert.equal(embed.fields[2].value, 'pic.png'); + assert.equal(nth(embed.fields, 2).value, 'pic.png'); assert.equal( - fake.calls.at(-1)!.payload.content, + contentOf(last(fake.calls).payload), 'Posted in <#chan-9>: https://discord.com/channels/g/c/m' ); }); @@ -102,17 +103,15 @@ test('missing permission replies clearly and does not send', async () => { await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.equal(sends, 0); assert.equal(sink.sent.length, 0); - assert.match(fake.calls.at(-1)!.payload.content, /AttachFiles/); + assert.match(contentOf(last(fake.calls).payload), /AttachFiles/); }); test('requires content or attachment and limits content length', async () => { const sink = fakeAuditClient('audit-1', [post]); const fake = setup(target(async () => ({ url: 'x' }), allPerms), {}); await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); - assert.match(fake.calls.at(-1)!.payload.content, /at least text or an attachment/); + assert.match(contentOf(last(fake.calls).payload), /at least text or an attachment/); - const content = post.toJSON().options!.find((o: any) => o.name === 'content') as any; - assert.equal(content.max_length, 2000); - const channel = post.toJSON().options!.find((o: any) => o.name === 'channel') as any; - assert.deepEqual(channel.channel_types, [0, 5]); + assert.equal(optionNamed(post.toJSON(), 'content').max_length, 2000); + assert.deepEqual(optionNamed(post.toJSON(), 'channel').channel_types, [0, 5]); }); diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts index 1e824898..0828e531 100644 --- a/tests/onboarding/OnboardingService.test.ts +++ b/tests/onboarding/OnboardingService.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { AttachmentBuilder } from 'discord.js'; +import { AttachmentBuilder, type GuildMember } from 'discord.js'; import { complete, completedScreening, @@ -9,6 +9,7 @@ import { } from '../../src/services/OnboardingService.ts'; import { fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; const app = createTestApp(); @@ -23,7 +24,7 @@ test('parseRoleIds trims and drops empties', () => { }); test('completedScreening only for pending true to false', () => { - const m = (pending: boolean | null | undefined) => ({ pending }) as any; + const m = (pending: boolean | null | undefined) => ({ pending }) as unknown as GuildMember; assert.equal(completedScreening(m(true), m(false)), true); assert.equal(completedScreening(m(false), m(false)), false); assert.equal(completedScreening(m(true), m(true)), false); @@ -38,7 +39,7 @@ test('unconfigured server: audit done, roles and welcome skipped, no debug audit assert.match(result.roles, /^skipped:/); assert.match(result.welcome, /^skipped:/); assert.equal(env.auditSends.length, 1); - assert.match(JSON.stringify(env.auditSends[0].embeds[0]), /completed onboarding/); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /completed onboarding/); }); test('guest roles are added in one call and unassignable ones filtered', async () => { @@ -76,7 +77,7 @@ test('a failing roles step is audited and does not stop the welcome step', async assert.match(result.roles, /^failed:missing permissions/); assert.equal(result.welcome, 'done'); assert.equal(env.systemSends.length, 1); - assert.ok(env.auditSends.some((a) => /guest role/.test(JSON.stringify(a.embeds[0])))); + assert.ok(env.auditSends.some((a) => /guest role/.test(JSON.stringify(a.embeds?.[0])))); }); test('audit failure does not stop later steps', async () => { @@ -96,8 +97,8 @@ test('welcome replaces every {member} and does not ping others', async () => { }); const result = await complete(app, env.client, env.member, env.config); assert.equal(result.welcome, 'done'); - assert.equal(env.systemSends[0].content, '<@member-1> hello <@member-1>!'); - assert.deepEqual(env.systemSends[0].allowedMentions, { users: ['member-1'] }); + assert.equal(nth(env.systemSends).content, '<@member-1> hello <@member-1>!'); + assert.deepEqual(nth(env.systemSends).allowedMentions, { users: ['member-1'] }); }); test('welcome skip reasons', async () => { @@ -125,8 +126,8 @@ test('image failure still sends text and audits the image failure', async () => const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: failImage }); assert.equal(result.welcome, 'done'); assert.equal(env.systemSends.length, 1); - assert.equal(env.systemSends[0].files, undefined); - assert.ok(env.auditSends.some((a) => /welcome image/.test(JSON.stringify(a.embeds[0])))); + assert.equal(nth(env.systemSends).files, undefined); + assert.ok(env.auditSends.some((a) => /welcome image/.test(JSON.stringify(a.embeds?.[0])))); }); test('image failure with no text fails the welcome step', async () => { @@ -145,7 +146,7 @@ test('welcome send failure is audited and reported', async () => { }); const result = await complete(app, env.client, env.member, env.config); assert.match(result.welcome, /^failed:cannot send/); - assert.ok(env.auditSends.some((a) => /welcome message/.test(JSON.stringify(a.embeds[0])))); + assert.ok(env.auditSends.some((a) => /welcome message/.test(JSON.stringify(a.embeds?.[0])))); }); test('dryRun makes no role, send or audit calls and returns the payload', async () => { @@ -178,7 +179,7 @@ test('debug summary audit is sent only when debug is enabled', async () => { const result = await complete(app, on.client, on.member, on.config); assert.equal(result.debug, 'done'); assert.equal(on.auditSends.length, 2); - const summary = JSON.stringify(on.auditSends[1].embeds[0]); + const summary = JSON.stringify(nth(nth(on.auditSends, 1).embeds)); assert.match(summary, /diagnostics/); assert.match(summary, /skipped:no guest roles configured/); }); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 92bb1e0e..29b0b4ca 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -9,7 +9,7 @@ import WelcomeGroup, { } from '../../src/commands/config/groups/welcome.ts'; import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; -import { rejectsUserError } from '../helpers/assertions.ts'; +import { nth, rejectsUserError } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; const app = createTestApp(); @@ -56,7 +56,7 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { await handler(NewRolesGroup, 'set')(ctx); assert.equal(ctx.config.guestRoleIds, 'a,b'); assert.equal(env.auditSends.length, 1); - assert.match(replies[0].content!, /set to/); + assert.match(nth(replies).content!, /set to/); }); test('newroles get is read-only: no audit, marks missing roles', async () => { @@ -64,13 +64,13 @@ test('newroles get is read-only: no audit, marks missing roles', async () => { const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(NewRolesGroup, 'get')(ctx); assert.equal(env.auditSends.length, 0); - assert.match(replies[0].content!, /<@&a>/); - assert.match(replies[0].content!, /gone \(this role no longer exists\)/); + assert.match(nth(replies).content!, /<@&a>/); + assert.match(nth(replies).content!, /gone \(this role no longer exists\)/); const empty = fakeOnboarding(); const e = fakeCommandContext(app, {}, empty); await handler(NewRolesGroup, 'get')(e.ctx); - assert.equal(e.replies[0].content!, 'No new-member roles are configured.'); + assert.equal(nth(e.replies).content!, 'No new-member roles are configured.'); }); test('newroles clear nulls roles and audits', async () => { @@ -117,8 +117,8 @@ test('welcome set-message, get and clear', async () => { const get = fakeCommandContext(app, {}, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'get')(get.ctx); - assert.match(get.replies[0].content!, /Hi \{member\}/); - assert.match(get.replies[0].content!, /Welcome image: Not set/); + assert.match(nth(get.replies).content!, /Hi \{member\}/); + assert.match(nth(get.replies).content!, /Welcome image: Not set/); const clear = fakeCommandContext(app, { which: 'all' }, { ...env, config: set.ctx.config }); await handler(WelcomeGroup as never, 'clear')(clear.ctx); @@ -129,7 +129,7 @@ test('welcome preview explains why nothing would be sent', async () => { const env = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); - assert.match(replies[0].content!, /disabled/); + assert.match(nth(replies).content!, /disabled/); assert.equal(env.systemSends.length, 0); }); @@ -137,6 +137,6 @@ test('welcome preview shows the text without sending', async () => { const env = fakeOnboarding({ config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true } }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); - assert.match(replies[0].content!, /hi <@member-1> <@member-1>/); + assert.match(nth(replies).content!, /hi <@member-1> <@member-1>/); assert.equal(env.systemSends.length, 0); }); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index efef2786..462d225f 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -1,10 +1,14 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; +import type { GuildMember } from 'discord.js'; import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import { stub } from '../fakes/discord.ts'; import { fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; +import { nth } from '../helpers/assertions.ts'; const app = createTestApp(); const { clearConfigs, createConfig } = dbFixtures(app); @@ -21,8 +25,8 @@ beforeEach(async () => { const update = async (oldState: Record, newPending: boolean | null) => { const env = fakeOnboarding({ roles: [role('r1')] }); const newMember = Object.assign(env.member, { pending: newPending }); - const oldMember = { guild: env.guild, id: 'member-1', ...oldState } as any; - await guildMemberUpdate.run(app, env.client as any, oldMember, newMember); + const oldMember = { guild: env.guild, id: 'member-1', ...oldState } as unknown as GuildMember; + await guildMemberUpdate.run(app, stub(env.client), oldMember, newMember); return env.roleAdds.length; }; @@ -41,15 +45,15 @@ test('guildMemberUpdate skips when the old member is partial', async () => { test('guildMemberAdd audits the join and onboards immediately when not pending', async () => { const env = fakeOnboarding({ roles: [role('r1')] }); Object.assign(env.member, { pending: false }); - await guildMemberAdd.run(app, env.client as any, env.member); + await guildMemberAdd.run(app, stub(env.client), env.member); assert.equal(env.roleAdds.length, 1); - assert.match(JSON.stringify(env.auditSends[0].embeds[0]), /New member joined/); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /New member joined/); }); test('guildMemberAdd waits for screening when the member is pending', async () => { const env = fakeOnboarding({ roles: [role('r1')] }); Object.assign(env.member, { pending: true }); - await guildMemberAdd.run(app, env.client as any, env.member); + await guildMemberAdd.run(app, stub(env.client), env.member); assert.equal(env.roleAdds.length, 0); assert.equal(env.auditSends.length, 1); }); diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts index 7acfe382..cd4fca4a 100644 --- a/tests/services/AuditEmbed.test.ts +++ b/tests/services/AuditEmbed.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test'; import { fakeMember, stub } from '../fakes/discord.ts'; import { AuditEmbed, memberAuditEmbed } from '../../src/services/AuditEmbed.ts'; import { EmbedColours } from '../../src/resources/EmbedColours.ts'; +import { nth } from '../helpers/assertions.ts'; const total = (json: ReturnType): number => (json.title?.length ?? 0) + @@ -18,7 +19,8 @@ test('short fields pass through untouched', () => { test('1025-char value is truncated to 1024 with ellipsis and a note', () => { const json = new AuditEmbed().addField('Big', 'x'.repeat(1025)).toJSON(); - const [field, note] = json.fields ?? []; + const field = nth(json.fields); + const note = nth(json.fields, 1); assert.equal(field.value.length, 1024); assert.ok(field.value.endsWith('…')); assert.equal(note.name, 'Truncated'); @@ -35,7 +37,7 @@ test('empty and whitespace values render as *(empty)*', () => { test('long names truncate to 256', () => { const json = new AuditEmbed().addField('n'.repeat(300), 'v').toJSON(); - assert.equal(json.fields?.[0].name.length, 256); + assert.equal(nth(json.fields).name.length, 256); }); test('30 fields are capped at 25 including the truncation note', () => { @@ -45,8 +47,8 @@ test('30 fields are capped at 25 including the truncation note', () => { } const json = embed.toJSON(); assert.equal(json.fields?.length, 25); - assert.equal(json.fields?.[24].name, 'Truncated'); - assert.equal(json.fields?.[23].name, 'f23'); + assert.equal(nth(json.fields, 24).name, 'Truncated'); + assert.equal(nth(json.fields, 23).name, 'f23'); }); test('exactly 25 fields need no note', () => { @@ -56,7 +58,7 @@ test('exactly 25 fields need no note', () => { } const json = embed.toJSON(); assert.equal(json.fields?.length, 25); - assert.notEqual(json.fields?.[24].name, 'Truncated'); + assert.notEqual(nth(json.fields, 24).name, 'Truncated'); }); test('description over 4096 is truncated', () => { @@ -73,7 +75,7 @@ test('7000 total chars are trimmed to 6000 or less, dropping trailing fields', ( const json = embed.toJSON(); assert.ok(total(json) <= 6000, `total was ${total(json)}`); assert.equal(json.fields?.at(-1)?.name, 'Truncated'); - assert.equal(json.fields?.[0].name, 'f0'); + assert.equal(nth(json.fields).name, 'f0'); assert.ok((json.fields?.length ?? 0) < 8); }); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 9eb4710e..71c785e0 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -16,6 +16,7 @@ import { fakeCommandContext } from './fakes/command.ts'; import { fakeGuild } from './fakes/discord.ts'; import { createTestApp } from './helpers/app.ts'; import { dbFixtures } from './helpers/db.ts'; +import { nth, optionNamed, firstOption } from './helpers/assertions.ts'; const app = createTestApp(); const { clearConfigs, findConfig } = dbFixtures(app); @@ -96,7 +97,7 @@ test('channelSetting get reports set, missing and not-set channels', async () => const missing = makeCtx({}, []); await run(channelGroup, 'get')(missing.ctx); - assert.match(missing.replies[0].content!, /no longer exists/); + assert.match(nth(missing.replies).content!, /no longer exists/); }); test('channelSetting clear nulls the field, saves, then replies', async () => { @@ -113,12 +114,13 @@ test('channelSetting clear nulls the field, saves, then replies', async () => { test('channelSetting restricts the channel option types', () => { const json = defineCommand({ name: 'config', description: 'd', groups: [channelGroup] }).toJSON(); - const group: any = json.options?.[0]; - const set = group.options.find((o: any) => o.name === 'set'); - assert.deepEqual(set.options[0].channel_types, [ChannelType.GuildText, ChannelType.GuildAnnouncement]); - assert.equal(set.options[0].required, true); + const group = firstOption(json); + const set = optionNamed(group, 'set'); + const channel = nth(set.options); + assert.deepEqual(channel.channel_types, [ChannelType.GuildText, ChannelType.GuildAnnouncement]); + assert.equal(channel.required, true); assert.deepEqual( - group.options.map((o: any) => o.name), + group.options?.map((o) => o.name), ['set', 'get', 'clear'] ); }); @@ -138,8 +140,7 @@ test('booleanSetting set saves before replying; get reads the stored value', asy test('booleanSetting requires the enabled option', () => { const json = defineCommand({ name: 'config', description: 'd', groups: [boolGroup] }).toJSON(); - const group: any = json.options?.[0]; - const set = group.options.find((o: any) => o.name === 'set'); - assert.equal(set.options[0].name, 'enabled'); - assert.equal(set.options[0].required, true); + const set = optionNamed(firstOption(json), 'set'); + assert.equal(nth(set.options).name, 'enabled'); + assert.equal(nth(set.options).required, true); }); diff --git a/tsconfig.json b/tsconfig.json index 79f3a078..f1acc9ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,10 @@ "moduleResolution": "nodenext", "target": "ES2024", "noEmit": true, - "strict": false, - "strictNullChecks": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "allowImportingTsExtensions": true, From 9ae0697525fef9ae22660e03d99ab1229cf98cc7 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:24:40 +0100 Subject: [PATCH 30/77] Add coverage thresholds and run them in CI Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build-and-publish.yml | 2 +- .github/workflows/lint-and-build.yml | 2 +- README.md | 1 + package.json | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f05de2d4..55b5ff6a 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -38,7 +38,7 @@ jobs: run: npm run typecheck - name: Run tests - run: npm test + run: npm run test:coverage docker: name: Build Docker Image diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index af96fac5..95abcf25 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -42,4 +42,4 @@ jobs: run: npm run typecheck - name: Run tests - run: npm test + run: npm run test:coverage diff --git a/README.md b/README.md index 132eaff1..e379ea79 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Logs are structured JSON. For human-readable output while developing, use `npm r Test and lint: ```bash npm test # Run the test suite +npm run test:coverage # Run tests and enforce coverage thresholds npm run typecheck # Type-check src and tests npm run lint # Check code style and errors ``` diff --git a/package.json b/package.json index 6c5e72a2..ab7e7cad 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "lint": "eslint .", "test": "node --test \"tests/**/*.test.ts\"", + "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 --test-coverage-include='src/**' --test-coverage-exclude='src/index.ts' --test-coverage-exclude='src/scripts/**' \"tests/**/*.test.ts\"", "dev": "node --watch --env-file-if-exists=.env src/index.ts", "dev:pretty": "node --watch --env-file-if-exists=.env src/index.ts | pino-pretty", "start": "node --env-file-if-exists=.env src/index.ts", From e99794cb1172445f155a181c256410db879de07d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:25:43 +0100 Subject: [PATCH 31/77] Upgrade to TypeScript 7 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 843 +++++++++++++++++++++++++++++++--------------- package.json | 2 +- 2 files changed, 564 insertions(+), 281 deletions(-) diff --git a/package-lock.json b/package-lock.json index feae83d3..1663c18e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@types/node": "^24.13.3", "eslint": "^10.6.0", "pino-pretty": "^13.2.0", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "typescript-eslint": "^8.63.0" }, "engines": { @@ -227,45 +227,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", @@ -762,21 +723,15 @@ "@types/node": "*" } }, - "node_modules/@typescript-eslint/eslint-plugin": { + "node_modules/@typescript-eslint/scope-manager": { "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -784,58 +739,31 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" } }, - "node_modules/@typescript-eslint/parser": { + "node_modules/@typescript-eslint/types": { "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3" - }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/project-service": { + "node_modules/@typescript-eslint/visitor-keys": { "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", - "debug": "^4.4.3" + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -843,192 +771,346 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "20 || >=22" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "eslint-visitor-keys": "^5.0.0" - }, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16.20.0" } }, "node_modules/@vladfrangu/async_event_emitter": { @@ -1090,6 +1172,16 @@ "node": ">=8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/better-sqlite3": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", @@ -1102,6 +1194,19 @@ "node": ">=22" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1457,45 +1562,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -1831,6 +1897,22 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1954,9 +2036,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2258,17 +2340,38 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/typescript-eslint": { @@ -2295,6 +2398,186 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", diff --git a/package.json b/package.json index ab7e7cad..a21515bc 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@types/node": "^24.13.3", "eslint": "^10.6.0", "pino-pretty": "^13.2.0", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "typescript-eslint": "^8.63.0" }, "overrides": { From ccc4f7dbb9e92fed5226548dea3da9d4777e1af2 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:26:23 +0100 Subject: [PATCH 32/77] Replace ESLint with oxlint (type-aware via oxlint-tsgolint) Co-Authored-By: Claude Sonnet 5.5 --- .oxlintrc.json | 26 + eslint.config.mjs | 38 - package-lock.json | 1721 +++++++--------------- package.json | 12 +- src/commands/admin/post.ts | 3 +- src/commands/admin/simulate.ts | 2 +- src/commands/config/groups/newroles.ts | 2 +- src/events/messageCreate.ts | 3 +- src/framework/command.ts | 15 +- src/framework/event.ts | 3 +- src/framework/process.ts | 6 +- src/framework/settings.ts | 3 +- src/services/AuditEmbed.ts | 3 +- src/services/AuditService.ts | 2 +- src/services/BirthdayService.ts | 3 +- src/services/DataCheckService.ts | 3 +- src/services/MemberListing.ts | 3 +- src/services/MemberResolver.ts | 3 +- src/services/OnboardingService.ts | 5 +- src/services/WelcomeImage.ts | 6 +- tests/birthdays/BirthdayService.test.ts | 26 +- tests/birthdays/commands.test.ts | 6 +- tests/datacheck/DataCheckService.test.ts | 46 +- tests/onboarding/events.test.ts | 2 +- tests/services/AuditService.test.ts | 3 +- 25 files changed, 618 insertions(+), 1327 deletions(-) create mode 100644 .oxlintrc.json delete mode 100644 eslint.config.mjs diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..b159f4a1 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "import", "node", "oxc"], + "categories": { "correctness": "error" }, + "env": { "node": true, "es2024": true }, + "rules": { + "typescript/no-floating-promises": "error", + "typescript/no-misused-promises": "error", + "typescript/no-explicit-any": "error", + "typescript/consistent-type-imports": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "curly": ["error", "all"], + "no-console": "error" + }, + "overrides": [ + { + "files": ["tests/**/*.ts"], + "rules": { "typescript/no-floating-promises": "off" } + }, + { + "files": ["src/scripts/**/*.ts"], + "rules": { "no-console": "off" } + } + ], + "ignorePatterns": ["node_modules", "coverage", "data", ".claude"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 924bb05f..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import js from '@eslint/js'; -import { defineConfig, globalIgnores } from 'eslint/config'; -import tseslint from 'typescript-eslint'; - -export default defineConfig([ - globalIgnores(['node_modules/**', '.claude/**', 'package.json', '.github/**']), - js.configs.recommended, - tseslint.configs.recommended, - { - files: ['**/*.ts'], - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - rules: { - 'no-tabs': ['error', { allowIndentationTabs: true }], - 'quotes': [ - 'error', - 'single', - { avoidEscape: true, allowTemplateLiterals: false }, - ], - 'semi': ['error', 'always'], - 'brace-style': 'error', - 'curly': ['error', 'all'], - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - }, - }, - { - // node:test's top-level test()/describe() calls return promises that the runner tracks - files: ['tests/**/*.ts'], - rules: { - '@typescript-eslint/no-floating-promises': 'off', - }, - }, -]); diff --git a/package-lock.json b/package-lock.json index 1663c18e..5dad9e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,13 @@ "pino": "^10.4.0" }, "devDependencies": { - "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", - "eslint": "^10.6.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", "pino-pretty": "^13.2.0", - "typescript": "^7.0.2", - "typescript-eslint": "^8.63.0" + "typescript": "^7.0.2" }, "engines": { "node": ">=24" @@ -170,200 +169,6 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@napi-rs/canvas": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", @@ -628,6 +433,437 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2003.tgz", + "integrity": "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2003.tgz", + "integrity": "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2003.tgz", + "integrity": "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2003.tgz", + "integrity": "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2003.tgz", + "integrity": "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2003.tgz", + "integrity": "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -677,27 +913,6 @@ "@types/node": "*" } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", @@ -708,69 +923,19 @@ "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", - "dev": true, + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "dependencies": { + "undici-types": "~7.18.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", - "dev": true, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@types/node": "*" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -1123,46 +1288,6 @@ "npm": ">=7.0.0" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1172,16 +1297,6 @@ "node": ">=8.0.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/better-sqlite3": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", @@ -1194,19 +1309,6 @@ "node": ">=22" } }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1233,21 +1335,6 @@ "node": ">=18.0" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -1258,31 +1345,6 @@ "node": "*" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/discord-api-types": { "version": "0.38.49", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", @@ -1458,174 +1520,6 @@ "once": "^1.4.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-copy": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.2.tgz", @@ -1639,235 +1533,28 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } + "dev": true, + "license": "MIT" }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } + "license": "MIT" }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash": { @@ -1897,22 +1584,6 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1923,20 +1594,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, "node_modules/node-addon-api": { "version": "8.9.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", @@ -1965,87 +1622,71 @@ "wrappy": "1" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", "dev": true, "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" + "bin": { + "oxlint": "bin/oxlint" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" + "url": "https://github.com/sponsors/oxc-project" }, - "engines": { - "node": ">=10" + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } } }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "node_modules/oxlint-tsgolint": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2003.tgz", + "integrity": "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "bin": { + "tsgolint": "bin/tsgolint.js" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2003", + "@oxlint-tsgolint/darwin-x64": "7.0.2003", + "@oxlint-tsgolint/linux-arm64": "7.0.2003", + "@oxlint-tsgolint/linux-x64": "7.0.2003", + "@oxlint-tsgolint/win32-arm64": "7.0.2003", + "@oxlint-tsgolint/win32-x64": "7.0.2003" } }, "node_modules/pino": { @@ -2120,16 +1761,6 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/process-warning": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", @@ -2157,16 +1788,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -2205,42 +1826,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2284,36 +1869,6 @@ "node": ">=20" } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -2326,19 +1881,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2374,210 +1916,6 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, - "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/ignore": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", - "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -2593,42 +1931,6 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2656,19 +1958,6 @@ "optional": true } } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index a21515bc..c2205ac0 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,15 @@ "node": ">=24" }, "scripts": { - "lint": "eslint .", + "lint": "oxlint --type-aware --deny-warnings", "test": "node --test \"tests/**/*.test.ts\"", "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 --test-coverage-include='src/**' --test-coverage-exclude='src/index.ts' --test-coverage-exclude='src/scripts/**' \"tests/**/*.test.ts\"", "dev": "node --watch --env-file-if-exists=.env src/index.ts", "dev:pretty": "node --watch --env-file-if-exists=.env src/index.ts | pino-pretty", "start": "node --env-file-if-exists=.env src/index.ts", "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint:fix": "oxlint --type-aware --fix" }, "dependencies": { "@napi-rs/canvas": "^1.0.2", @@ -29,14 +30,13 @@ "pino": "^10.4.0" }, "devDependencies": { - "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", - "eslint": "^10.6.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", "pino-pretty": "^13.2.0", - "typescript": "^7.0.2", - "typescript-eslint": "^8.63.0" + "typescript": "^7.0.2" }, "overrides": { "brace-expansion@<=1.1.12": "1.1.17", diff --git a/src/commands/admin/post.ts b/src/commands/admin/post.ts index 75e71680..b4c97b84 100644 --- a/src/commands/admin/post.ts +++ b/src/commands/admin/post.ts @@ -1,4 +1,5 @@ -import { ChannelType, Message, PermissionFlagsBits } from 'discord.js'; +import type { Message} from 'discord.js'; +import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { defineCommand } from '../../framework/command.ts'; import { UserError } from '../../framework/errors.ts'; import { EmbedColours } from '../../resources/EmbedColours.ts'; diff --git a/src/commands/admin/simulate.ts b/src/commands/admin/simulate.ts index 0cf72878..175a5536 100644 --- a/src/commands/admin/simulate.ts +++ b/src/commands/admin/simulate.ts @@ -1,4 +1,4 @@ -import { GuildMember, SlashCommandSubcommandBuilder } from 'discord.js'; +import type { GuildMember, SlashCommandSubcommandBuilder } from 'discord.js'; import { type CommandContext, defineCommand } from '../../framework/command.ts'; import { UserError } from '../../framework/errors.ts'; import { resolveMember } from '../../services/MemberResolver.ts'; diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts index 1c35412d..3f58d52b 100644 --- a/src/commands/config/groups/newroles.ts +++ b/src/commands/config/groups/newroles.ts @@ -1,4 +1,4 @@ -import { Role } from 'discord.js'; +import type { Role } from 'discord.js'; import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; import { UserError } from '../../../framework/errors.ts'; import { EmbedColours } from '../../../resources/EmbedColours.ts'; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index eba64a5e..4f920b0e 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,4 +1,5 @@ -import { GuildMember, Message, PermissionFlagsBits, type Snowflake } from 'discord.js'; +import type { GuildMember, Message} from 'discord.js'; +import { PermissionFlagsBits, type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; import type { DingirClient } from '../client/DingirClient.ts'; import type { ServerConfig } from '../db/schema.ts'; diff --git a/src/framework/command.ts b/src/framework/command.ts index 05caf1df..76f99240 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -1,12 +1,15 @@ -import { - type APIEmbed, +import type { Attachment, AttachmentBuilder, - type AttachmentPayload, ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder} from 'discord.js'; +import { + type APIEmbed, + type AttachmentPayload, InteractionContextType, type InteractionEditReplyOptions, type InteractionReplyOptions, @@ -15,9 +18,7 @@ import { PermissionFlagsBits, type RESTPostAPIChatInputApplicationCommandsJSONBody, SlashCommandBuilder, - type SlashCommandOptionsOnlyBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, + type SlashCommandOptionsOnlyBuilder } from 'discord.js'; import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; @@ -38,7 +39,7 @@ export interface CommandContext { guild: Guild; member: GuildMember; config: ServerConfig; - reply(response: string | ReplyOptions): Promise; + reply: (response: string | ReplyOptions) => Promise; } export type Handler = (ctx: CommandContext) => Promise; diff --git a/src/framework/event.ts b/src/framework/event.ts index cd1848ff..5f3bfa21 100644 --- a/src/framework/event.ts +++ b/src/framework/event.ts @@ -1,4 +1,5 @@ -import { Client, type ClientEvents } from 'discord.js'; +import type { Client} from 'discord.js'; +import { type ClientEvents } from 'discord.js'; import type { App } from '../app.ts'; import type { DingirClient } from '../client/DingirClient.ts'; diff --git a/src/framework/process.ts b/src/framework/process.ts index 2b1d127e..0d9b1ad9 100644 --- a/src/framework/process.ts +++ b/src/framework/process.ts @@ -13,9 +13,9 @@ export interface ProcessHandlerDeps { } export interface ProcessHandlers { - onUnhandledRejection(reason: unknown): void; - onUncaughtException(error: unknown): Promise; - onSignal(signal: string): Promise; + onUnhandledRejection: (reason: unknown) => void; + onUncaughtException: (error: unknown) => Promise; + onSignal: (signal: string) => Promise; } /** Resolves true if `run` finished (even by throwing) before the timeout, false if it timed out. */ diff --git a/src/framework/settings.ts b/src/framework/settings.ts index 7a21bf60..70069896 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -1,4 +1,5 @@ -import { type ApplicationCommandOptionAllowedChannelTypes, Guild } from 'discord.js'; +import type { Guild } from 'discord.js'; +import { type ApplicationCommandOptionAllowedChannelTypes } from 'discord.js'; import { defineSubcommandGroup, type SubcommandGroupDefinition } from './command.ts'; import { updateConfig } from '../services/ConfigService.ts'; diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts index 5f62b0a7..8187a68a 100644 --- a/src/services/AuditEmbed.ts +++ b/src/services/AuditEmbed.ts @@ -1,4 +1,5 @@ -import { type ColorResolvable, EmbedBuilder, GuildMember, User } from 'discord.js'; +import type { GuildMember} from 'discord.js'; +import { type ColorResolvable, EmbedBuilder, User } from 'discord.js'; const LIMITS = { title: 256, diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index 90468da0..e9aeea8a 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -1,4 +1,4 @@ -import { Attachment, Client, EmbedBuilder } from 'discord.js'; +import type { Attachment, Client, EmbedBuilder } from 'discord.js'; import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 3b0a10d2..665cbbab 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,4 +1,5 @@ -import { Client, type Snowflake } from 'discord.js'; +import type { Client} from 'discord.js'; +import { type Snowflake } from 'discord.js'; import { DateTime } from 'luxon'; import type { ServerConfig } from '../db/schema.ts'; import type { App } from '../app.ts'; diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index 111c52ae..a9fc032a 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,4 +1,5 @@ -import { Client, type Snowflake } from 'discord.js'; +import type { Client} from 'discord.js'; +import { type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; import { getConfigs, purgeGuild, updateConfig } from './ConfigService.ts'; import { deleteUsers, getServerProfiles } from './UserProfileService.ts'; diff --git a/src/services/MemberListing.ts b/src/services/MemberListing.ts index 88cd4a8d..3308090d 100644 --- a/src/services/MemberListing.ts +++ b/src/services/MemberListing.ts @@ -1,4 +1,5 @@ -import { AttachmentBuilder, GuildMember } from 'discord.js'; +import type { GuildMember } from 'discord.js'; +import { AttachmentBuilder } from 'discord.js'; import { DateTime } from 'luxon'; import type { ReplyOptions } from '../framework/command.ts'; diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts index 2d77e70e..fe74f640 100644 --- a/src/services/MemberResolver.ts +++ b/src/services/MemberResolver.ts @@ -1,4 +1,5 @@ -import { Guild, GuildMember, type GuildTextBasedChannel, type Snowflake, User } from 'discord.js'; +import type { Guild, GuildMember, User } from 'discord.js'; +import { type GuildTextBasedChannel, type Snowflake } from 'discord.js'; const UNKNOWN_MEMBER = 10007; const UNKNOWN_USER = 10013; diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 2769abbe..ec644429 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,10 +1,11 @@ -import { +import type { AttachmentBuilder, Client, Guild, GuildMember, + Role} from 'discord.js'; +import { type PartialGuildMember, - Role, type Snowflake, } from 'discord.js'; import type { App } from '../app.ts'; diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts index 30a9488c..716ae54b 100644 --- a/src/services/WelcomeImage.ts +++ b/src/services/WelcomeImage.ts @@ -1,7 +1,9 @@ import path from 'node:path'; -import { AttachmentBuilder, GuildMember } from 'discord.js'; +import type { GuildMember } from 'discord.js'; +import { AttachmentBuilder } from 'discord.js'; import { DateTime } from 'luxon'; -import { Canvas, createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; +import type { Canvas} from '@napi-rs/canvas'; +import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; import type { App } from '../app.ts'; const FONT_FAMILY = 'Roboto'; diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index bc6080b6..5ae58820 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -35,25 +35,25 @@ const makeConfig = (fields: Record = {}): ServerConfig => createConfig({ serverId: 'g1', ...fields }); test('refreshCalendar: not configured', async () => { - const config = await makeConfig(); + const config = makeConfig(); assert.equal(await refreshCalendar(appAt(), fakeClient(), config), 'not-configured'); }); test('refreshCalendar: channel fetch rejecting keeps the stored path', async () => { - const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const client = fakeClient({ channelFetchError: apiError(50001) }); assert.equal(await refreshCalendar(appAt(), client, config), 'channel-missing'); assert.equal(config.birthdayCalendarMessagePath, 'c1/m1'); }); test('refreshCalendar: message missing', async () => { - const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const client = fakeClient({ channels: [fakeTextChannel('c1')] }); assert.equal(await refreshCalendar(appAt(), client, config), 'message-missing'); }); test('refreshCalendar: edit failure reports failed', async () => { - const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const message = fakeEditableMessage('m1'); message.edit = async () => { throw new Error('boom'); @@ -63,8 +63,8 @@ test('refreshCalendar: edit failure reports failed', async () => { }); test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', async () => { - const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - await createProfiles([ + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + createProfiles([ { serverId: 'g1', userId: 'leap', birthdayMonth: 2, birthdayDay: 29 }, { serverId: 'g1', userId: 'today', birthdayMonth: 2, birthdayDay: 27 }, { serverId: 'g1', userId: 'nobday' }, @@ -86,7 +86,7 @@ test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', asy }); test('refreshCalendar: empty state', async () => { - const config = await makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); const message = fakeEditableMessage('m1'); const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); assert.equal(await refreshCalendar(appAt(), client, config), 'updated'); @@ -94,8 +94,8 @@ test('refreshCalendar: empty state', async () => { }); test('notifyBirthdays: skips departed members and mentions only present ones', async () => { - await makeConfig({ announcementsChannelId: 'ann' }); - await createProfiles([ + makeConfig({ announcementsChannelId: 'ann' }); + createProfiles([ { serverId: 'g1', userId: 'here', birthdayMonth: 6, birthdayDay: 1 }, { serverId: 'g1', userId: 'gone', birthdayMonth: 6, birthdayDay: 1 }, { serverId: 'g1', userId: 'other', birthdayMonth: 6, birthdayDay: 2 }, @@ -111,8 +111,8 @@ test('notifyBirthdays: skips departed members and mentions only present ones', a }); test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async () => { - await makeConfig({ announcementsChannelId: 'ann' }); - await createProfiles([ + makeConfig({ announcementsChannelId: 'ann' }); + createProfiles([ { serverId: 'g1', userId: 'a', birthdayMonth: 2, birthdayDay: 29 }, { serverId: 'g1', userId: 'b', birthdayMonth: 2, birthdayDay: 28 }, ]); @@ -131,8 +131,8 @@ test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async }); test('notifyBirthdays: no announcements channel sends nothing', async () => { - await makeConfig(); - await createProfiles([{ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }]); + makeConfig(); + createProfiles([{ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }]); const channel = fakeTextChannel('ann'); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'], channels: [channel] }); await notifyBirthdays(appAt('2027-06-01T09:00'), fakeClient({ guilds: { g1: guild } })); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 2ea3619a..0ef8c82c 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -54,7 +54,7 @@ test('mybirthday set rejects invalid dates', async () => { exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }), /invalid/ ); - assert.equal(await countProfiles(), 0); + assert.equal(countProfiles(), 0); }); test('mybirthday clear removes the birthday but keeps the profile', async () => { @@ -74,7 +74,7 @@ test('profile replies for a non-member without creating a profile', async () => exec(profile, { options: { member: { id: 'ghost' } }, guild }), "That user isn't a member of this server." ); - assert.equal(await countProfiles(), 0); + assert.equal(countProfiles(), 0); }); test('profile for a member without a profile does not create one', async () => { @@ -93,5 +93,5 @@ test('profile for a member without a profile does not create one', async () => { const field = (name: string): string => embed.fields?.find((f) => f.name === name)?.value ?? ''; assert.equal(field('Onboarding'), 'Not completed'); assert.equal(field('Activity Score'), '0'); - assert.equal(await countProfiles(), 0); + assert.equal(countProfiles(), 0); }); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index 75ba98e4..da7ede07 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -28,50 +28,50 @@ beforeEach(() => { const reload = (id: string): ServerConfig | null => findConfig(id); test('transient errors never purge or change counters', async () => { - await createConfig({ serverId: 'g1', accessFailureCount: 5, firstAccessFailureAt: t0 }); - await createProfiles([{ serverId: 'g1', userId: 'a' }]); + createConfig({ serverId: 'g1', accessFailureCount: 5, firstAccessFailureAt: t0 }); + createProfiles([{ serverId: 'g1', userId: 'a' }]); const client = fakeClient({ guilds: { g1: apiError(500) } }); await run(client, new Date(t0.getTime() + 30 * DAY), policy); - const config = await reload('g1'); + const config = reload('g1'); assert.equal(config?.accessFailureCount, 5); - assert.equal(await countProfiles(), 1); + assert.equal(countProfiles(), 1); }); test('gone guild is purged only after min failures and grace period', async () => { - await createConfig({ serverId: 'g1' }); - await createProfiles([{ serverId: 'g1', userId: 'a' }]); + createConfig({ serverId: 'g1' }); + createProfiles([{ serverId: 'g1', userId: 'a' }]); const client = fakeClient({ guilds: { g1: apiError(50001) } }); await run(client, t0, policy); await run(client, new Date(t0.getTime() + 3 * DAY), policy); await run(client, new Date(t0.getTime() + 6 * DAY), policy); - const config = await reload('g1'); + const config = reload('g1'); assert.equal(config?.accessFailureCount, 3); - assert.equal(await countProfiles(), 1); + assert.equal(countProfiles(), 1); await run(client, new Date(t0.getTime() + 7 * DAY), policy); - assert.equal(await reload('g1'), null); - assert.equal(await countProfiles(), 0); + assert.equal(reload('g1'), null); + assert.equal(countProfiles(), 0); }); test('guild missing from the client entirely counts as gone', async () => { - await createConfig({ serverId: 'g1' }); + createConfig({ serverId: 'g1' }); await run(fakeClient(), t0, policy); - assert.equal((await reload('g1'))?.accessFailureCount, 1); + assert.equal((reload('g1'))?.accessFailureCount, 1); }); test('a successful fetch resets counters', async () => { - await createConfig({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); + createConfig({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); await run(fakeClient({ guilds: { g1: guild } }), new Date(t0.getTime() + DAY), policy); - const config = await reload('g1'); + const config = reload('g1'); assert.equal(config?.accessFailureCount, 0); assert.equal(config?.firstAccessFailureAt, null); }); test('profiles of departed members are deleted, including non-birthday ones', async () => { - await createConfig({ serverId: 'g1' }); - await createProfiles([ + createConfig({ serverId: 'g1' }); + createProfiles([ { serverId: 'g1', userId: 'here' }, { serverId: 'g1', userId: 'gone-plain' }, { serverId: 'g1', userId: 'gone-bday', birthdayMonth: 1, birthdayDay: 1 }, @@ -79,21 +79,21 @@ test('profiles of departed members are deleted, including non-birthday ones', as ]); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here'] }); await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - const remaining = (await allProfiles()).map((p) => `${p.serverId}/${p.userId}`).sort(); + const remaining = (allProfiles()).map((p) => `${p.serverId}/${p.userId}`).sort(); assert.deepEqual(remaining, ['g1/here', 'other/gone-plain']); }); test('an empty member list does not wipe profiles', async () => { - await createConfig({ serverId: 'g1' }); - await createProfiles([{ serverId: 'g1', userId: 'a' }]); + createConfig({ serverId: 'g1' }); + createProfiles([{ serverId: 'g1', userId: 'a' }]); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: [] }); await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - assert.equal(await countProfiles(), 1); + assert.equal(countProfiles(), 1); }); test('one guild throwing does not stop the others', async () => { - await createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); - await createProfiles([ + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + createProfiles([ { serverId: 'g1', userId: 'x' }, { serverId: 'g2', userId: 'x' }, ]); @@ -104,6 +104,6 @@ test('one guild throwing does not stop the others', async () => { }); const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0, policy); - const remaining = (await allProfiles()).map((p) => p.serverId); + const remaining = (allProfiles()).map((p) => p.serverId); assert.deepEqual(remaining, ['g1']); }); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 462d225f..18b34748 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -19,7 +19,7 @@ after(() => { beforeEach(async () => { clearConfigs(); - await createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); + createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); }); const update = async (oldState: Record, newPending: boolean | null) => { diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index c805b3e2..c7e4bb87 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Client, EmbedBuilder } from 'discord.js'; +import type { Client} from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; import { sendAudit } from '../../src/services/AuditService.ts'; import { fakeConfig, stub } from '../fakes/discord.ts'; From 6f71571b095447f1a0d414da4a4d496f6419e22d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:26:35 +0100 Subject: [PATCH 33/77] Add oxfmt config and format/check scripts Co-Authored-By: Claude Sonnet 5.5 --- .oxfmtrc.json | 11 ++ package-lock.json | 410 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +- 3 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 .oxfmtrc.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..3fdc0a81 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf", + "ignorePatterns": ["package-lock.json", "coverage", "data", "src/resources/**", ".claude"] +} diff --git a/package-lock.json b/package-lock.json index 5dad9e26..b8154dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", + "oxfmt": "^0.72.0", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", "pino-pretty": "^13.2.0", @@ -433,6 +434,353 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.72.0.tgz", + "integrity": "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.72.0.tgz", + "integrity": "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.72.0.tgz", + "integrity": "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.72.0.tgz", + "integrity": "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.72.0.tgz", + "integrity": "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.72.0.tgz", + "integrity": "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.72.0.tgz", + "integrity": "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.72.0.tgz", + "integrity": "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.72.0.tgz", + "integrity": "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.72.0.tgz", + "integrity": "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.72.0.tgz", + "integrity": "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.72.0.tgz", + "integrity": "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.72.0.tgz", + "integrity": "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.72.0.tgz", + "integrity": "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.72.0.tgz", + "integrity": "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.72.0.tgz", + "integrity": "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.72.0.tgz", + "integrity": "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.72.0.tgz", + "integrity": "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz", + "integrity": "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint-tsgolint/darwin-arm64": { "version": "7.0.2003", "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2003.tgz", @@ -1622,6 +1970,58 @@ "wrappy": "1" } }, + "node_modules/oxfmt": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", + "integrity": "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.72.0", + "@oxfmt/binding-android-arm64": "0.72.0", + "@oxfmt/binding-darwin-arm64": "0.72.0", + "@oxfmt/binding-darwin-x64": "0.72.0", + "@oxfmt/binding-freebsd-x64": "0.72.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", + "@oxfmt/binding-linux-arm64-gnu": "0.72.0", + "@oxfmt/binding-linux-arm64-musl": "0.72.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-musl": "0.72.0", + "@oxfmt/binding-linux-s390x-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-musl": "0.72.0", + "@oxfmt/binding-openharmony-arm64": "0.72.0", + "@oxfmt/binding-win32-arm64-msvc": "0.72.0", + "@oxfmt/binding-win32-ia32-msvc": "0.72.0", + "@oxfmt/binding-win32-x64-msvc": "0.72.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/oxlint": { "version": "1.87.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", @@ -1869,6 +2269,16 @@ "node": ">=20" } }, + "node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", diff --git a/package.json b/package.json index c2205ac0..90e2a9ef 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,10 @@ "start": "node --env-file-if-exists=.env src/index.ts", "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", "typecheck": "tsc --noEmit", - "lint:fix": "oxlint --type-aware --fix" + "lint:fix": "oxlint --type-aware --fix", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:coverage" }, "dependencies": { "@napi-rs/canvas": "^1.0.2", @@ -33,6 +36,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/luxon": "^3.7.2", "@types/node": "^24.13.3", + "oxfmt": "^0.72.0", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", "pino-pretty": "^13.2.0", From e2c8d464dc3ff8a8ad49cbdf5e191db28fb8a90e Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:26:40 +0100 Subject: [PATCH 34/77] Format with oxfmt Co-Authored-By: Claude Sonnet 5.5 --- .github/dependabot.yml | 26 +-- .github/workflows/build-and-publish.yml | 10 +- README.md | 149 +++++++++--------- SECURITY.md | 3 +- package.json | 100 ++++++------ src/commands/admin/noroles.ts | 4 +- src/commands/admin/post.ts | 14 +- src/commands/admin/rolesince.ts | 8 +- src/commands/config/groups/birthdays.ts | 4 +- src/commands/config/groups/newroles.ts | 8 +- src/commands/config/groups/welcome.ts | 17 +- src/commands/index.ts | 12 +- src/commands/info/about.ts | 2 +- src/commands/info/mybirthday.ts | 12 +- src/commands/info/profile.ts | 4 +- src/config/env.ts | 7 +- src/db/migrations/002-userprofile-unique.ts | 12 +- .../003-serverconfig-access-tracking.ts | 6 +- src/db/migrations/004-bot-state.ts | 2 +- src/db/migrator.ts | 2 +- src/db/schema.ts | 2 +- src/events/guildMemberRemove.ts | 6 +- src/events/interactionCreate.ts | 2 +- src/events/messageCreate.ts | 33 ++-- src/framework/command.ts | 17 +- src/framework/event.ts | 16 +- src/framework/process.ts | 2 +- src/framework/registrar.ts | 6 +- src/framework/settings.ts | 9 +- src/services/AuditEmbed.ts | 6 +- src/services/AuditService.ts | 2 +- src/services/BirthdayDates.ts | 8 +- src/services/BirthdayService.ts | 14 +- src/services/ConfigService.ts | 10 +- src/services/DataCheckService.ts | 6 +- src/services/MemberListing.ts | 2 +- src/services/MemberResolver.ts | 7 +- src/services/OnboardingService.ts | 54 +++---- src/services/RetentionPolicy.ts | 2 +- src/services/Scheduler.ts | 4 +- src/services/UserProfileService.ts | 21 +-- src/services/WelcomeImage.ts | 21 +-- src/utilities/Logger.ts | 2 +- tests/about.test.ts | 10 +- tests/birthdays/BirthdayService.test.ts | 6 +- tests/birthdays/commands.test.ts | 8 +- tests/command.test.ts | 44 ++++-- tests/database/migrations.test.ts | 36 +++-- tests/database/purgeGuild.test.ts | 11 +- tests/database/schema.test.ts | 9 +- tests/database/services.test.ts | 5 +- tests/datacheck/DataCheckService.test.ts | 20 ++- tests/dispatcher.test.ts | 31 +++- tests/env.test.ts | 2 +- tests/fakes/command.ts | 2 +- tests/fakes/discord.ts | 10 +- tests/fakes/guild.ts | 4 +- tests/fakes/messages.ts | 5 +- tests/fakes/onboarding.ts | 13 +- tests/helpers/app.ts | 8 +- tests/helpers/assertions.ts | 5 +- tests/helpers/db.ts | 2 +- tests/logger.test.ts | 7 +- tests/moderation/events.test.ts | 28 +++- tests/moderation/memberListing.test.ts | 21 ++- tests/moderation/post.test.ts | 13 +- tests/onboarding/OnboardingService.test.ts | 83 ++++++++-- tests/onboarding/commands.test.ts | 20 ++- tests/registry.test.ts | 10 +- tests/services/AuditEmbed.test.ts | 7 +- tests/services/AuditService.test.ts | 12 +- tests/services/BirthdayDates.test.ts | 6 +- tests/services/MemberResolver.test.ts | 5 +- tests/services/RetentionPolicy.test.ts | 5 +- tests/services/Scheduler.test.ts | 15 +- tests/settings.test.ts | 2 +- tsconfig.json | 36 ++--- 77 files changed, 715 insertions(+), 440 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 992c0ea8..148a4bed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,27 +5,27 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "daily" + interval: 'daily' labels: - - "dependencies" + - 'dependencies' groups: all-dependencies: patterns: - - "*" + - '*' - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "daily" + interval: 'daily' labels: - - "dependencies" + - 'dependencies' - - package-ecosystem: "docker" - directory: "/" + - package-ecosystem: 'docker' + directory: '/' schedule: - interval: "daily" + interval: 'daily' labels: - - "dependencies" + - 'dependencies' diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 55b5ff6a..7ae95923 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -3,12 +3,12 @@ name: Build on Tag on: push: tags: - - '*' + - '*' permissions: checks: write packages: write - + jobs: verify: name: Lint, Typecheck and Test @@ -65,6 +65,6 @@ jobs: with: push: true platforms: linux/amd64,linux/arm64 - tags: | - ghcr.io/brentoates/dingir:${{ github.ref_name }} - ghcr.io/brentoates/dingir:latest + tags: | + ghcr.io/brentoates/dingir:${{ github.ref_name }} + ghcr.io/brentoates/dingir:latest diff --git a/README.md b/README.md index e379ea79..976ccbd5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Discord bot for the Irkallu server, built with discord.js and TypeScript. Prov 4. Start: `npm start` (Node runs the TypeScript source directly; there is no build step) For watch mode during development: + ```bash npm run dev ``` @@ -19,6 +20,7 @@ npm run dev Logs are structured JSON. For human-readable output while developing, use `npm run dev:pretty` (pipes through `pino-pretty`, a dev dependency). In production, pipe `docker logs` or `npm start` through `npx pino-pretty` if you want the same. Test and lint: + ```bash npm test # Run the test suite npm run test:coverage # Run tests and enforce coverage thresholds @@ -45,17 +47,17 @@ The database is stored in `/usr/src/app/data` inside the container; mount a volu All configuration is managed through environment variables in `.env`. See `.env.example` for defaults. -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `TOKEN` | Yes | — | Discord bot token from the Developer Portal | -| `CLIENT_ID` | Yes | — | Discord application (client) ID from the Developer Portal | -| `JOB_SCHEDULE` | No | `0 9 * * *` | Cron schedule (5 or 6 fields) for daily cleanup, birthday notifications, and calendar updates. Example: `0 9 * * *` = 9:00 AM daily | -| `BOT_TIMEZONE` | No | `Europe/London` | IANA time zone used for scheduling and birthday calculations (e.g. `Europe/London`, `America/New_York`) | -| `DB_PATH` | No | `data/dingir.sqlite` | Path to SQLite database file. Use `:memory:` for throwaway runs (no persistence) | -| `PURGE_MIN_FAILURES` | No | `3` | Minimum number of failed guild access checks before purging orphaned data (see Data & Retention) | -| `PURGE_GRACE_DAYS` | No | `7` | Grace period (days) after first failure before purging. Data is purged only after both thresholds are met | -| `LOG_LEVEL` | No | `info` | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug` or `trace`. Logs are JSON, one object per line | -| `DEV_GUILD_ID` | No | — | Guild ID for instant slash command registration during development (commands update immediately instead of within 1 hour). Omit in production | +| Variable | Required | Default | Description | +| -------------------- | -------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `TOKEN` | Yes | — | Discord bot token from the Developer Portal | +| `CLIENT_ID` | Yes | — | Discord application (client) ID from the Developer Portal | +| `JOB_SCHEDULE` | No | `0 9 * * *` | Cron schedule (5 or 6 fields) for daily cleanup, birthday notifications, and calendar updates. Example: `0 9 * * *` = 9:00 AM daily | +| `BOT_TIMEZONE` | No | `Europe/London` | IANA time zone used for scheduling and birthday calculations (e.g. `Europe/London`, `America/New_York`) | +| `DB_PATH` | No | `data/dingir.sqlite` | Path to SQLite database file. Use `:memory:` for throwaway runs (no persistence) | +| `PURGE_MIN_FAILURES` | No | `3` | Minimum number of failed guild access checks before purging orphaned data (see Data & Retention) | +| `PURGE_GRACE_DAYS` | No | `7` | Grace period (days) after first failure before purging. Data is purged only after both thresholds are met | +| `LOG_LEVEL` | No | `info` | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug` or `trace`. Logs are JSON, one object per line | +| `DEV_GUILD_ID` | No | — | Guild ID for instant slash command registration during development (commands update immediately instead of within 1 hour). Omit in production | ## Discord Setup @@ -96,23 +98,23 @@ Commands are organized into categories. Only **admin-only** commands require the ### Info Commands -| Command | Who Can Use | Description | -|---------|------------|-------------| -| `/ping` | Everyone | Show WebSocket latency and round-trip time | -| `/about` | Everyone | Display Dingir version, source code link, server info, and uptime | -| `/profile ` | Admin | Fetch a member's profile: nickname, username, join date, onboarding status, activity score, and birthday if set | -| `/mybirthday set ` | Everyone | Set your birthday for this server | -| `/mybirthday clear` | Everyone | Remove your birthday from this server | +| Command | Who Can Use | Description | +| ------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | +| `/ping` | Everyone | Show WebSocket latency and round-trip time | +| `/about` | Everyone | Display Dingir version, source code link, server info, and uptime | +| `/profile ` | Admin | Fetch a member's profile: nickname, username, join date, onboarding status, activity score, and birthday if set | +| `/mybirthday set ` | Everyone | Set your birthday for this server | +| `/mybirthday clear` | Everyone | Remove your birthday from this server | ### Admin Commands -| Command | Description | -|---------|-------------| -| `/simulate join [member]` | Audit only: send the "member joined" audit for a member (no roles or welcome message). Defaults to you | -| `/simulate onboard [member]` | Dry run: show what onboarding would do (guest roles, welcome) without applying changes. Defaults to you | -| `/rolesince [days]` | List members in a role who joined the server at least N days ago | -| `/noroles` | List all members with no roles assigned (excluding bots) | -| `/post [content] [attachment]` | Post a simple message and/or file to a channel. Requires at least one of content or attachment | +| Command | Description | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `/simulate join [member]` | Audit only: send the "member joined" audit for a member (no roles or welcome message). Defaults to you | +| `/simulate onboard [member]` | Dry run: show what onboarding would do (guest roles, welcome) without applying changes. Defaults to you | +| `/rolesince [days]` | List members in a role who joined the server at least N days ago | +| `/noroles` | List all members with no roles assigned (excluding bots) | +| `/post [content] [attachment]` | Post a simple message and/or file to a channel. Requires at least one of content or attachment | ### Config Commands @@ -120,77 +122,77 @@ Use `/config ` to manage server settings. All are admin-only #### `/config announcements` — Announcements Channel -| Subcommand | Description | -|------------|-------------| -| `set ` | Set the announcements channel | -| `get` | Show the currently configured announcements channel | -| `clear` | Clear the announcements channel | +| Subcommand | Description | +| --------------- | --------------------------------------------------- | +| `set ` | Set the announcements channel | +| `get` | Show the currently configured announcements channel | +| `clear` | Clear the announcements channel | #### `/config audit` — Audit Log Channel -| Subcommand | Description | -|------------|-------------| +| Subcommand | Description | +| --------------- | ---------------------------------------------------------- | | `set ` | Set the audit log channel (where member events are posted) | -| `get` | Show the currently configured audit channel | -| `clear` | Clear the audit channel | +| `get` | Show the currently configured audit channel | +| `clear` | Clear the audit channel | #### `/config sysmsgs` — Bot System Messages Toggle whether the bot uses Discord's system channel for welcome messages: -| Subcommand | Description | -|------------|-------------| -| `enable` | Enable bot system messages in the server's system channel | -| `disable` | Disable bot system messages in the server's system channel | -| `status` | Show the current status | +| Subcommand | Description | +| ---------- | ---------------------------------------------------------- | +| `enable` | Enable bot system messages in the server's system channel | +| `disable` | Disable bot system messages in the server's system channel | +| `status` | Show the current status | #### `/config honeypot` — Honeypot Channel Configure a channel that automatically bans members who post in it: -| Subcommand | Description | -|------------|-------------| -| `set ` | Set the honeypot channel | -| `get` | Show the currently configured honeypot channel | -| `clear` | Clear the honeypot channel | +| Subcommand | Description | +| --------------- | ---------------------------------------------- | +| `set ` | Set the honeypot channel | +| `get` | Show the currently configured honeypot channel | +| `clear` | Clear the honeypot channel | #### `/config welcome` — Welcome Messages & Images -| Subcommand | Description | -|------------|-------------| +| Subcommand | Description | +| -------------------- | --------------------------------------------------------------------------------------- | | `set-message ` | Set the welcome message (max 1500 characters). Use `{member}` to mention the new member | -| `set-image ` | Set the background image for welcome images (http/https URL) | -| `get` | Show the welcome message and image URL | -| `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | -| `preview` | Preview the welcome message as it would be sent for you | +| `set-image ` | Set the background image for welcome images (http/https URL) | +| `get` | Show the welcome message and image URL | +| `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | +| `preview` | Preview the welcome message as it would be sent for you | #### `/config newroles` — New Member Roles Roles given to members when they complete onboarding: -| Subcommand | Description | -|------------|-------------| -| `get` | Show the current new-member roles | +| Subcommand | Description | +| ---------------------------------------- | ------------------------------------------- | +| `get` | Show the current new-member roles | | `set [role-two] [role-three]` | Set up to three roles to give on onboarding | -| `clear` | Clear the new-member roles | +| `clear` | Clear the new-member roles | #### `/config birthdays` — Birthday Calendar -| Subcommand | Description | -|------------|-------------| +| Subcommand | Description | +| ------------------ | --------------------------------------------------------------------------------------- | | `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays) | -| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | -| `remove` | Remove the birthday calendar message | +| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | +| `remove` | Remove the birthday calendar message | #### `/config debug` — Onboarding Diagnostics Toggle diagnostic audits for onboarding runs: -| Subcommand | Description | -|------------|-------------| -| `enable` | Post a summary of each onboarding run to the audit channel | -| `disable` | Do not post onboarding summaries | -| `status` | Show the current status | +| Subcommand | Description | +| ---------- | ---------------------------------------------------------- | +| `enable` | Post a summary of each onboarding run to the audit channel | +| `disable` | Do not post onboarding summaries | +| `status` | Show the current status | ## Behaviour @@ -204,6 +206,7 @@ When a member joins without screening enabled, or completes server screening: 4. **Debug**: If debug mode is enabled, a diagnostic summary is posted to the audit channel showing what succeeded, failed, or was skipped Roles are only assigned if: + - They are below the bot's highest role in the role hierarchy - They are not managed by an integration - They are not `@everyone` @@ -213,6 +216,7 @@ If a role cannot be assigned, the error is logged and the onboarding continues f ### Audit Log Member events are posted to the configured audit channel: + - Member joined (join audit) - Member completed onboarding - Member left (with member data cleanup status) @@ -223,6 +227,7 @@ Member events are posted to the configured audit channel: ### Honeypot If a honeypot channel is configured, any non-administrator member who posts in it is: + 1. Immediately banned (with messages from the past 7 days deleted) 2. Removed from the database 3. Logged to the audit channel @@ -264,11 +269,11 @@ Migrations run automatically on startup. Current migrations: ### What Gets Deleted -| Event | Data Deleted | -|-------|--------------| -| Member leaves server | User profile (birthday, activity score, settings) for that server | -| Bot removed from server | All configuration and user profiles for that server (immediate) | -| Guild unreachable for `PURGE_MIN_FAILURES` checks spanning `PURGE_GRACE_DAYS` days | All configuration and user profiles for that guild (purge) | +| Event | Data Deleted | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Member leaves server | User profile (birthday, activity score, settings) for that server | +| Bot removed from server | All configuration and user profiles for that server (immediate) | +| Guild unreachable for `PURGE_MIN_FAILURES` checks spanning `PURGE_GRACE_DAYS` days | All configuration and user profiles for that guild (purge) | **Note**: Transient errors (network timeouts, API rate limiting, temporary outages) never count toward the purge threshold. Only permanent failures (guild deleted, bot kicked) count. @@ -313,7 +318,7 @@ export default defineCommand({ description: 'Greet a member', options: (b) => b.addUserOption((opt) => - opt.setName('member').setDescription('Member to greet').setRequired(true) + opt.setName('member').setDescription('Member to greet').setRequired(true), ), run: async (ctx) => { const user = ctx.interaction.options.getUser('member', true); @@ -333,10 +338,10 @@ Use the helpers in `src/framework/settings.ts`. For a channel setting: channelSetting({ name: 'myfeature', description: 'Set the my feature channel', - field: 'myFeatureChannelId', // Must exist in ServerConfig + field: 'myFeatureChannelId', // Must exist in ServerConfig label: 'My feature channel', channelTypes: [ChannelType.GuildText], -}) +}); ``` Then ensure the field is defined in `src/db/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. @@ -352,7 +357,7 @@ Events are defined in `src/events/`. Discord.js events are mapped automatically. import { defineEvent } from '../framework/event'; export default defineEvent({ - name: 'messageCreate', // discord.js event name + name: 'messageCreate', // discord.js event name run: async (app, client, message) => { // `app` carries env, db, logger, clock, honeypot and shutdown; see src/app.ts }, diff --git a/SECURITY.md b/SECURITY.md index 908a0083..67843931 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,10 @@ ## Supported Versions - | Version | Supported | | ------- | ------------------ | | 1.0.x | :no_entry: | | 1.1.x | :no_entry: | | 1.2.x | :no_entry: | | 1.3.x | :no_entry: | -| 2.0.x | :white_check_mark: | \ No newline at end of file +| 2.0.x | :white_check_mark: | diff --git a/package.json b/package.json index 90e2a9ef..19db21e8 100644 --- a/package.json +++ b/package.json @@ -1,52 +1,52 @@ { - "name": "dingir", - "author": "Brent Oates", - "repository": { - "type": "git", - "url": "https://github.com/BrentOates/dingir.git" - }, - "version": "3.0.0", - "engines": { - "node": ">=24" - }, - "scripts": { - "lint": "oxlint --type-aware --deny-warnings", - "test": "node --test \"tests/**/*.test.ts\"", - "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 --test-coverage-include='src/**' --test-coverage-exclude='src/index.ts' --test-coverage-exclude='src/scripts/**' \"tests/**/*.test.ts\"", - "dev": "node --watch --env-file-if-exists=.env src/index.ts", - "dev:pretty": "node --watch --env-file-if-exists=.env src/index.ts | pino-pretty", - "start": "node --env-file-if-exists=.env src/index.ts", - "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", - "typecheck": "tsc --noEmit", - "lint:fix": "oxlint --type-aware --fix", - "format": "oxfmt", - "format:check": "oxfmt --check", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:coverage" - }, - "dependencies": { - "@napi-rs/canvas": "^1.0.2", - "better-sqlite3": "^13.0.3", - "croner": "^10.0.1", - "discord.js": "^14.26.5", - "drizzle-orm": "^0.45.3", - "luxon": "^3.7.2", - "pino": "^10.4.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^9.6.0", - "@types/luxon": "^3.7.2", - "@types/node": "^24.13.3", - "oxfmt": "^0.72.0", - "oxlint": "^1.87.0", - "oxlint-tsgolint": "^7.0.2003", - "pino-pretty": "^13.2.0", - "typescript": "^7.0.2" - }, - "overrides": { - "brace-expansion@<=1.1.12": "1.1.17", - "lodash@<=4.17.23": "4.18.1", - "minimatch@<=3.1.3": "3.1.5", - "undici@<=6.26.0": "6.27.0" - }, - "type": "module" + "name": "dingir", + "version": "3.0.0", + "author": "Brent Oates", + "repository": { + "type": "git", + "url": "https://github.com/BrentOates/dingir.git" + }, + "type": "module", + "scripts": { + "lint": "oxlint --type-aware --deny-warnings", + "test": "node --test \"tests/**/*.test.ts\"", + "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 --test-coverage-include='src/**' --test-coverage-exclude='src/index.ts' --test-coverage-exclude='src/scripts/**' \"tests/**/*.test.ts\"", + "dev": "node --watch --env-file-if-exists=.env src/index.ts", + "dev:pretty": "node --watch --env-file-if-exists=.env src/index.ts | pino-pretty", + "start": "node --env-file-if-exists=.env src/index.ts", + "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", + "typecheck": "tsc --noEmit", + "lint:fix": "oxlint --type-aware --fix", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:coverage" + }, + "dependencies": { + "@napi-rs/canvas": "^1.0.2", + "better-sqlite3": "^13.0.3", + "croner": "^10.0.1", + "discord.js": "^14.26.5", + "drizzle-orm": "^0.45.3", + "luxon": "^3.7.2", + "pino": "^10.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/luxon": "^3.7.2", + "@types/node": "^24.13.3", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", + "pino-pretty": "^13.2.0", + "typescript": "^7.0.2" + }, + "overrides": { + "brace-expansion@<=1.1.12": "1.1.17", + "lodash@<=4.17.23": "4.18.1", + "minimatch@<=3.1.3": "3.1.5", + "undici@<=6.26.0": "6.27.0" + }, + "engines": { + "node": ">=24" + } } diff --git a/src/commands/admin/noroles.ts b/src/commands/admin/noroles.ts index 2baede2f..8045a3fd 100644 --- a/src/commands/admin/noroles.ts +++ b/src/commands/admin/noroles.ts @@ -9,10 +9,10 @@ export default defineCommand({ run: async (ctx) => { const all = await ctx.guild.members.fetch(); const members = [...all.values()].filter( - (member) => !member.user.bot && member.roles.cache.size === 1 + (member) => !member.user.bot && member.roles.cache.size === 1, ); await ctx.reply( - buildMemberListing('**Users with no roles**', 'There are no users with no roles.', members) + buildMemberListing('**Users with no roles**', 'There are no users with no roles.', members), ); }, }); diff --git a/src/commands/admin/post.ts b/src/commands/admin/post.ts index b4c97b84..21efa98b 100644 --- a/src/commands/admin/post.ts +++ b/src/commands/admin/post.ts @@ -1,4 +1,4 @@ -import type { Message} from 'discord.js'; +import type { Message } from 'discord.js'; import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { defineCommand } from '../../framework/command.ts'; import { UserError } from '../../framework/errors.ts'; @@ -19,13 +19,13 @@ export default defineCommand({ .setName('channel') .setDescription('Channel to post in') .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true) + .setRequired(true), ) .addStringOption((opt) => - opt.setName('content').setDescription('Optional simple message to send').setMaxLength(2000) + opt.setName('content').setDescription('Optional simple message to send').setMaxLength(2000), ) .addAttachmentOption((opt) => - opt.setName('attachment').setDescription('Optional attachment to send') + opt.setName('attachment').setDescription('Optional attachment to send'), ), run: async (ctx) => { const options = ctx.interaction.options; @@ -52,7 +52,9 @@ export default defineCommand({ const names = Object.entries(PermissionFlagsBits) .filter(([, flag]) => missing.includes(flag)) .map(([name]) => name); - throw new UserError(`I am missing permissions in ${channel.toString()}: ${names.join(', ')}.`); + throw new UserError( + `I am missing permissions in ${channel.toString()}: ${names.join(', ')}.`, + ); } let sent: Message; @@ -66,7 +68,7 @@ export default defineCommand({ ctx.app.logger.warn( 'Failed to send /post message', { guildId: ctx.guild.id, channelId: channel.id }, - error + error, ); throw new UserError('An error was encountered sending this message.', { cause: error }); } diff --git a/src/commands/admin/rolesince.ts b/src/commands/admin/rolesince.ts index 30da6aeb..4f674a09 100644 --- a/src/commands/admin/rolesince.ts +++ b/src/commands/admin/rolesince.ts @@ -9,13 +9,13 @@ export default defineCommand({ options: (b) => b .addRoleOption((opt) => - opt.setName('role').setDescription('Role to search').setRequired(true) + opt.setName('role').setDescription('Role to search').setRequired(true), ) .addIntegerOption((opt) => opt .setName('days') .setDescription('Minimum number of days since the member joined the server') - .setMinValue(0) + .setMinValue(0), ), run: async (ctx) => { const role = ctx.interaction.options.getRole('role', true); @@ -35,8 +35,8 @@ export default defineCommand({ buildMemberListing( `**Users in ${role.toString()} that joined the server at least ${days} days ago**`, `There are no users in ${role.toString()} that joined the server at least ${days} days ago.`, - members - ) + members, + ), ); }, }); diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index cceed39f..42afb2b1 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -23,7 +23,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ .setName('channel') .setDescription('Channel to create the birthday calendar in') .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true) + .setRequired(true), ), run: async (ctx) => { const { id } = ctx.interaction.options.getChannel('channel', true); @@ -57,7 +57,7 @@ export const BirthdaysGroup = defineSubcommandGroup({ await ctx.reply( status === 'updated' ? `Birthday calendar has been created: ${link}` - : `Birthday calendar message was created (${link}) but could not be populated yet; try \`/config birthdays sync\`.` + : `Birthday calendar message was created (${link}) but could not be populated yet; try \`/config birthdays sync\`.`, ); }, }, diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts index 3f58d52b..eb690840 100644 --- a/src/commands/config/groups/newroles.ts +++ b/src/commands/config/groups/newroles.ts @@ -25,7 +25,7 @@ const get = async (ctx: CommandContext) => { return; } const lines = ids.map((id) => - ctx.guild.roles.cache.has(id) ? `<@&${id}>` : `${id} (this role no longer exists)` + ctx.guild.roles.cache.has(id) ? `<@&${id}>` : `${id} (this role no longer exists)`, ); await ctx.reply({ content: `Roles given to members when they complete onboarding:\n${lines.join('\n')}`, @@ -51,13 +51,13 @@ export const NewRolesGroup = defineSubcommandGroup({ option .setName(ROLE_OPTIONS[0]) .setDescription('Role to give to members when they complete onboarding') - .setRequired(true) + .setRequired(true), ) .addRoleOption((option) => - option.setName(ROLE_OPTIONS[1]).setDescription('Optional second role to give') + option.setName(ROLE_OPTIONS[1]).setDescription('Optional second role to give'), ) .addRoleOption((option) => - option.setName(ROLE_OPTIONS[2]).setDescription('Optional third role to give') + option.setName(ROLE_OPTIONS[2]).setDescription('Optional third role to give'), ), run: async (ctx) => { const roles = new Map(); diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index 70a93731..4be0e9de 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -42,7 +42,7 @@ const setImage = async (ctx: CommandContext) => { } catch (error) { throw new UserError( `That image could not be used: ${error instanceof Error ? error.message : String(error)}`, - { cause: error } + { cause: error }, ); } ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { @@ -76,7 +76,9 @@ const clear = async (ctx: CommandContext) => { }; const preview = async (ctx: CommandContext) => { - const result = await complete(ctx.app, ctx.interaction.client, ctx.member, ctx.config, { dryRun: true }); + const result = await complete(ctx.app, ctx.interaction.client, ctx.member, ctx.config, { + dryRun: true, + }); const payload = result.welcomePayload; if (!payload) { await ctx.reply(`Nothing would be sent: ${result.welcome.replace(/^(skipped|failed):/, '')}.`); @@ -103,7 +105,7 @@ export const WelcomeGroup = defineSubcommandGroup({ .setName('text') .setDescription('The welcome text; use {member} to mention the new member') .setMaxLength(MAX_WELCOME_MESSAGE_LENGTH) - .setRequired(true) + .setRequired(true), ), run: setMessage, }, @@ -113,7 +115,10 @@ export const WelcomeGroup = defineSubcommandGroup({ defer: 'ephemeral', options: (sub) => sub.addStringOption((opt) => - opt.setName('url').setDescription('http(s) URL of the background image').setRequired(true) + opt + .setName('url') + .setDescription('http(s) URL of the background image') + .setRequired(true), ), run: setImage, }, @@ -134,8 +139,8 @@ export const WelcomeGroup = defineSubcommandGroup({ .addChoices( { name: 'Welcome message', value: 'message' }, { name: 'Welcome image', value: 'image' }, - { name: 'Both', value: 'all' } - ) + { name: 'Both', value: 'all' }, + ), ), run: clear, }, diff --git a/src/commands/index.ts b/src/commands/index.ts index da175ec8..62831fce 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -10,4 +10,14 @@ import ping from './info/ping.ts'; import profile from './info/profile.ts'; /** Every slash command the bot registers. Add new commands here. */ -export const commands: Command[] = [noroles, post, rolesince, simulate, config, about, mybirthday, ping, profile]; +export const commands: Command[] = [ + noroles, + post, + rolesince, + simulate, + config, + about, + mybirthday, + ping, + profile, +]; diff --git a/src/commands/info/about.ts b/src/commands/info/about.ts index a2bd8660..2b1baa57 100644 --- a/src/commands/info/about.ts +++ b/src/commands/info/about.ts @@ -26,7 +26,7 @@ export default defineCommand({ { name: 'Source', value: repoUrl, inline: false }, { name: 'Servers', value: serverCount.toString(), inline: false }, { name: 'This server', value: `${guildName} (${memberCount} members)`, inline: false }, - { name: 'Uptime', value: uptime, inline: false } + { name: 'Uptime', value: uptime, inline: false }, ); await ctx.reply({ embeds: [embed] }); diff --git a/src/commands/info/mybirthday.ts b/src/commands/info/mybirthday.ts index 1b37a043..6cd27e7b 100644 --- a/src/commands/info/mybirthday.ts +++ b/src/commands/info/mybirthday.ts @@ -31,7 +31,7 @@ export default defineCommand({ .setDescription('Day of the month of your birthday') .setRequired(true) .setMinValue(1) - .setMaxValue(31) + .setMaxValue(31), ) .addIntegerOption((opt) => opt @@ -39,7 +39,7 @@ export default defineCommand({ .setDescription('Month of your birthday') .setRequired(true) .setMinValue(1) - .setMaxValue(12) + .setMaxValue(12), ), run: async (ctx) => { const day = ctx.interaction.options.getInteger('day', true); @@ -66,13 +66,9 @@ export default defineCommand({ name: 'clear', description: 'Remove your birthday from this server', run: async (ctx) => { - const cleared = await clearBirthday( - ctx.app.db, - ctx.guild.id, - ctx.interaction.user.id - ); + const cleared = await clearBirthday(ctx.app.db, ctx.guild.id, ctx.interaction.user.id); await ctx.reply( - cleared ? 'Your birthday has been removed.' : "You don't have a birthday set." + cleared ? 'Your birthday has been removed.' : "You don't have a birthday set.", ); if (cleared) { await refresh(ctx); diff --git a/src/commands/info/profile.ts b/src/commands/info/profile.ts index 8bc66bf0..2e87c795 100644 --- a/src/commands/info/profile.ts +++ b/src/commands/info/profile.ts @@ -12,7 +12,7 @@ export default defineCommand({ adminOnly: true, options: (b) => b.addUserOption((opt) => - opt.setName('member').setDescription('Member to fetch profile for').setRequired(true) + opt.setName('member').setDescription('Member to fetch profile for').setRequired(true), ), run: async (ctx) => { const user = ctx.interaction.options.getUser('member', true); @@ -40,7 +40,7 @@ export default defineCommand({ { name: 'Username', value: member.user.username }, { name: 'Joined', value: `` }, { name: 'Onboarding', value: member.pending ? 'Not completed' : 'Completed' }, - { name: 'Activity Score', value: String(profile?.activityScore ?? 0) } + { name: 'Activity Score', value: String(profile?.activityScore ?? 0) }, ); if (birthday) { embed.addFields({ name: 'Birthday', value: birthday }); diff --git a/src/config/env.ts b/src/config/env.ts index 694c6a30..f2896e82 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,10 @@ import { IANAZone } from 'luxon'; -import { DEFAULT_LOG_LEVEL, LOG_LEVELS, type LogLevel, parseLogLevel } from '../utilities/Logger.ts'; +import { + DEFAULT_LOG_LEVEL, + LOG_LEVELS, + type LogLevel, + parseLogLevel, +} from '../utilities/Logger.ts'; export interface Env { readonly token: string; diff --git a/src/db/migrations/002-userprofile-unique.ts b/src/db/migrations/002-userprofile-unique.ts index b46b01fb..60244194 100644 --- a/src/db/migrations/002-userprofile-unique.ts +++ b/src/db/migrations/002-userprofile-unique.ts @@ -15,7 +15,11 @@ interface ProfileRow { } const toTime = (value: string | null): number => { - const parsed = Date.parse(String(value ?? '').replace(' ', 'T').replace(/ ([+-])/, '$1')); + const parsed = Date.parse( + String(value ?? '') + .replace(' ', 'T') + .replace(/ ([+-])/, '$1'), + ); return Number.isNaN(parsed) ? 0 : parsed; }; @@ -31,7 +35,7 @@ const newestFirst = (a: ProfileRow, b: ProfileRow): number => export function up(db: Database, logger: Logger): void { const rows = db .prepare( - 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL' + 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL', ) .all() as ProfileRow[]; @@ -77,7 +81,5 @@ export function up(db: Database, logger: Logger): void { } } - db.exec( - `CREATE UNIQUE INDEX \`${INDEX_NAME}\` ON \`UserProfiles\` (\`serverId\`, \`userId\`)` - ); + db.exec(`CREATE UNIQUE INDEX \`${INDEX_NAME}\` ON \`UserProfiles\` (\`serverId\`, \`userId\`)`); } diff --git a/src/db/migrations/003-serverconfig-access-tracking.ts b/src/db/migrations/003-serverconfig-access-tracking.ts index dd8332e2..eeee3e48 100644 --- a/src/db/migrations/003-serverconfig-access-tracking.ts +++ b/src/db/migrations/003-serverconfig-access-tracking.ts @@ -2,11 +2,13 @@ import type { Database } from 'better-sqlite3'; export function up(db: Database): void { const columns = new Set( - (db.pragma('table_info(`ServerConfigs`)') as { name: string }[]).map((column) => column.name) + (db.pragma('table_info(`ServerConfigs`)') as { name: string }[]).map((column) => column.name), ); if (!columns.has('accessFailureCount')) { - db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `accessFailureCount` INTEGER NOT NULL DEFAULT 0'); + db.exec( + 'ALTER TABLE `ServerConfigs` ADD COLUMN `accessFailureCount` INTEGER NOT NULL DEFAULT 0', + ); } if (!columns.has('firstAccessFailureAt')) { db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `firstAccessFailureAt` DATETIME'); diff --git a/src/db/migrations/004-bot-state.ts b/src/db/migrations/004-bot-state.ts index 3773b4bb..3fa5ac21 100644 --- a/src/db/migrations/004-bot-state.ts +++ b/src/db/migrations/004-bot-state.ts @@ -2,6 +2,6 @@ import type { Database } from 'better-sqlite3'; export function up(db: Database): void { db.exec( - 'CREATE TABLE IF NOT EXISTS `BotState` (`key` TEXT PRIMARY KEY, `value` TEXT NOT NULL, `updatedAt` DATETIME NOT NULL)' + 'CREATE TABLE IF NOT EXISTS `BotState` (`key` TEXT PRIMARY KEY, `value` TEXT NOT NULL, `updatedAt` DATETIME NOT NULL)', ); } diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 70e84d46..3c4dff03 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -22,7 +22,7 @@ export function migrate(db: Database, logger: Logger, list: Migration[] = migrat db.exec('CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) PRIMARY KEY)'); const done = new Set( - (db.prepare('SELECT name FROM `SequelizeMeta`').all() as { name: string }[]).map((r) => r.name) + (db.prepare('SELECT name FROM `SequelizeMeta`').all() as { name: string }[]).map((r) => r.name), ); const record = db.prepare('INSERT INTO `SequelizeMeta` (name) VALUES (?)'); const applied: string[] = []; diff --git a/src/db/schema.ts b/src/db/schema.ts index 3ed7c6f9..ef82bb62 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -57,7 +57,7 @@ export const userProfiles = sqliteTable( .notNull() .$defaultFn(() => new Date()), }, - (table) => [uniqueIndex('user_profiles_server_user_unique').on(table.serverId, table.userId)] + (table) => [uniqueIndex('user_profiles_server_user_unique').on(table.serverId, table.userId)], ); export const botState = sqliteTable('BotState', { diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index b4058a4f..ee355e83 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -18,7 +18,11 @@ export default defineEvent({ return; } - const audit = memberAuditEmbed(member.partial ? user : member, EmbedColours.negative, 'Member left') + const audit = memberAuditEmbed( + member.partial ? user : member, + EmbedColours.negative, + 'Member left', + ) .addField('ID', user.id) .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data'); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index b64c2673..95a6948e 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -36,7 +36,7 @@ export default defineEvent({ try { if (resolved.defer) { await interaction.deferReply( - resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {} + resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {}, ); } const config = await getConfig(app, interaction.guildId); diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 4f920b0e..09d948d6 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,4 +1,4 @@ -import type { GuildMember, Message} from 'discord.js'; +import type { GuildMember, Message } from 'discord.js'; import { PermissionFlagsBits, type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; import type { DingirClient } from '../client/DingirClient.ts'; @@ -22,7 +22,7 @@ const auditHoneypot = async ( member: GuildMember, channelId: Snowflake, description: string, - action: string + action: string, ): Promise => { const audit = memberAuditEmbed(member, EmbedColours.negative, description) .addField('Member ID', member.id) @@ -36,15 +36,14 @@ export const handleHoneypot = async ( app: App, client: DingirClient, message: Message, - config: ServerConfig + config: ServerConfig, ): Promise => { if (!config.honeyPotChannelId || config.honeyPotChannelId !== message.channelId) { return false; } const member = - message.member ?? - (await message.guild.members.fetch(message.author.id).catch(() => null)); + message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { return true; } @@ -57,7 +56,15 @@ export const handleHoneypot = async ( app.honeypot.cancel(message.guild.id, member.id); const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; app.logger.error(`Honey-pot ban failed for ${member.id}.`, undefined, error); - await auditHoneypot(app, client, config, member, message.channelId, 'Honey-pot ban failed', error); + await auditHoneypot( + app, + client, + config, + member, + message.channelId, + 'Honey-pot ban failed', + error, + ); return true; } @@ -70,7 +77,15 @@ export const handleHoneypot = async ( app.honeypot.cancel(message.guild.id, member.id); const errorMessage = formatError(error); app.logger.error(`Honey-pot ban failed for ${member.id}.`, undefined, errorMessage); - await auditHoneypot(app, client, config, member, message.channelId, 'Honey-pot ban failed', errorMessage); + await auditHoneypot( + app, + client, + config, + member, + message.channelId, + 'Honey-pot ban failed', + errorMessage, + ); return true; } @@ -78,7 +93,7 @@ export const handleHoneypot = async ( app.logger.error( `Could not delete profile data for honey-pot ban ${member.id}.`, undefined, - formatError(error) + formatError(error), ); }); await auditHoneypot( @@ -88,7 +103,7 @@ export const handleHoneypot = async ( member, message.channelId, 'Honey-pot triggered', - "Banned and deleted the member's messages from the past 7 days." + "Banned and deleted the member's messages from the past 7 days.", ); return true; }; diff --git a/src/framework/command.ts b/src/framework/command.ts index 76f99240..056a73e0 100644 --- a/src/framework/command.ts +++ b/src/framework/command.ts @@ -6,7 +6,8 @@ import type { Guild, GuildMember, SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder} from 'discord.js'; + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; import { type APIEmbed, type AttachmentPayload, @@ -18,7 +19,7 @@ import { PermissionFlagsBits, type RESTPostAPIChatInputApplicationCommandsJSONBody, SlashCommandBuilder, - type SlashCommandOptionsOnlyBuilder + type SlashCommandOptionsOnlyBuilder, } from 'discord.js'; import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; @@ -83,7 +84,7 @@ export interface Command { } export function buildReplyPayload( - response: string | ReplyOptions + response: string | ReplyOptions, ): Omit & { flags?: MessageFlags.Ephemeral } { const { ephemeral = true, ...rest } = typeof response === 'string' ? { content: response } : response; @@ -137,7 +138,9 @@ export function defineCommand(def: CommandDefinition): Command { throw new Error(`${where} must define either run or subcommands/groups`); } if (!isLeaf && (def.options || def.defer !== undefined)) { - throw new Error(`${where} sets options/defer but is not a leaf command; set them on subcommands`); + throw new Error( + `${where} sets options/defer but is not a leaf command; set them on subcommands`, + ); } assertUnique('subcommand/group', where, [ @@ -153,7 +156,7 @@ export function defineCommand(def: CommandDefinition): Command { assertUnique( 'subcommand', groupWhere, - group.subcommands.map((s) => s.name) + group.subcommands.map((s) => s.name), ); } @@ -217,8 +220,8 @@ export function defineCommand(def: CommandDefinition): Command { routes.get( routeKey( interaction.options.getSubcommandGroup(false), - interaction.options.getSubcommand(false) - ) + interaction.options.getSubcommand(false), + ), ), }; } diff --git a/src/framework/event.ts b/src/framework/event.ts index 5f3bfa21..e55081d3 100644 --- a/src/framework/event.ts +++ b/src/framework/event.ts @@ -1,4 +1,4 @@ -import type { Client} from 'discord.js'; +import type { Client } from 'discord.js'; import { type ClientEvents } from 'discord.js'; import type { App } from '../app.ts'; import type { DingirClient } from '../client/DingirClient.ts'; @@ -9,10 +9,12 @@ export interface EventDefinition { run(app: App, client: DingirClient, ...args: ClientEvents[K]): Promise; } -export type AnyEventDefinition = { [K in keyof ClientEvents]: EventDefinition }[keyof ClientEvents]; +export type AnyEventDefinition = { + [K in keyof ClientEvents]: EventDefinition; +}[keyof ClientEvents]; export function defineEvent( - def: EventDefinition + def: EventDefinition, ): EventDefinition { return def; } @@ -30,11 +32,15 @@ const guildIdOf = (arg: unknown): string | undefined => { export function bindEvent( app: App, client: DingirClient, - def: EventDefinition + def: EventDefinition, ): void { const listener = (...args: ClientEvents[K]): void => { def.run(app, client, ...args).catch((error: unknown) => { - app.logger.error('Event handler failed', { event: def.name, guild: guildIdOf(args[0]) }, error); + app.logger.error( + 'Event handler failed', + { event: def.name, guild: guildIdOf(args[0]) }, + error, + ); }); }; const target: Client = client; diff --git a/src/framework/process.ts b/src/framework/process.ts index 0d9b1ad9..1d0ec62a 100644 --- a/src/framework/process.ts +++ b/src/framework/process.ts @@ -29,7 +29,7 @@ async function runBounded(run: () => Promise, ms: number, logger: Logger): (error: unknown) => { logger.error('Shutdown failed', undefined, error); return true as const; - } + }, ); try { return await Promise.race([finished, timeout]); diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts index d9caef8e..ab4da882 100644 --- a/src/framework/registrar.ts +++ b/src/framework/registrar.ts @@ -25,7 +25,7 @@ const canonical = (value: unknown): unknown => { return Object.fromEntries( Object.entries(value) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, entry]) => [key, canonical(entry)]) + .map(([key, entry]) => [key, canonical(entry)]), ); } return value; @@ -40,7 +40,7 @@ export function hashCommands(commands: readonly Command[]): string { /** Bulk-overwrites the application's commands in the env's scope and returns how many were set. */ export async function putCommands( env: Pick, - commands: readonly Command[] + commands: readonly Command[], ): Promise { const rest = new REST({ version: '10' }).setToken(env.token); const route = env.devGuildId @@ -56,7 +56,7 @@ export type PutCommands = typeof putCommands; export async function syncCommands( app: Pick, commands: readonly Command[], - put: PutCommands = putCommands + put: PutCommands = putCommands, ): Promise<'registered' | 'unchanged' | 'failed'> { const { env, db, logger, clock } = app; const scope = commandScope(env); diff --git a/src/framework/settings.ts b/src/framework/settings.ts index 70069896..25ea4b88 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -27,7 +27,7 @@ export const channelSetText = (label: string, channelId: string): string => export const channelGetText = ( label: string, channelId: string | null, - exists: boolean + exists: boolean, ): string => { if (!channelId) { return `${label}: not set`; @@ -65,7 +65,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi .setName('channel') .setDescription(`Channel to use for: ${label.toLowerCase()}`) .addChannelTypes(...opts.channelTypes) - .setRequired(true) + .setRequired(true), ), run: async (ctx) => { const channel = ctx.interaction.options.getChannel('channel', true); @@ -107,7 +107,10 @@ export function booleanSetting(opts: BooleanSettingOptions): SubcommandGroupDefi description: `Enable or disable: ${label.toLowerCase()}`, options: (b) => b.addBooleanOption((o) => - o.setName('enabled').setDescription(`Whether ${label.toLowerCase()} are on`).setRequired(true) + o + .setName('enabled') + .setDescription(`Whether ${label.toLowerCase()} are on`) + .setRequired(true), ), run: async (ctx) => { const enabled = ctx.interaction.options.getBoolean('enabled', true); diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts index 8187a68a..2b2b357d 100644 --- a/src/services/AuditEmbed.ts +++ b/src/services/AuditEmbed.ts @@ -1,4 +1,4 @@ -import type { GuildMember} from 'discord.js'; +import type { GuildMember } from 'discord.js'; import { type ColorResolvable, EmbedBuilder, User } from 'discord.js'; const LIMITS = { @@ -27,7 +27,7 @@ export class AuditEmbed extends EmbedBuilder { public override setDescription(description: string | null): this { return super.setDescription( - description === null ? null : this.limit(description, LIMITS.description) + description === null ? null : this.limit(description, LIMITS.description), ); } @@ -85,7 +85,7 @@ export class AuditEmbed extends EmbedBuilder { export function memberAuditEmbed( member: GuildMember | User, colour: ColorResolvable, - description: string + description: string, ): AuditEmbed { const user = member instanceof User ? member : member.user; const name = member.displayName || user.tag; diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index e9aeea8a..1f343302 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -7,7 +7,7 @@ export const sendAudit = async ( client: Client, config: ServerConfig, embed: EmbedBuilder, - files?: Attachment[] + files?: Attachment[], ): Promise => { const channelId = config.auditChannelId; if (!channelId) { diff --git a/src/services/BirthdayDates.ts b/src/services/BirthdayDates.ts index 3930472e..4db29aad 100644 --- a/src/services/BirthdayDates.ts +++ b/src/services/BirthdayDates.ts @@ -19,7 +19,7 @@ export const isValidBirthday = (month: number, day: number): boolean => export const celebrationDate = ( month: number, day: number, - year: number + year: number, ): { year: number; month: number; day: number } => { if (month === 2 && day === 29 && !DateTime.utc(year).isInLeapYear) { return { year, month: 2, day: 28 }; @@ -33,7 +33,7 @@ export const nextOccurrence = ( month: number, day: number, now: DateTime, - zone: string + zone: string, ): DateTime => { const start = startOfToday(now, zone); for (let year = start.year; year <= start.year + 8; year += 1) { @@ -49,7 +49,7 @@ export const isBirthdayToday = ( month: number, day: number, now: DateTime, - zone: string + zone: string, ): boolean => { const start = startOfToday(now, zone); const celebration = celebrationDate(month, day, start.year); @@ -61,7 +61,7 @@ export const upcoming = ( profiles: readonly BirthdayProfile[], now: DateTime, zone: string, - limit = 10 + limit = 10, ): UpcomingGroup[] => { const byDate = new Map(); for (const profile of profiles) { diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 665cbbab..3507105b 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,4 +1,4 @@ -import type { Client} from 'discord.js'; +import type { Client } from 'discord.js'; import { type Snowflake } from 'discord.js'; import { DateTime } from 'luxon'; import type { ServerConfig } from '../db/schema.ts'; @@ -20,7 +20,7 @@ const SEPARATOR = '-------------'; const LOCALE = 'en-GB'; export const parseCalendarPath = ( - path: string | null | undefined + path: string | null | undefined, ): { channelId: Snowflake; messageId: Snowflake } | null => { const [channelId, messageId, ...rest] = (path ?? '').split('/'); return channelId && messageId && rest.length === 0 ? { channelId, messageId } : null; @@ -32,7 +32,7 @@ export const calendarMessageUrl = (guildId: Snowflake, path: string): string => export const buildCalendarContent = ( profiles: readonly BirthdayProfile[], now: DateTime, - zone: string + zone: string, ): string => { const groups = upcoming(profiles, now, zone); let content = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; @@ -58,7 +58,7 @@ const nowOf = (app: App): DateTime => DateTime.fromJSDate(app.clock()); export const refreshCalendar = async ( app: App, client: Client, - config: ServerConfig + config: ServerConfig, ): Promise => { const { logger } = app; const guild = config.serverId; @@ -97,7 +97,7 @@ export const refreshCalendar = async ( export const deleteCalendarMessage = async ( client: Client, - path: string | null | undefined + path: string | null | undefined, ): Promise => { const target = parseCalendarPath(path); if (!target) { @@ -137,7 +137,7 @@ const notifyGuild = async ( app: App, client: Client, config: ServerConfig, - now: DateTime + now: DateTime, ): Promise => { const { logger } = app; const zone = app.env.timezone; @@ -162,7 +162,7 @@ const notifyGuild = async ( logger.warn( 'Could not resolve birthday member', { guild: config.serverId, user: profile.userId }, - error + error, ); } } diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 6a9788f1..3a8d8826 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -17,7 +17,7 @@ export type ServerConfigPatch = Partial { const cached = configCache.get(serverId); if (cached) { @@ -39,7 +39,7 @@ export async function getConfigs(db: Db): Promise { export async function updateConfig( { db, configCache }: ConfigDeps, serverId: Snowflake, - patch: ServerConfigPatch + patch: ServerConfigPatch, ): Promise { configCache.delete(serverId); const updated = db.transaction((tx) => { @@ -57,7 +57,7 @@ export async function updateConfig( export async function deleteConfig( { db, configCache }: ConfigDeps, - serverId: Snowflake + serverId: Snowflake, ): Promise { configCache.delete(serverId); const result = db.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); @@ -66,7 +66,7 @@ export async function deleteConfig( export async function resetAccessFailures( deps: ConfigDeps, - config: ServerConfig + config: ServerConfig, ): Promise { if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { return config; @@ -76,7 +76,7 @@ export async function resetAccessFailures( export async function purgeGuild( { db, configCache }: ConfigDeps, - serverId: Snowflake + serverId: Snowflake, ): Promise<{ config: boolean; profiles: number }> { configCache.delete(serverId); return db.transaction((tx) => { diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index a9fc032a..28d9a226 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,4 +1,4 @@ -import type { Client} from 'discord.js'; +import type { Client } from 'discord.js'; import { type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; import { getConfigs, purgeGuild, updateConfig } from './ConfigService.ts'; @@ -31,7 +31,7 @@ export const runDataCheck = async ( policy: RetentionPolicyConfig = { minFailures: app.env.purgeMinFailures, graceDays: app.env.purgeGraceDays, - } + }, ): Promise => { const { logger } = app; const now = app.clock(); @@ -55,7 +55,7 @@ export const runDataCheck = async ( }, outcome, now, - policy + policy, ); if (next.purge) { diff --git a/src/services/MemberListing.ts b/src/services/MemberListing.ts index 3308090d..8ffd0f94 100644 --- a/src/services/MemberListing.ts +++ b/src/services/MemberListing.ts @@ -33,7 +33,7 @@ export const wholeDaysSinceJoin = (member: GuildMember, now: number): number | n export const buildMemberListing = ( header: string, emptyMessage: string, - members: GuildMember[] + members: GuildMember[], ): ReplyOptions => { if (members.length === 0) { return { content: emptyMessage, allowedMentions: { parse: [] } }; diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts index fe74f640..a41bba7d 100644 --- a/src/services/MemberResolver.ts +++ b/src/services/MemberResolver.ts @@ -9,7 +9,7 @@ const errorCode = (error: unknown): unknown => export const resolveMember = async ( guild: Guild, - userOrId: User | Snowflake + userOrId: User | Snowflake, ): Promise => { const id = typeof userOrId === 'string' ? userOrId : userOrId.id; const cached = guild.members.cache.get(id); @@ -29,9 +29,10 @@ export const resolveMember = async ( export const resolveTextChannel = async ( guild: Guild, - id: Snowflake + id: Snowflake, ): Promise => { - const channel = guild.channels.cache.get(id) ?? (await guild.channels.fetch(id).catch(() => null)); + const channel = + guild.channels.cache.get(id) ?? (await guild.channels.fetch(id).catch(() => null)); if (!channel || !channel.isTextBased() || channel.isDMBased() || !channel.isSendable()) { return null; } diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index ec644429..d9040562 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,13 +1,5 @@ -import type { - AttachmentBuilder, - Client, - Guild, - GuildMember, - Role} from 'discord.js'; -import { - type PartialGuildMember, - type Snowflake, -} from 'discord.js'; +import type { AttachmentBuilder, Client, Guild, GuildMember, Role } from 'discord.js'; +import { type PartialGuildMember, type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; import type { ServerConfig } from '../db/schema.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; @@ -68,7 +60,7 @@ export const roleProblem = (guild: Guild, role: Role): string | null => { return 'bot member unavailable'; } if (role.position >= me.roles.highest.position) { - return 'at or above the bot\'s highest role'; + return "at or above the bot's highest role"; } return null; }; @@ -82,7 +74,7 @@ export const parseRoleIds = (raw: string | null | undefined): Snowflake[] => /** True only when a member is known to have just passed membership screening. */ export const completedScreening = ( oldMember: GuildMember | PartialGuildMember, - newMember: GuildMember | PartialGuildMember + newMember: GuildMember | PartialGuildMember, ): boolean => oldMember.pending === true && newMember.pending === false; export const formatOnboardingSummary = (result: OnboardingResult): string => { @@ -92,7 +84,9 @@ export const formatOnboardingSummary = (result: OnboardingResult): string => { `Welcome message: ${result.welcome}`, ]; if (result.rolesAdded.length > 0) { - lines.push(`${result.dryRun ? 'Would add' : 'Added'}: ${result.rolesAdded.map((id) => `<@&${id}>`).join(', ')}`); + lines.push( + `${result.dryRun ? 'Would add' : 'Added'}: ${result.rolesAdded.map((id) => `<@&${id}>`).join(', ')}`, + ); } for (const skip of result.rolesSkipped) { lines.push(`Skipped role ${skip.id}: ${skip.reason}`); @@ -104,13 +98,13 @@ export const auditJoin = ( app: App, client: Client, member: GuildMember, - config: ServerConfig + config: ServerConfig, ): Promise => sendAudit( app, client, config, - memberAuditEmbed(member, EmbedColours.positive, 'New member joined').addField('ID', member.id) + memberAuditEmbed(member, EmbedColours.positive, 'New member joined').addField('ID', member.id), ); const auditFailure = ( @@ -119,7 +113,7 @@ const auditFailure = ( member: GuildMember, config: ServerConfig, description: string, - detail: string + detail: string, ): Promise => sendAudit( app, @@ -127,14 +121,14 @@ const auditFailure = ( config, memberAuditEmbed(member, EmbedColours.negative, description) .addField('ID', member.id) - .addField('Reason', detail) + .addField('Reason', detail), ); async function runStep( app: App, name: string, member: GuildMember, - step: () => Promise + step: () => Promise, ): Promise { try { return await step(); @@ -142,7 +136,7 @@ async function runStep( app.logger.error( `Onboarding step failed: ${name}`, { guild: member.guild.id, member: member.id }, - error + error, ); return `failed:${messageOf(error)}`; } @@ -154,7 +148,7 @@ async function addGuestRoles( member: GuildMember, config: ServerConfig, dryRun: boolean, - result: OnboardingResult + result: OnboardingResult, ): Promise { const ids = parseRoleIds(config.guestRoleIds); if (ids.length === 0) { @@ -192,7 +186,7 @@ async function addGuestRoles( member, config, 'Unable to provide guest role(s) to member.', - messageOf(error) + messageOf(error), ); throw error; } @@ -206,7 +200,7 @@ async function sendWelcome( config: ServerConfig, dryRun: boolean, result: OnboardingResult, - renderImage: WelcomeImageRenderer + renderImage: WelcomeImageRenderer, ): Promise { const text = config.welcomeMessage ?? ''; const url = config.welcomeMessageBackgroundUrl; @@ -238,7 +232,7 @@ async function sendWelcome( member, config, 'Unable to generate welcome image.', - payload.imageError + payload.imageError, ); } } @@ -264,7 +258,7 @@ async function sendWelcome( member, config, 'Unable to send welcome message.', - messageOf(error) + messageOf(error), ); throw error; } @@ -277,7 +271,7 @@ export async function complete( member: GuildMember, config: ServerConfig, options: OnboardingOptions = {}, - deps: OnboardingDeps = {} + deps: OnboardingDeps = {}, ): Promise { const dryRun = options.dryRun ?? false; const renderImage = deps.renderImage ?? WelcomeImage.render; @@ -299,18 +293,18 @@ export async function complete( config, memberAuditEmbed(member, EmbedColours.neutral, 'Member completed onboarding').addField( 'ID', - member.id - ) + member.id, + ), ); return sent ? 'done' : 'skipped:no usable audit channel'; }); } result.roles = await runStep(app, 'roles', member, () => - addGuestRoles(app, client, member, config, dryRun, result) + addGuestRoles(app, client, member, config, dryRun, result), ); result.welcome = await runStep(app, 'welcome', member, () => - sendWelcome(app, client, member, config, dryRun, result, renderImage) + sendWelcome(app, client, member, config, dryRun, result, renderImage), ); if (config.debug && !dryRun) { @@ -321,7 +315,7 @@ export async function complete( config, memberAuditEmbed(member, EmbedColours.info, 'Onboarding diagnostics') .addField('ID', member.id) - .addField('Steps', formatOnboardingSummary(result)) + .addField('Steps', formatOnboardingSummary(result)), ); return sent ? 'done' : 'skipped:no usable audit channel'; }); diff --git a/src/services/RetentionPolicy.ts b/src/services/RetentionPolicy.ts index 7195242d..a4b7fcf0 100644 --- a/src/services/RetentionPolicy.ts +++ b/src/services/RetentionPolicy.ts @@ -23,7 +23,7 @@ export const nextAccessState = ( state: AccessState, outcome: AccessOutcome, now: Date, - policy: RetentionPolicyConfig + policy: RetentionPolicyConfig, ): AccessState & { purge: boolean } => { if (outcome === 'ok') { return { accessFailureCount: 0, firstAccessFailureAt: null, purge: false }; diff --git a/src/services/Scheduler.ts b/src/services/Scheduler.ts index e46e9aee..2dbb4916 100644 --- a/src/services/Scheduler.ts +++ b/src/services/Scheduler.ts @@ -13,7 +13,7 @@ export interface ScheduledJobLike { export type ScheduleFn = ( spec: { rule: string; tz: string }, - callback: () => void + callback: () => void, ) => ScheduledJobLike | null; const defaultScheduleFn: ScheduleFn = (spec, callback) => { @@ -39,7 +39,7 @@ export class Scheduler { cron: string, timezone: string, tasks: SchedulerTask[], - scheduleFn: ScheduleFn = defaultScheduleFn + scheduleFn: ScheduleFn = defaultScheduleFn, ) { this.logger = logger; this.cron = cron; diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 717fbf12..c73542b0 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -14,8 +14,8 @@ export async function getServerBirthdays(db: Db, serverId: Snowflake): Promise { db.insert(userProfiles) .values({ serverId, userId, birthdayMonth: month, birthdayDay: day }) @@ -43,7 +43,7 @@ export async function setBirthday( export async function clearBirthday( db: Db, serverId: Snowflake, - userId: Snowflake + userId: Snowflake, ): Promise { const result = db .update(userProfiles) @@ -56,14 +56,17 @@ export async function clearBirthday( export async function deleteUsers( db: Db, serverId: Snowflake, - userIds: Snowflake[] + userIds: Snowflake[], ): Promise { let removed = 0; for (let i = 0; i < userIds.length; i += 500) { removed += db .delete(userProfiles) .where( - and(eq(userProfiles.serverId, serverId), inArray(userProfiles.userId, userIds.slice(i, i + 500))) + and( + eq(userProfiles.serverId, serverId), + inArray(userProfiles.userId, userIds.slice(i, i + 500)), + ), ) .run().changes; } @@ -73,7 +76,7 @@ export async function deleteUsers( export async function getUserProfile( db: Db, serverId: Snowflake, - userId: Snowflake + userId: Snowflake, ): Promise { db.insert(userProfiles).values({ serverId, userId }).onConflictDoNothing().run(); return db.select().from(userProfiles).where(forUser(serverId, userId)).get()!; @@ -82,7 +85,7 @@ export async function getUserProfile( export async function findUserProfile( db: Db, serverId: Snowflake, - userId: Snowflake + userId: Snowflake, ): Promise { return db.select().from(userProfiles).where(forUser(serverId, userId)).get() ?? null; } @@ -90,7 +93,7 @@ export async function findUserProfile( export async function incrementActivityScore( db: Db, serverId: Snowflake, - userId: Snowflake + userId: Snowflake, ): Promise { db.insert(userProfiles) .values({ serverId, userId, activityScore: 1 }) diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts index 716ae54b..e19add3a 100644 --- a/src/services/WelcomeImage.ts +++ b/src/services/WelcomeImage.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import type { GuildMember } from 'discord.js'; import { AttachmentBuilder } from 'discord.js'; import { DateTime } from 'luxon'; -import type { Canvas} from '@napi-rs/canvas'; +import type { Canvas } from '@napi-rs/canvas'; import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; import type { App } from '../app.ts'; @@ -49,13 +49,16 @@ const downloadImage = async (url: string): Promise => { response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); } catch (error) { if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { - throw new Error(`Downloading the image timed out after ${DOWNLOAD_TIMEOUT_MS / 1000} seconds`, { - cause: error, - }); + throw new Error( + `Downloading the image timed out after ${DOWNLOAD_TIMEOUT_MS / 1000} seconds`, + { + cause: error, + }, + ); } throw new Error( `Could not download the image: ${error instanceof Error ? error.message : String(error)}`, - { cause: error } + { cause: error }, ); } if (!response.ok) { @@ -71,7 +74,7 @@ const downloadImage = async (url: string): Promise => { export type WelcomeImageRenderer = ( app: App, member: GuildMember, - backgroundUrl: string + backgroundUrl: string, ) => Promise; export const render: WelcomeImageRenderer = async (app, member, backgroundUrl) => { @@ -90,9 +93,9 @@ export const render: WelcomeImageRenderer = async (app, member, backgroundUrl) = const canvas = createCanvas(700, 250); const ctx = canvas.getContext('2d'); - const joined = DateTime.fromMillis(member.joinedTimestamp, { zone: app.env.timezone }).toLocaleString( - DateTime.DATE_FULL - ); + const joined = DateTime.fromMillis(member.joinedTimestamp, { + zone: app.env.timezone, + }).toLocaleString(DateTime.DATE_FULL); const name = member.displayName; const welcome = `Welcome to ${member.guild.name}!`; const joinedText = `Joined: ${joined}`; diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index 9d4a0e14..f10a597c 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -31,7 +31,7 @@ export const createConsoleLogger = ({ }: LoggerOptions = {}): Logger => { const base = pino( { level, timestamp: pino.stdTimeFunctions.isoTime, base: undefined }, - destination ?? pino.destination(1) + destination ?? pino.destination(1), ); const at = (method: keyof Logger): Logger[keyof Logger] => diff --git a/tests/about.test.ts b/tests/about.test.ts index c2d995c6..51505bdb 100644 --- a/tests/about.test.ts +++ b/tests/about.test.ts @@ -6,28 +6,28 @@ test('toRepoUrl', async (t) => { await t.test('removes git+ prefix', () => { assert.equal( toRepoUrl('git+https://github.com/BrentOates/dingir.git'), - 'https://github.com/BrentOates/dingir' + 'https://github.com/BrentOates/dingir', ); }); await t.test('removes .git suffix', () => { assert.equal( toRepoUrl('https://github.com/BrentOates/dingir.git'), - 'https://github.com/BrentOates/dingir' + 'https://github.com/BrentOates/dingir', ); }); await t.test('leaves URL unchanged if no prefix or suffix', () => { assert.equal( toRepoUrl('https://github.com/BrentOates/dingir'), - 'https://github.com/BrentOates/dingir' + 'https://github.com/BrentOates/dingir', ); }); await t.test('removes both git+ prefix and .git suffix', () => { assert.equal( toRepoUrl('git+https://github.com/BrentOates/dingir.git'), - 'https://github.com/BrentOates/dingir' + 'https://github.com/BrentOates/dingir', ); }); }); @@ -54,7 +54,7 @@ test('formatUptime', async (t) => { }); await t.test('returns "3d 4h 12m" for 3 days 4 hours 12 minutes', () => { - const ms = (3 * 24 * 60 * 60 * 1000) + (4 * 60 * 60 * 1000) + (12 * 60 * 1000); + const ms = 3 * 24 * 60 * 60 * 1000 + 4 * 60 * 60 * 1000 + 12 * 60 * 1000; assert.equal(formatUptime(ms), '3d 4h 12m'); }); }); diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index 5ae58820..ef32ff4c 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -101,7 +101,11 @@ test('notifyBirthdays: skips departed members and mentions only present ones', a { serverId: 'g1', userId: 'other', birthdayMonth: 6, birthdayDay: 2 }, ]); const channel = fakeTextChannel('ann'); - const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here', 'other'], channels: [channel] }); + const guild = fakeGuildWithMembers({ + id: 'g1', + memberIds: ['here', 'other'], + channels: [channel], + }); const client = fakeClient({ guilds: { g1: guild } }); await notifyBirthdays(appAt('2027-06-01T09:00'), client); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 0ef8c82c..b84bef93 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -26,7 +26,7 @@ beforeEach(() => { const exec = async ( command: Command, - opts: { subcommand?: string; options?: Record; guild?: object } + opts: { subcommand?: string; options?: Record; guild?: object }, ) => { const config = createConfig({ serverId: 'guild-1' }); const { ctx, replies, interaction } = fakeCommandContext(app, opts.options, { @@ -52,7 +52,7 @@ test('mybirthday set stores Feb 29 as 29', async () => { test('mybirthday set rejects invalid dates', async () => { await rejectsUserError( exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }), - /invalid/ + /invalid/, ); assert.equal(countProfiles(), 0); }); @@ -72,7 +72,7 @@ test('profile replies for a non-member without creating a profile', async () => const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: [] }); await rejectsUserError( exec(profile, { options: { member: { id: 'ghost' } }, guild }), - "That user isn't a member of this server." + "That user isn't a member of this server.", ); assert.equal(countProfiles(), 0); }); @@ -89,7 +89,7 @@ test('profile for a member without a profile does not create one', async () => { displayAvatarURL: () => 'https://example.com/a.png', }); const replies = await exec(profile, { options: { member: { id: 'm1' } }, guild }); - const embed = (nth(last(replies).embeds as EmbedBuilder[] | undefined)).toJSON(); + const embed = nth(last(replies).embeds as EmbedBuilder[] | undefined).toJSON(); const field = (name: string): string => embed.fields?.find((f) => f.name === name)?.value ?? ''; assert.equal(field('Onboarding'), 'Not completed'); assert.equal(field('Activity Score'), '0'); diff --git a/tests/command.test.ts b/tests/command.test.ts index 98422d44..7a5bff99 100644 --- a/tests/command.test.ts +++ b/tests/command.test.ts @@ -26,7 +26,8 @@ test('defineCommand applies leaf options', () => { const json = defineCommand({ name: 'say', description: 'Says', - options: (b) => b.addStringOption((o) => o.setName('text').setDescription('Text').setRequired(true)), + options: (b) => + b.addStringOption((o) => o.setName('text').setDescription('Text').setRequired(true)), run: noop, }).toJSON(); assert.equal(nth(json.options).name, 'text'); @@ -35,7 +36,7 @@ test('defineCommand applies leaf options', () => { test('defineCommand rejects invalid shapes with descriptive errors', () => { assert.throws( () => defineCommand({ name: 'a', description: 'd' }), - /command "a" must define either run or subcommands/ + /command "a" must define either run or subcommands/, ); assert.throws( () => @@ -45,7 +46,7 @@ test('defineCommand rejects invalid shapes with descriptive errors', () => { run: noop, subcommands: [{ name: 's', description: 'd', run: noop }], }), - /cannot define both run and subcommands/ + /cannot define both run and subcommands/, ); assert.throws( () => @@ -55,7 +56,7 @@ test('defineCommand rejects invalid shapes with descriptive errors', () => { defer: 'ephemeral', subcommands: [{ name: 's', description: 'd', run: noop }], }), - /not a leaf command/ + /not a leaf command/, ); assert.throws( () => @@ -67,7 +68,7 @@ test('defineCommand rejects invalid shapes with descriptive errors', () => { { name: 's', description: 'd', run: noop }, ], }), - /Duplicate subcommand\/group name "s" in command "a"/ + /Duplicate subcommand\/group name "s" in command "a"/, ); assert.throws( () => @@ -83,7 +84,7 @@ test('defineCommand rejects invalid shapes with descriptive errors', () => { }, ], }), - /Duplicate subcommand\/group name "x"/ + /Duplicate subcommand\/group name "x"/, ); assert.throws( () => @@ -101,15 +102,20 @@ test('defineCommand rejects invalid shapes with descriptive errors', () => { }, ], }), - /Duplicate subcommand name "s" in group "g"/ + /Duplicate subcommand name "s" in group "g"/, ); assert.throws( - () => defineCommand({ name: 'a', description: 'd', groups: [{ name: 'g', description: 'd', subcommands: [] }] }), - /group "g" in command "a" must define at least one subcommand/ + () => + defineCommand({ + name: 'a', + description: 'd', + groups: [{ name: 'g', description: 'd', subcommands: [] }], + }), + /group "g" in command "a" must define at least one subcommand/, ); assert.throws( () => defineCommand({ name: 'Bad Name', description: 'd', run: noop }), - /Invalid command "Bad Name"/ + /Invalid command "Bad Name"/, ); }); @@ -119,7 +125,12 @@ test('resolve routes leaf, subcommand and grouped subcommand handlers', () => { ran.push(id); }; - const leaf = defineCommand({ name: 'leaf', description: 'd', defer: 'public', run: handler('leaf') }); + const leaf = defineCommand({ + name: 'leaf', + description: 'd', + defer: 'public', + run: handler('leaf'), + }); const resolvedLeaf = leaf.resolve(fakeInteraction({ commandName: 'leaf' }).interaction)!; assert.equal(resolvedLeaf.defer, 'public'); assert.equal(resolvedLeaf.path, 'leaf'); @@ -145,10 +156,15 @@ test('resolve routes leaf, subcommand and grouped subcommand handlers', () => { assert.equal(tree.resolve(fakeInteraction({ subcommand: 'missing' }).interaction), undefined); assert.equal(tree.resolve(fakeInteraction({}).interaction), undefined); - assert.equal(tree.resolve(fakeInteraction({ group: 'grp', subcommand: 'plain' }).interaction), undefined); + assert.equal( + tree.resolve(fakeInteraction({ group: 'grp', subcommand: 'plain' }).interaction), + undefined, + ); assert.equal(leaf.resolve(fakeInteraction({ subcommand: 'x' }).interaction), undefined); - return Promise.all([resolvedLeaf.run, plain.run, grouped.run].map((run) => run({} as CommandContext))).then(() => { + return Promise.all( + [resolvedLeaf.run, plain.run, grouped.run].map((run) => run({} as CommandContext)), + ).then(() => { assert.deepEqual(ran, ['leaf', 'tree plain', 'grp go']); }); }); @@ -174,7 +190,7 @@ test('reply uses editReply() after deferring, then followUp() afterwards', async await reply('second'); assert.deepEqual( calls.map((c) => c.method), - ['editReply', 'followUp'] + ['editReply', 'followUp'], ); assert.equal(nth(calls).payload.flags, undefined); assert.equal(nth(calls, 1).payload.flags, MessageFlags.Ephemeral); diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index d785efad..0953f469 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -10,7 +10,12 @@ const { logger, logs } = fakeLogger(); type Db = Database.Database; const INDEX = 'user_profiles_server_user_unique'; -const ALL = ['001-baseline', '002-userprofile-unique', '003-serverconfig-access-tracking', '004-bot-state']; +const ALL = [ + '001-baseline', + '002-userprofile-unique', + '003-serverconfig-access-tracking', + '004-bot-state', +]; const LEGACY_SERVER_CONFIGS = "CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `prefix` VARCHAR(255) DEFAULT '^', `rulesMessagePath` VARCHAR(255), `rulesMessage` VARCHAR(255), `guestRoleIds` VARCHAR(255), `adminRoleId` VARCHAR(255), `welcomeMessage` VARCHAR(255), `debug` TINYINT(1) DEFAULT 0, `auditChannelId` VARCHAR(255), `welcomeMessageBackgroundUrl` VARCHAR(255), `systemMessagesEnabled` TINYINT(1) DEFAULT 0, `announcementsChannelId` VARCHAR(255), `birthdayCalendarMessagePath` VARCHAR(255), `honeyPotChannelId` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)"; @@ -38,12 +43,12 @@ const profileRow = ( month: number | null, day: number | null, score: number, - updatedAt: string + updatedAt: string, ) => ({ serverId, userId, month, day, score, updatedAt }); const insertProfiles = (db: Db, rows: ReturnType[]): void => { const insert = db.prepare( - 'INSERT INTO `UserProfiles` (serverId, userId, birthdayMonth, birthdayDay, activityScore, createdAt, updatedAt) VALUES (@serverId, @userId, @month, @day, @score, @updatedAt, @updatedAt)' + 'INSERT INTO `UserProfiles` (serverId, userId, birthdayMonth, birthdayDay, activityScore, createdAt, updatedAt) VALUES (@serverId, @userId, @month, @day, @score, @updatedAt, @updatedAt)', ); for (const r of rows) { insert.run(r); @@ -87,10 +92,12 @@ test('migrations on an empty database create the expected schema', () => { assert.ok(indexesOf(db, 'UserProfiles').find((i) => i.name === INDEX)?.unique); - const meta = allRows(db, 'SELECT name FROM `SequelizeMeta` ORDER BY name') as { name: string }[]; + const meta = allRows(db, 'SELECT name FROM `SequelizeMeta` ORDER BY name') as { + name: string; + }[]; assert.deepEqual( meta.map((m) => m.name), - ALL + ALL, ); }); }); @@ -109,7 +116,7 @@ test('baseline is a no-op on a legacy sync() database and later migrations prese db.exec(LEGACY_SERVER_CONFIGS); db.exec(LEGACY_USER_PROFILES); db.exec( - "INSERT INTO `ServerConfigs` (serverId, prefix, adminRoleId, auditChannelId, createdAt, updatedAt) VALUES ('s1', '!', 'admin', 'chan', '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')" + "INSERT INTO `ServerConfigs` (serverId, prefix, adminRoleId, auditChannelId, createdAt, updatedAt) VALUES ('s1', '!', 'admin', 'chan', '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')", ); insertProfiles(db, [ profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00'), @@ -123,10 +130,9 @@ test('baseline is a no-op on a legacy sync() database and later migrations prese assert.deepEqual(migrate(db, logger), ALL.slice(1)); - const config = db.prepare('SELECT * FROM `ServerConfigs` WHERE serverId = ?').get('s1') as Record< - string, - unknown - >; + const config = db + .prepare('SELECT * FROM `ServerConfigs` WHERE serverId = ?') + .get('s1') as Record; assert.equal(config.auditChannelId, 'chan'); assert.equal(config.accessFailureCount, 0); assert.equal(config.firstAccessFailureAt, null); @@ -136,7 +142,7 @@ test('baseline is a no-op on a legacy sync() database and later migrations prese const profiles = allRows( db, - 'SELECT userId, birthdayMonth, activityScore FROM `UserProfiles` ORDER BY id' + 'SELECT userId, birthdayMonth, activityScore FROM `UserProfiles` ORDER BY id', ); assert.deepEqual(profiles, [ { userId: 'u1', birthdayMonth: 5, activityScore: 3 }, @@ -150,7 +156,7 @@ test('a database already migrated by the previous umzug setup runs nothing and k migrate(db, logger); insertProfiles(db, [profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00')]); db.exec( - "INSERT INTO `ServerConfigs` (serverId, accessFailureCount, createdAt, updatedAt) VALUES ('s1', 2, '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')" + "INSERT INTO `ServerConfigs` (serverId, accessFailureCount, createdAt, updatedAt) VALUES ('s1', 2, '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')", ); const ran: string[] = []; @@ -175,7 +181,7 @@ test('a database already migrated by the previous umzug setup runs nothing and k test('baseline adds missing nullable columns to an older schema', () => { withDb((db) => { db.exec( - 'CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)' + 'CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)', ); migrate(db, logger); assert.ok(columnsOf(db, 'ServerConfigs').includes('honeyPotChannelId')); @@ -210,7 +216,7 @@ test('002 dedupes UserProfiles and enforces uniqueness afterwards', () => { const rows = db .prepare( - 'SELECT serverId, userId, birthdayMonth, birthdayDay, activityScore FROM `UserProfiles` ORDER BY serverId, userId' + 'SELECT serverId, userId, birthdayMonth, birthdayDay, activityScore FROM `UserProfiles` ORDER BY serverId, userId', ) .raw() .all(); @@ -223,7 +229,7 @@ test('002 dedupes UserProfiles and enforces uniqueness afterwards', () => { ]); assert.throws(() => - insertProfiles(db, [profileRow('s1', 'a', null, null, 0, '2024-05-01 00:00:00.000 +00:00')]) + insertProfiles(db, [profileRow('s1', 'a', null, null, 0, '2024-05-01 00:00:00.000 +00:00')]), ); }); }); diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts index ac95f43c..6b0d35e0 100644 --- a/tests/database/purgeGuild.test.ts +++ b/tests/database/purgeGuild.test.ts @@ -16,7 +16,9 @@ beforeEach(() => { app.configCache.clear(); db.delete(userProfiles).run(); db.delete(serverConfigs).run(); - db.insert(serverConfigs).values([{ serverId: 's1' }, { serverId: 's2' }]).run(); + db.insert(serverConfigs) + .values([{ serverId: 's1' }, { serverId: 's2' }]) + .run(); db.insert(userProfiles) .values([ { serverId: 's1', userId: 'a' }, @@ -36,7 +38,10 @@ const configCount = (): number => db.select({ n: count() }).from(serverConfigs). test('purgeGuild deletes only the target server and reports counts', async () => { assert.deepEqual(await purgeGuild(app, 's1'), { config: true, profiles: 2 }); - assert.equal(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), undefined); + assert.equal( + db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), + undefined, + ); assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's2')).get()); assert.equal(profileCount('s1'), 0); assert.equal(profileCount('s2'), 1); @@ -50,7 +55,7 @@ test('purgeGuild on an unknown server deletes nothing', async () => { test('purgeGuild rolls back the profile delete when the config delete fails', async () => { db.$client.exec( - "CREATE TRIGGER block_config_delete BEFORE DELETE ON `ServerConfigs` BEGIN SELECT RAISE(ABORT, 'boom'); END" + "CREATE TRIGGER block_config_delete BEFORE DELETE ON `ServerConfigs` BEGIN SELECT RAISE(ABORT, 'boom'); END", ); try { await assert.rejects(purgeGuild(app, 's1'), /boom/); diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts index 952aaa0a..3ddf68f4 100644 --- a/tests/database/schema.test.ts +++ b/tests/database/schema.test.ts @@ -38,11 +38,16 @@ test('migrated database applies column defaults and round-trips dates', () => { const when = new Date('2024-01-01T12:00:00.000Z'); db.update(serverConfigs).set({ firstAccessFailureAt: when }).run(); - const raw = app.db.$client.prepare('SELECT firstAccessFailureAt AS v FROM `ServerConfigs`').get() as { + const raw = app.db.$client + .prepare('SELECT firstAccessFailureAt AS v FROM `ServerConfigs`') + .get() as { v: string; }; assert.equal(raw.v, '2024-01-01 12:00:00.000 +00:00'); - assert.equal(db.select().from(serverConfigs).get()!.firstAccessFailureAt?.getTime(), when.getTime()); + assert.equal( + db.select().from(serverConfigs).get()!.firstAccessFailureAt?.getTime(), + when.getTime(), + ); db.insert(userProfiles).values({ serverId: 's1', userId: 'u1' }).run(); assert.equal(db.select().from(userProfiles).get()!.activityScore, 0); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index e141c16e..758a631c 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -91,7 +91,10 @@ test('updateConfig refreshes the cached config', async () => { }); test('resetAccessFailures updates the cache', async () => { - const config = await updateConfig(app, 's1', { accessFailureCount: 2, firstAccessFailureAt: new Date() }); + const config = await updateConfig(app, 's1', { + accessFailureCount: 2, + firstAccessFailureAt: new Date(), + }); const reset = await resetAccessFailures(app, config); assert.equal(reset.accessFailureCount, 0); assert.equal((await getConfig(app, 's1')).accessFailureCount, 0); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index da7ede07..e9e518a6 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -11,8 +11,16 @@ const DAY = 24 * 60 * 60 * 1000; const t0 = new Date('2027-01-01T00:00:00Z'); const base = createTestApp(); -const { allProfiles, clearConfigs, clearProfiles, countProfiles, createConfig, createConfigs, createProfiles, findConfig } = - dbFixtures(base); +const { + allProfiles, + clearConfigs, + clearProfiles, + countProfiles, + createConfig, + createConfigs, + createProfiles, + findConfig, +} = dbFixtures(base); const run = (client: ReturnType, now: Date, p = policy): Promise => runDataCheck(createTestApp({ db: base.db, clock: () => now }), client, p); @@ -57,7 +65,7 @@ test('gone guild is purged only after min failures and grace period', async () = test('guild missing from the client entirely counts as gone', async () => { createConfig({ serverId: 'g1' }); await run(fakeClient(), t0, policy); - assert.equal((reload('g1'))?.accessFailureCount, 1); + assert.equal(reload('g1')?.accessFailureCount, 1); }); test('a successful fetch resets counters', async () => { @@ -79,7 +87,9 @@ test('profiles of departed members are deleted, including non-birthday ones', as ]); const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here'] }); await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - const remaining = (allProfiles()).map((p) => `${p.serverId}/${p.userId}`).sort(); + const remaining = allProfiles() + .map((p) => `${p.serverId}/${p.userId}`) + .sort(); assert.deepEqual(remaining, ['g1/here', 'other/gone-plain']); }); @@ -104,6 +114,6 @@ test('one guild throwing does not stop the others', async () => { }); const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0, policy); - const remaining = (allProfiles()).map((p) => p.serverId); + const remaining = allProfiles().map((p) => p.serverId); assert.deepEqual(remaining, ['g1']); }); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts index addc3da5..7c060de5 100644 --- a/tests/dispatcher.test.ts +++ b/tests/dispatcher.test.ts @@ -84,7 +84,7 @@ test('runs the handler with context and applies the defer mode', async () => { assert.deepEqual( calls.map((c) => c.method), - ['deferReply', 'editReply'] + ['deferReply', 'editReply'], ); assert.deepEqual(nth(calls).payload, { flags: MessageFlags.Ephemeral }); assert.equal(seen?.config.serverId, 'guild-9'); @@ -92,7 +92,12 @@ test('runs the handler with context and applies the defer mode', async () => { }); test('public defer does not set the ephemeral flag', async () => { - const cmd = defineCommand({ name: 'test', description: 'd', defer: 'public', run: async () => {} }); + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'public', + run: async () => {}, + }); const { interaction, calls } = fakeInteraction(); await dispatch(clientWith(cmd), interaction); assert.deepEqual(calls, [{ method: 'deferReply', payload: {} }]); @@ -112,7 +117,10 @@ test('a throwing handler produces exactly one ephemeral error reply', async () = assert.deepEqual(calls, [ { method: 'reply', - payload: { content: 'Something went wrong running this command.', flags: MessageFlags.Ephemeral }, + payload: { + content: 'Something went wrong running this command.', + flags: MessageFlags.Ephemeral, + }, }, ]); const failure = app.logsAt('error').find((entry) => entry.message === 'Command failed'); @@ -134,7 +142,7 @@ test('a handler that throws after replying results in a single follow-up error m assert.deepEqual( calls.map((c) => c.method), - ['deferReply', 'editReply', 'followUp'] + ['deferReply', 'editReply', 'followUp'], ); }); @@ -153,7 +161,9 @@ test('a failing error reply is logged and does not throw', async () => { }, }); await dispatch(clientWith(cmd), interaction); - assert.ok(app.logsAt('error').some((entry) => entry.message === 'Could not send command error reply')); + assert.ok( + app.logsAt('error').some((entry) => entry.message === 'Could not send command error reply'), + ); }); test('a UserError replies ephemerally with its message and is not logged as an error', async () => { @@ -169,12 +179,17 @@ test('a UserError replies ephemerally with its message and is not logged as an e await dispatch(clientWith(cmd), interaction); assert.deepEqual(calls, [ - { method: 'reply', payload: { content: 'That is not allowed.', flags: MessageFlags.Ephemeral } }, + { + method: 'reply', + payload: { content: 'That is not allowed.', flags: MessageFlags.Ephemeral }, + }, ]); const entries = app.logs.slice(before); assert.equal(entries.filter((entry) => entry.level === 'error').length, 0); assert.ok( - entries.some((entry) => entry.level === 'info' && entry.context?.reason === 'That is not allowed.') + entries.some( + (entry) => entry.level === 'info' && entry.context?.reason === 'That is not allowed.', + ), ); }); @@ -192,7 +207,7 @@ test('a UserError thrown after deferring edits the deferred reply', async () => assert.deepEqual( calls.map((c) => c.method), - ['deferReply', 'editReply'] + ['deferReply', 'editReply'], ); assert.equal(nth(calls, 1).payload.content, 'Nope.'); }); diff --git a/tests/env.test.ts b/tests/env.test.ts index 6809c1a2..c83690df 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -66,7 +66,7 @@ test('reports every problem in a single error', () => { assert.match(err.message, new RegExp(expected)); } return true; - } + }, ); }); diff --git a/tests/fakes/command.ts b/tests/fakes/command.ts index 6a3e191c..05bc157a 100644 --- a/tests/fakes/command.ts +++ b/tests/fakes/command.ts @@ -17,7 +17,7 @@ export interface FakeCommandParts { export function fakeCommandContext( app: App, options: Record = {}, - parts: FakeCommandParts = {} + parts: FakeCommandParts = {}, ) { const { interaction } = fakeInteraction({ options, subcommand: parts.subcommand }); if (parts.client) { diff --git a/tests/fakes/discord.ts b/tests/fakes/discord.ts index 5e33662c..f3cf38df 100644 --- a/tests/fakes/discord.ts +++ b/tests/fakes/discord.ts @@ -64,7 +64,9 @@ export function fakeGuild(overrides: FakeGuildOverrides = {}): Guild { }); } -export function fakeConfig(overrides: Partial> = {}): ServerConfig { +export function fakeConfig( + overrides: Partial> = {}, +): ServerConfig { return stub({ serverId: 'guild-1', guestRoleIds: null, @@ -116,7 +118,11 @@ export function fakeAuditChannel(failWith?: string): AuditChannel { } /** A client whose channel cache resolves only `channelId` (to `channel`). */ -export function fakeChannelClient(channelId: string, channel: object, extra: object = {}): DingirClient { +export function fakeChannelClient( + channelId: string, + channel: object, + extra: object = {}, +): DingirClient { return stub({ channels: { fetch: async (id: string) => (id === channelId ? channel : null) }, ...extra, diff --git a/tests/fakes/guild.ts b/tests/fakes/guild.ts index bac8a3e9..98d75c8f 100644 --- a/tests/fakes/guild.ts +++ b/tests/fakes/guild.ts @@ -70,9 +70,7 @@ export interface FakeGuildOptions { } export const fakeGuildWithMembers = (opts: FakeGuildOptions) => { - const members = new Collection( - (opts.memberIds ?? []).map((id) => [id, fakeMember(id)]) - ); + const members = new Collection((opts.memberIds ?? []).map((id) => [id, fakeMember(id)])); const channels = new Collection((opts.channels ?? []).map((c) => [c.id, c])); return { id: opts.id, diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts index b5ae7cec..b3a48064 100644 --- a/tests/fakes/messages.ts +++ b/tests/fakes/messages.ts @@ -41,7 +41,8 @@ export const embedField = (embed: EmbedJson, name: string): { name: string; valu }; export function fakeMessage(overrides: Record = {}) { - const author = (overrides.author as ReturnType | null | undefined) ?? fakeUser('u1'); + const author = + (overrides.author as ReturnType | null | undefined) ?? fakeUser('u1'); const base = { id: 'm1', partial: false, @@ -67,7 +68,7 @@ export async function runSlash( app: App, client: DingirClient, fake: FakeInteraction, - extra: Record = {} + extra: Record = {}, ): Promise { Object.assign(fake.interaction, { client, ...extra }); await interactionCreate.run(app, client, fake.interaction); diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts index 5fab2bac..92931c1f 100644 --- a/tests/fakes/onboarding.ts +++ b/tests/fakes/onboarding.ts @@ -1,6 +1,13 @@ import type { Client, Guild, GuildMember } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; -import { fakeAuditChannel, fakeConfig, fakeGuild, fakeMember, type SentPayload, stub } from './discord.ts'; +import { + fakeAuditChannel, + fakeConfig, + fakeGuild, + fakeMember, + type SentPayload, + stub, +} from './discord.ts'; export { fakeCommandContext } from './command.ts'; @@ -64,7 +71,9 @@ export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { }, }); - const { channel, sent: auditSends } = fakeAuditChannel(opts.failAuditSend ? 'audit down' : undefined); + const { channel, sent: auditSends } = fakeAuditChannel( + opts.failAuditSend ? 'audit down' : undefined, + ); const client = stub({ channels: { fetch: async () => channel } }); const config = fakeConfig({ diff --git a/tests/helpers/app.ts b/tests/helpers/app.ts index c5bc276c..1c0a7099 100644 --- a/tests/helpers/app.ts +++ b/tests/helpers/app.ts @@ -31,7 +31,13 @@ export function fakeLogger(): { logger: Logger; logs: LogEntry[] } { logs.push({ level, message, context, error }); }; return { - logger: { debug: at('debug'), info: at('info'), warn: at('warn'), error: at('error'), fatal: at('fatal') }, + logger: { + debug: at('debug'), + info: at('info'), + warn: at('warn'), + error: at('error'), + fatal: at('fatal'), + }, logs, }; } diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts index 25101cfb..889c31b7 100644 --- a/tests/helpers/assertions.ts +++ b/tests/helpers/assertions.ts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import { UserError } from '../../src/framework/errors.ts'; /** Asserts the promise rejects with a UserError whose message matches. */ -export const rejectsUserError = (promise: Promise, message: string | RegExp): Promise => +export const rejectsUserError = ( + promise: Promise, + message: string | RegExp, +): Promise => assert.rejects(promise, (error: unknown) => { assert.ok(error instanceof UserError, `expected UserError, got ${String(error)}`); if (typeof message === 'string') { diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 98e6ba21..ed86b234 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -41,7 +41,7 @@ export function dbFixtures({ db, configCache }: ConfigDeps) { countProfiles: (): number => allProfiles().length, findProfile: (userId: string, serverId?: string): UserProfile | null => allProfiles().find( - (p) => p.userId === userId && (serverId === undefined || p.serverId === serverId) + (p) => p.userId === userId && (serverId === undefined || p.serverId === serverId), ) ?? null, }; } diff --git a/tests/logger.test.ts b/tests/logger.test.ts index ba50a29a..c36fdcad 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -12,7 +12,10 @@ const capture = (level?: LogLevel) => { }, }, }); - return { logger, entries: () => lines.map((line) => JSON.parse(line) as Record) }; + return { + logger, + entries: () => lines.map((line) => JSON.parse(line) as Record), + }; }; test('writes JSON lines with level, message and merged context', () => { @@ -51,7 +54,7 @@ test('respects the configured level', () => { logger.warn('shown'); assert.deepEqual( entries().map((e) => e.msg), - ['shown'] + ['shown'], ); }); diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts index 55a6057d..c2cb7566 100644 --- a/tests/moderation/events.test.ts +++ b/tests/moderation/events.test.ts @@ -9,7 +9,14 @@ import { updateConfig } from '../../src/services/ConfigService.ts'; import { incrementActivityScore } from '../../src/services/UserProfileService.ts'; import { createTestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; -import { auditJson, embedField, fakeAuditClient, fakeMember, fakeMessage, fakeUser } from '../fakes/messages.ts'; +import { + auditJson, + embedField, + fakeAuditClient, + fakeMember, + fakeMessage, + fakeUser, +} from '../fakes/messages.ts'; import { nth } from '../helpers/assertions.ts'; const app = createTestApp(); @@ -82,7 +89,7 @@ test('messageDelete audits uncached messages without content', async () => { assert.match(embed.description ?? '', /not cached/); assert.deepEqual( embed.fields.map((f) => f.value), - ['<#chan-7>', 'm42'] + ['<#chan-7>', 'm42'], ); }); @@ -90,7 +97,10 @@ test('messageDelete handles very long content and attachments', async () => { const sink = fakeAuditClient(); const message = fakeMessage({ content: 'x'.repeat(5000), - attachments: new Collection([['a', { name: 'a.png' }], ['b', { name: 'b.png' }]]), + attachments: new Collection([ + ['a', { name: 'a.png' }], + ['b', { name: 'b.png' }], + ]), embeds: [{}], }); await messageDelete.run(app, sink.client, message); @@ -114,7 +124,7 @@ test('messageUpdate notes an uncached previous message', async () => { app, sink.client, fakeMessage({ partial: true, content: null }), - fakeMessage({ content: 'edited' }) + fakeMessage({ content: 'edited' }), ); const embed = auditJson(sink.sent); assert.equal(embedField(embed, 'Previous').value, '*(not cached)*'); @@ -146,7 +156,7 @@ test('messageUpdate skips unchanged content and bots', async () => { app, sink.client, fakeMessage({ content: 'a' }), - fakeMessage({ content: 'b', author: fakeUser('u1', { bot: true }) }) + fakeMessage({ content: 'b', author: fakeUser('u1', { bot: true }) }), ); assert.equal(sink.sent.length, 0); }); @@ -157,7 +167,7 @@ test('messageUpdate truncates long content', async () => { app, sink.client, fakeMessage({ content: 'a'.repeat(4000) }), - fakeMessage({ content: 'b'.repeat(4000) }) + fakeMessage({ content: 'b'.repeat(4000) }), ); assert.equal(sink.sent.length, 1); assert.ok(auditJson(sink.sent).fields.every((f) => f.value.length <= 1024)); @@ -166,7 +176,11 @@ test('messageUpdate truncates long content', async () => { test('guildMemberRemove handles partial members and deletes profile data', async () => { const sink = fakeAuditClient(); await incrementActivityScore(app.db, 'guild-1', 'u1'); - const partial = { partial: true, guild: { id: 'guild-1' }, user: fakeUser('u1') } as unknown as PartialGuildMember; + const partial = { + partial: true, + guild: { id: 'guild-1' }, + user: fakeUser('u1'), + } as unknown as PartialGuildMember; await guildMemberRemove.run(app, sink.client, partial); assert.equal(await score(), null); const embed = auditJson(sink.sent); diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 41025492..7f433faf 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -16,7 +16,9 @@ after(() => { const DAY = 24 * 60 * 60 * 1000; const members = (count: number, joined: number | null = Date.now()) => - Array.from({ length: count }, (_, i) => fakeMember(String(100000000000000000 + i), { joinedTimestamp: joined })); + Array.from({ length: count }, (_, i) => + fakeMember(String(100000000000000000 + i), { joinedTimestamp: joined }), + ); test('small listings reply inline without mentions', () => { const result = buildMemberListing('**Header**', 'none', members(3)); @@ -42,7 +44,10 @@ test('null join dates are described as unknown', () => { const result = buildMemberListing('**Header**', 'none', members(1, null)); assert.match(result.content!, /join date unknown/); const big = buildMemberListing('**Header**', 'none', members(200, null)); - assert.match((nth(big.files as unknown[] | undefined) as { attachment: Buffer }).attachment.toString('utf8'), /join date unknown/); + assert.match( + (nth(big.files as unknown[] | undefined) as { attachment: Buffer }).attachment.toString('utf8'), + /join date unknown/, + ); }); test('empty listing returns the empty message', () => { @@ -53,7 +58,10 @@ test('rolesince filters by days in server and excludes bots', async () => { const now = FIXED_NOW.getTime(); const old = fakeMember('1', { joinedTimestamp: now - 40 * DAY }); const recent = fakeMember('2', { joinedTimestamp: now - 2 * DAY }); - const bot = fakeMember('3', { joinedTimestamp: now - 90 * DAY, user: fakeUser('3', { bot: true }) }); + const bot = fakeMember('3', { + joinedTimestamp: now - 90 * DAY, + user: fakeUser('3', { bot: true }), + }); const role = { toString: () => '<@&r1>', members: new Collection([old, recent, bot].map((m) => [m.id, m])), @@ -84,7 +92,12 @@ test('rolesince filters by days in server and excludes bots', async () => { test('noroles lists only non-bot members with just the everyone role', async () => { const plain = fakeMember('1'); const withRole = fakeMember('2', { - roles: { cache: new Collection([['e', {}], ['r', {}]]) }, + roles: { + cache: new Collection([ + ['e', {}], + ['r', {}], + ]), + }, }); const bot = fakeMember('3', { user: fakeUser('3', { bot: true }) }); const guild = { diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts index 386b734e..82cf2e0e 100644 --- a/tests/moderation/post.test.ts +++ b/tests/moderation/post.test.ts @@ -58,7 +58,7 @@ test('send failure replies with an error and neither succeeds nor audits', async target(async () => { throw new Error('boom'); }, allPerms), - { content: 'hi' } + { content: 'hi' }, ); await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); const reply = last(fake.calls); @@ -75,7 +75,7 @@ test('success audits then replies with the jump link', async () => { sentPayloads.push(1); return { url: 'https://discord.com/channels/g/c/m' }; }, allPerms), - { content: 'hello world', attachment: { name: 'pic.png' } } + { content: 'hello world', attachment: { name: 'pic.png' } }, ); await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.equal(sentPayloads.length, 1); @@ -86,7 +86,7 @@ test('success audits then replies with the jump link', async () => { assert.equal(nth(embed.fields, 2).value, 'pic.png'); assert.equal( contentOf(last(fake.calls).payload), - 'Posted in <#chan-9>: https://discord.com/channels/g/c/m' + 'Posted in <#chan-9>: https://discord.com/channels/g/c/m', ); }); @@ -98,7 +98,7 @@ test('missing permission replies clearly and does not send', async () => { sends += 1; return { url: 'x' }; }, [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]), - { attachment: { name: 'pic.png' } } + { attachment: { name: 'pic.png' } }, ); await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.equal(sends, 0); @@ -108,7 +108,10 @@ test('missing permission replies clearly and does not send', async () => { test('requires content or attachment and limits content length', async () => { const sink = fakeAuditClient('audit-1', [post]); - const fake = setup(target(async () => ({ url: 'x' }), allPerms), {}); + const fake = setup( + target(async () => ({ url: 'x' }), allPerms), + {}, + ); await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); assert.match(contentOf(last(fake.calls).payload), /at least text or an attachment/); diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts index 0828e531..401fb2f1 100644 --- a/tests/onboarding/OnboardingService.test.ts +++ b/tests/onboarding/OnboardingService.test.ts @@ -34,7 +34,14 @@ test('completedScreening only for pending true to false', () => { test('unconfigured server: audit done, roles and welcome skipped, no debug audit', async () => { const env = fakeOnboarding(); - const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: okImage }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: okImage }, + ); assert.equal(result.audit, 'done'); assert.match(result.roles, /^skipped:/); assert.match(result.welcome, /^skipped:/); @@ -44,16 +51,31 @@ test('unconfigured server: audit done, roles and welcome skipped, no debug audit test('guest roles are added in one call and unassignable ones filtered', async () => { const env = fakeOnboarding({ - roles: [role('ok1', 1), role('ok2', 2), role('managed', 1, true), role('high', 10), role('guild-1', 0)], + roles: [ + role('ok1', 1), + role('ok2', 2), + role('managed', 1, true), + role('high', 10), + role('guild-1', 0), + ], config: { guestRoleIds: 'ok1,ok2,managed,high,guild-1,gone' }, }); - const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: okImage }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: okImage }, + ); assert.equal(result.roles, 'done'); assert.deepEqual(env.roleAdds, [['ok1', 'ok2']]); - assert.deepEqual( - result.rolesSkipped.map((s) => s.id).sort(), - ['gone', 'guild-1', 'high', 'managed'] - ); + assert.deepEqual(result.rolesSkipped.map((s) => s.id).sort(), [ + 'gone', + 'guild-1', + 'high', + 'managed', + ]); }); test('no assignable roles skips the roles step without calling add', async () => { @@ -103,16 +125,25 @@ test('welcome replaces every {member} and does not ping others', async () => { test('welcome skip reasons', async () => { const disabled = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); - assert.match((await complete(app, disabled.client, disabled.member, disabled.config)).welcome, /disabled/); + assert.match( + (await complete(app, disabled.client, disabled.member, disabled.config)).welcome, + /disabled/, + ); const empty = fakeOnboarding({ config: { systemMessagesEnabled: true } }); - assert.match((await complete(app, empty.client, empty.member, empty.config)).welcome, /^skipped:/); + assert.match( + (await complete(app, empty.client, empty.member, empty.config)).welcome, + /^skipped:/, + ); const noChannel = fakeOnboarding({ systemChannel: false, config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, }); - assert.match((await complete(app, noChannel.client, noChannel.member, noChannel.config)).welcome, /no system channel/); + assert.match( + (await complete(app, noChannel.client, noChannel.member, noChannel.config)).welcome, + /no system channel/, + ); }); test('image failure still sends text and audits the image failure', async () => { @@ -123,7 +154,14 @@ test('image failure still sends text and audits the image failure', async () => welcomeMessageBackgroundUrl: 'https://example.com/bg.png', }, }); - const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: failImage }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: failImage }, + ); assert.equal(result.welcome, 'done'); assert.equal(env.systemSends.length, 1); assert.equal(nth(env.systemSends).files, undefined); @@ -132,9 +170,19 @@ test('image failure still sends text and audits the image failure', async () => test('image failure with no text fails the welcome step', async () => { const env = fakeOnboarding({ - config: { systemMessagesEnabled: true, welcomeMessageBackgroundUrl: 'https://example.com/bg.png' }, + config: { + systemMessagesEnabled: true, + welcomeMessageBackgroundUrl: 'https://example.com/bg.png', + }, }); - const result = await complete(app, env.client, env.member, env.config, {}, { renderImage: failImage }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: failImage }, + ); assert.match(result.welcome, /^failed:/); assert.equal(env.systemSends.length, 0); }); @@ -160,7 +208,14 @@ test('dryRun makes no role, send or audit calls and returns the payload', async welcomeMessageBackgroundUrl: 'https://example.com/bg.png', }, }); - const result = await complete(app, env.client, env.member, env.config, { dryRun: true }, { renderImage: okImage }); + const result = await complete( + app, + env.client, + env.member, + env.config, + { dryRun: true }, + { renderImage: okImage }, + ); assert.equal(env.roleAdds.length, 0); assert.equal(env.systemSends.length, 0); assert.equal(env.auditSends.length, 0); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 29b0b4ca..623cc039 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -29,18 +29,23 @@ const handler = (group: typeof NewRolesGroup, name: string): Handler => test('newroles set rejects @everyone, managed and too-high roles and saves nothing', async () => { const env = fakeOnboarding({ roles: [role('ok', 1), role('m', 1, true), role('hi', 10)] }); const { ctx, replies } = fakeCommandContext( - app, { + app, + { 'role-one': env.guild.roles.cache.get('ok'), 'role-two': env.guild.roles.cache.get('m'), 'role-three': env.guild.roles.cache.get('hi'), }, - env + env, ); await rejectsUserError(handler(NewRolesGroup, 'set')(ctx), /managed[\s\S]*highest role/); assert.equal(ctx.config, env.config); assert.equal(replies.length, 0); - const everyone = fakeCommandContext(app, { 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, env); + const everyone = fakeCommandContext( + app, + { 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, + env, + ); await rejectsUserError(handler(NewRolesGroup, 'set')(everyone.ctx), /everyone/); assert.equal(everyone.ctx.config, env.config); }); @@ -50,8 +55,9 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1' }); const a = env.guild.roles.cache.get('a'); const { ctx, replies } = fakeCommandContext( - app, { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, - env + app, + { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, + env, ); await handler(NewRolesGroup, 'set')(ctx); assert.equal(ctx.config.guestRoleIds, 'a,b'); @@ -134,7 +140,9 @@ test('welcome preview explains why nothing would be sent', async () => { }); test('welcome preview shows the text without sending', async () => { - const env = fakeOnboarding({ config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true } }); + const env = fakeOnboarding({ + config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true }, + }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); assert.match(nth(replies).content!, /hi <@member-1> <@member-1>/); diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 109b09e3..24ab13a7 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -9,8 +9,14 @@ test('validateRegistry accepts unique names', () => { }); test('validateRegistry rejects duplicate names', () => { - assert.throws(() => validateRegistry('command', [{ name: 'same' }, { name: 'same' }]), /Duplicate command "same"/); - assert.throws(() => validateRegistry('event', [{ name: 'x' }, { name: 'x' }]), /Duplicate event "x"/); + assert.throws( + () => validateRegistry('command', [{ name: 'same' }, { name: 'same' }]), + /Duplicate command "same"/, + ); + assert.throws( + () => validateRegistry('event', [{ name: 'x' }, { name: 'x' }]), + /Duplicate event "x"/, + ); }); test('validateRegistry rejects missing names', () => { diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts index cd4fca4a..9dcf34f2 100644 --- a/tests/services/AuditEmbed.test.ts +++ b/tests/services/AuditEmbed.test.ts @@ -30,7 +30,7 @@ test('empty and whitespace values render as *(empty)*', () => { const json = new AuditEmbed().addField('A', '').addField('B', ' ').toJSON(); assert.deepEqual( json.fields?.map((f) => f.value), - ['*(empty)*', '*(empty)*'] + ['*(empty)*', '*(empty)*'], ); assert.equal(json.fields?.length, 2); }); @@ -81,7 +81,10 @@ test('7000 total chars are trimmed to 6000 or less, dropping trailing fields', ( test('oversized description plus fields still fits', () => { const embed = new AuditEmbed().setDescription('d'.repeat(4096)); - embed.addField('a', 'v'.repeat(1024)).addField('b', 'v'.repeat(1024)).addField('c', 'v'.repeat(1024)); + embed + .addField('a', 'v'.repeat(1024)) + .addField('b', 'v'.repeat(1024)) + .addField('c', 'v'.repeat(1024)); assert.ok(total(embed.toJSON()) <= 6000); }); diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts index c7e4bb87..8458b29b 100644 --- a/tests/services/AuditService.test.ts +++ b/tests/services/AuditService.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import type { Client} from 'discord.js'; +import type { Client } from 'discord.js'; import { EmbedBuilder } from 'discord.js'; import type { ServerConfig } from '../../src/db/schema.ts'; import { sendAudit } from '../../src/services/AuditService.ts'; @@ -32,7 +32,15 @@ test('returns false when fetch rejects', async () => { }); test('returns false when channel is missing or not sendable', async () => { - assert.equal(await sendAudit(app, clientWith(async () => null), config('c1'), embed), false); + assert.equal( + await sendAudit( + app, + clientWith(async () => null), + config('c1'), + embed, + ), + false, + ); const unsendable = clientWith(async () => ({ isSendable: () => false })); assert.equal(await sendAudit(app, unsendable, config('c1'), embed), false); }); diff --git a/tests/services/BirthdayDates.test.ts b/tests/services/BirthdayDates.test.ts index 8b1e32ba..4c8608e2 100644 --- a/tests/services/BirthdayDates.test.ts +++ b/tests/services/BirthdayDates.test.ts @@ -88,7 +88,7 @@ test('upcoming groups by date, sorts, and does not mutate input', () => { ['2026-06-20', ['a', 'b']], ['2026-07-01', ['c']], ['2027-01-05', ['d']], - ] + ], ); assert.deepEqual(profiles, copy); }); @@ -103,11 +103,11 @@ test('upcoming limit: stops after the group that reaches the limit, never splitt const now = at('2026-06-15T12:00:00'); assert.deepEqual( upcoming(profiles, now, 'utc', 2).map((g) => g.userIds), - [['a'], ['b', 'c']] + [['a'], ['b', 'c']], ); assert.deepEqual( upcoming(profiles, now, 'utc', 3).map((g) => g.userIds), - [['a'], ['b', 'c']] + [['a'], ['b', 'c']], ); assert.equal(upcoming(profiles, now, 'utc', 4).length, 3); assert.deepEqual(upcoming([], now, 'utc'), []); diff --git a/tests/services/MemberResolver.test.ts b/tests/services/MemberResolver.test.ts index 7eb5c590..e7598d10 100644 --- a/tests/services/MemberResolver.test.ts +++ b/tests/services/MemberResolver.test.ts @@ -4,7 +4,10 @@ import type { Guild } from 'discord.js'; import { apiError, fakeMember, stub } from '../fakes/discord.ts'; import { resolveMember, resolveTextChannel } from '../../src/services/MemberResolver.ts'; -const guildWith = (cached: Record, fetch: (id: string) => Promise): Guild => +const guildWith = ( + cached: Record, + fetch: (id: string) => Promise, +): Guild => stub({ members: { cache: new Map(Object.entries(cached)), fetch }, channels: { cache: new Map(Object.entries(cached)), fetch }, diff --git a/tests/services/RetentionPolicy.test.ts b/tests/services/RetentionPolicy.test.ts index e7721721..8a07b8ca 100644 --- a/tests/services/RetentionPolicy.test.ts +++ b/tests/services/RetentionPolicy.test.ts @@ -58,5 +58,8 @@ test('ok resets state', () => { test('transient leaves state unchanged and never purges', () => { const state: AccessState = { accessFailureCount: 9, firstAccessFailureAt: day(0) }; - assert.deepEqual(nextAccessState(state, 'transient', day(40), policy), { ...state, purge: false }); + assert.deepEqual(nextAccessState(state, 'transient', day(40), policy), { + ...state, + purge: false, + }); }); diff --git a/tests/services/Scheduler.test.ts b/tests/services/Scheduler.test.ts index 91d9ac2f..bd1ffe29 100644 --- a/tests/services/Scheduler.test.ts +++ b/tests/services/Scheduler.test.ts @@ -5,7 +5,12 @@ import { fakeLogger } from '../helpers/app.ts'; const { logger } = fakeLogger(); -const fakeSchedule = (): { fn: ScheduleFn; calls: unknown[]; cancelled: () => number; fire: () => void } => { +const fakeSchedule = (): { + fn: ScheduleFn; + calls: unknown[]; + cancelled: () => number; + fire: () => void; +} => { const calls: unknown[] = []; let cancels = 0; let cb: () => void = () => undefined; @@ -70,7 +75,13 @@ test('start schedules with cron and tz; second start is a no-op', () => { test('fired job triggers tasks', async () => { const fake = fakeSchedule(); let ran = 0; - const scheduler = new Scheduler(logger, '* * * * *', 'utc', [{ name: 't', run: async () => void (ran += 1) }], fake.fn); + const scheduler = new Scheduler( + logger, + '* * * * *', + 'utc', + [{ name: 't', run: async () => void (ran += 1) }], + fake.fn, + ); scheduler.start(); fake.fire(); await new Promise((resolve) => setImmediate(resolve)); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 71c785e0..8b90dcfb 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -121,7 +121,7 @@ test('channelSetting restricts the channel option types', () => { assert.equal(channel.required, true); assert.deepEqual( group.options?.map((o) => o.name), - ['set', 'get', 'clear'] + ['set', 'get', 'clear'], ); }); diff --git a/tsconfig.json b/tsconfig.json index f1acc9ac..39930a2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,20 @@ { - "compilerOptions": { - "lib": ["ESNext"], - "types": ["node"], - "module": "nodenext", - "moduleResolution": "nodenext", - "target": "ES2024", - "noEmit": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "noFallthroughCasesInSwitch": true, - "erasableSyntaxOnly": true, - "verbatimModuleSyntax": true, - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "skipLibCheck": true - }, - "include": ["src", "tests"] + "compilerOptions": { + "lib": ["ESNext"], + "types": ["node"], + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "ES2024", + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] } From 6c6dcd94833ac9deb59784a34fb0ed6954549f6a Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:26:55 +0100 Subject: [PATCH 35/77] Update dependency overrides for audit Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4742 ++++++++++++++++++++++----------------------- package.json | 4 +- 2 files changed, 2372 insertions(+), 2374 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8154dc4..34a2c439 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,2373 +1,2373 @@ { - "name": "dingir", - "version": "3.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "dingir", - "version": "3.0.0", - "dependencies": { - "@napi-rs/canvas": "^1.0.2", - "better-sqlite3": "^13.0.3", - "croner": "^10.0.1", - "discord.js": "^14.26.5", - "drizzle-orm": "^0.45.3", - "luxon": "^3.7.2", - "pino": "^10.4.0" - }, - "devDependencies": { - "@types/better-sqlite3": "^9.6.0", - "@types/luxon": "^3.7.2", - "@types/node": "^24.13.3", - "oxfmt": "^0.72.0", - "oxlint": "^1.87.0", - "oxlint-tsgolint": "^7.0.2003", - "pino-pretty": "^13.2.0", - "typescript": "^7.0.2" - }, - "engines": { - "node": ">=24" - } - }, - "node_modules/@discordjs/builders": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", - "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", - "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.2.0", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.5", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.40", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", - "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@napi-rs/canvas": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", - "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", - "license": "MIT", - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "1.0.2", - "@napi-rs/canvas-darwin-arm64": "1.0.2", - "@napi-rs/canvas-darwin-x64": "1.0.2", - "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", - "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", - "@napi-rs/canvas-linux-arm64-musl": "1.0.2", - "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", - "@napi-rs/canvas-linux-x64-gnu": "1.0.2", - "@napi-rs/canvas-linux-x64-musl": "1.0.2", - "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", - "@napi-rs/canvas-win32-x64-msvc": "1.0.2" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", - "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", - "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", - "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.72.0.tgz", - "integrity": "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.72.0.tgz", - "integrity": "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.72.0.tgz", - "integrity": "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.72.0.tgz", - "integrity": "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.72.0.tgz", - "integrity": "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.72.0.tgz", - "integrity": "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.72.0.tgz", - "integrity": "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.72.0.tgz", - "integrity": "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.72.0.tgz", - "integrity": "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.72.0.tgz", - "integrity": "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.72.0.tgz", - "integrity": "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.72.0.tgz", - "integrity": "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.72.0.tgz", - "integrity": "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.72.0.tgz", - "integrity": "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.72.0.tgz", - "integrity": "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.72.0.tgz", - "integrity": "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.72.0.tgz", - "integrity": "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.72.0.tgz", - "integrity": "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz", - "integrity": "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2003.tgz", - "integrity": "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2003.tgz", - "integrity": "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2003.tgz", - "integrity": "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2003.tgz", - "integrity": "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2003.tgz", - "integrity": "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2003.tgz", - "integrity": "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", - "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-android-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", - "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", - "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", - "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", - "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", - "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", - "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", - "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", - "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", - "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", - "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", - "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", - "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", - "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", - "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", - "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", - "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", - "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", - "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@types/better-sqlite3": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", - "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.0.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/croner": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", - "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", - "funding": [ - { - "type": "other", - "url": "https://paypal.me/hexagonpp" - }, - { - "type": "github", - "url": "https://github.com/sponsors/hexagon" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - } - }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/discord-api-types": { - "version": "0.38.49", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", - "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/discord.js": { - "version": "14.26.5", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.5.tgz", - "integrity": "sha512-SGYgjiAs0o8ZzMC97XmFXKONyairJ9YzVda+LvoSKs9YYh2gPtQhY+liaV6H/w72jxYbu9ggiSqAXoF3FLOH4A==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/builders": "^1.14.1", - "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.1", - "@discordjs/util": "^1.2.0", - "@discordjs/ws": "^1.2.3", - "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.48", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/drizzle-orm": { - "version": "0.45.3", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", - "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@netlify/db": ">=0.4.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@netlify/db": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/fast-copy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.2.tgz", - "integrity": "sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT" - }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/magic-bytes.js": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", - "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", - "license": "MIT" - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-addon-api": { - "version": "8.9.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", - "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/oxfmt": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", - "integrity": "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinypool": "2.2.0" - }, - "bin": { - "oxfmt": "bin/oxfmt" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/oxc-project" - }, - "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.72.0", - "@oxfmt/binding-android-arm64": "0.72.0", - "@oxfmt/binding-darwin-arm64": "0.72.0", - "@oxfmt/binding-darwin-x64": "0.72.0", - "@oxfmt/binding-freebsd-x64": "0.72.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", - "@oxfmt/binding-linux-arm64-gnu": "0.72.0", - "@oxfmt/binding-linux-arm64-musl": "0.72.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", - "@oxfmt/binding-linux-riscv64-musl": "0.72.0", - "@oxfmt/binding-linux-s390x-gnu": "0.72.0", - "@oxfmt/binding-linux-x64-gnu": "0.72.0", - "@oxfmt/binding-linux-x64-musl": "0.72.0", - "@oxfmt/binding-openharmony-arm64": "0.72.0", - "@oxfmt/binding-win32-arm64-msvc": "0.72.0", - "@oxfmt/binding-win32-ia32-msvc": "0.72.0", - "@oxfmt/binding-win32-x64-msvc": "0.72.0" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite-plus": "*" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - }, - "vite-plus": { - "optional": true - } - } - }, - "node_modules/oxlint": { - "version": "1.87.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", - "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", - "dev": true, - "license": "MIT", - "bin": { - "oxlint": "bin/oxlint" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/oxc-project" - }, - "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.87.0", - "@oxlint/binding-android-arm64": "1.87.0", - "@oxlint/binding-darwin-arm64": "1.87.0", - "@oxlint/binding-darwin-x64": "1.87.0", - "@oxlint/binding-freebsd-x64": "1.87.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", - "@oxlint/binding-linux-arm-musleabihf": "1.87.0", - "@oxlint/binding-linux-arm64-gnu": "1.87.0", - "@oxlint/binding-linux-arm64-musl": "1.87.0", - "@oxlint/binding-linux-ppc64-gnu": "1.87.0", - "@oxlint/binding-linux-riscv64-gnu": "1.87.0", - "@oxlint/binding-linux-riscv64-musl": "1.87.0", - "@oxlint/binding-linux-s390x-gnu": "1.87.0", - "@oxlint/binding-linux-x64-gnu": "1.87.0", - "@oxlint/binding-linux-x64-musl": "1.87.0", - "@oxlint/binding-openharmony-arm64": "1.87.0", - "@oxlint/binding-win32-arm64-msvc": "1.87.0", - "@oxlint/binding-win32-ia32-msvc": "1.87.0", - "@oxlint/binding-win32-x64-msvc": "1.87.0" - }, - "peerDependencies": { - "oxlint-tsgolint": ">=7.0.2003", - "vite-plus": "*" - }, - "peerDependenciesMeta": { - "oxlint-tsgolint": { - "optional": true - }, - "vite-plus": { - "optional": true - } - } - }, - "node_modules/oxlint-tsgolint": { - "version": "7.0.2003", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2003.tgz", - "integrity": "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==", - "dev": true, - "license": "MIT", - "bin": { - "tsgolint": "bin/tsgolint.js" - }, - "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "7.0.2003", - "@oxlint-tsgolint/darwin-x64": "7.0.2003", - "@oxlint-tsgolint/linux-arm64": "7.0.2003", - "@oxlint-tsgolint/linux-x64": "7.0.2003", - "@oxlint-tsgolint/win32-arm64": "7.0.2003", - "@oxlint-tsgolint/win32-x64": "7.0.2003" - } - }, - "node_modules/pino": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", - "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^1.0.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-pretty": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.2.0.tgz", - "integrity": "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^2.0.7", - "dateformat": "^4.6.3", - "fast-copy": "^4.0.0", - "fast-safe-stringify": "^2.1.1", - "help-me": "^5.0.0", - "joycon": "^3.1.1", - "minimist": "^1.2.6", - "on-exit-leak-free": "^2.1.2", - "pino-abstract-transport": "^3.0.0", - "pump": "^3.0.3", - "secure-json-parse": "^4.1.0", - "sonic-boom": "^5.0.0", - "strip-json-comments": "^5.0.3" - }, - "bin": { - "pino-pretty": "bin.js" - } - }, - "node_modules/pino-pretty/node_modules/sonic-boom": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-5.0.1.tgz", - "integrity": "sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/process-warning": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", - "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/real-require": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", - "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/thread-stream": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", - "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", - "license": "MIT", - "dependencies": { - "real-require": "^1.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/tinypool": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", - "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } + "name": "dingir", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dingir", + "version": "3.0.0", + "dependencies": { + "@napi-rs/canvas": "^1.0.2", + "better-sqlite3": "^13.0.3", + "croner": "^10.0.1", + "discord.js": "^14.26.5", + "drizzle-orm": "^0.45.3", + "luxon": "^3.7.2", + "pino": "^10.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/luxon": "^3.7.2", + "@types/node": "^24.13.3", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", + "pino-pretty": "^13.2.0", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.72.0.tgz", + "integrity": "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.72.0.tgz", + "integrity": "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.72.0.tgz", + "integrity": "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.72.0.tgz", + "integrity": "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.72.0.tgz", + "integrity": "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.72.0.tgz", + "integrity": "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.72.0.tgz", + "integrity": "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.72.0.tgz", + "integrity": "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.72.0.tgz", + "integrity": "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.72.0.tgz", + "integrity": "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.72.0.tgz", + "integrity": "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.72.0.tgz", + "integrity": "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.72.0.tgz", + "integrity": "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.72.0.tgz", + "integrity": "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.72.0.tgz", + "integrity": "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.72.0.tgz", + "integrity": "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.72.0.tgz", + "integrity": "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.72.0.tgz", + "integrity": "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz", + "integrity": "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2003.tgz", + "integrity": "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2003.tgz", + "integrity": "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2003.tgz", + "integrity": "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2003.tgz", + "integrity": "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2003.tgz", + "integrity": "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2003.tgz", + "integrity": "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.5", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.5.tgz", + "integrity": "sha512-SGYgjiAs0o8ZzMC97XmFXKONyairJ9YzVda+LvoSKs9YYh2gPtQhY+liaV6H/w72jxYbu9ggiSqAXoF3FLOH4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.48", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/fast-copy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.2.tgz", + "integrity": "sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oxfmt": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", + "integrity": "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.72.0", + "@oxfmt/binding-android-arm64": "0.72.0", + "@oxfmt/binding-darwin-arm64": "0.72.0", + "@oxfmt/binding-darwin-x64": "0.72.0", + "@oxfmt/binding-freebsd-x64": "0.72.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", + "@oxfmt/binding-linux-arm64-gnu": "0.72.0", + "@oxfmt/binding-linux-arm64-musl": "0.72.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-musl": "0.72.0", + "@oxfmt/binding-linux-s390x-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-musl": "0.72.0", + "@oxfmt/binding-openharmony-arm64": "0.72.0", + "@oxfmt/binding-win32-arm64-msvc": "0.72.0", + "@oxfmt/binding-win32-ia32-msvc": "0.72.0", + "@oxfmt/binding-win32-x64-msvc": "0.72.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2003.tgz", + "integrity": "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2003", + "@oxlint-tsgolint/darwin-x64": "7.0.2003", + "@oxlint-tsgolint/linux-arm64": "7.0.2003", + "@oxlint-tsgolint/linux-x64": "7.0.2003", + "@oxlint-tsgolint/win32-arm64": "7.0.2003", + "@oxlint-tsgolint/win32-x64": "7.0.2003" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.2.0.tgz", + "integrity": "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.2", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.3", + "secure-json-parse": "^4.1.0", + "sonic-boom": "^5.0.0", + "strip-json-comments": "^5.0.3" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/sonic-boom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-5.0.1.tgz", + "integrity": "sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } } diff --git a/package.json b/package.json index 19db21e8..77fc978f 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,8 @@ "typescript": "^7.0.2" }, "overrides": { - "brace-expansion@<=1.1.12": "1.1.17", "lodash@<=4.17.23": "4.18.1", - "minimatch@<=3.1.3": "3.1.5", - "undici@<=6.26.0": "6.27.0" + "undici@<=6.28.0": "6.29.0" }, "engines": { "node": ">=24" From 433cd532457d36e56390b9aedc19eeca87a97a50 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:27:09 +0100 Subject: [PATCH 36/77] Run format, lint, typecheck and coverage in CI; update README tooling docs Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build-and-publish.yml | 8 +++++--- .github/workflows/lint-and-build.yml | 9 ++++++--- README.md | 12 ++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7ae95923..336d7773 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -11,7 +11,7 @@ permissions: jobs: verify: - name: Lint, Typecheck and Test + name: Format, Lint, Typecheck and Test runs-on: ubuntu-latest steps: @@ -24,14 +24,16 @@ jobs: node-version: 24 cache: npm - # ESLint and Prettier must be in `package.json` - name: Install Node.js dependencies run: npm ci - name: Audit dependencies run: npm audit --audit-level=low - - name: Run linters + - name: Check formatting + run: npm run format:check + + - name: Run linter run: npm run lint - name: Type check diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 95abcf25..1bea9d80 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -1,4 +1,4 @@ -name: Lint, Typecheck and Test +name: Format, Lint, Typecheck and Test on: # Trigger the workflow on push or pull request, @@ -16,7 +16,7 @@ permissions: jobs: verify: - name: Lint, Typecheck and Test + name: Format, Lint, Typecheck and Test runs-on: ubuntu-latest steps: @@ -35,7 +35,10 @@ jobs: - name: Audit dependencies run: npm audit --audit-level=low - - name: Run linters + - name: Check formatting + run: npm run format:check + + - name: Run linter run: npm run lint - name: Type check diff --git a/README.md b/README.md index 976ccbd5..15da2381 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,17 @@ npm run dev Logs are structured JSON. For human-readable output while developing, use `npm run dev:pretty` (pipes through `pino-pretty`, a dev dependency). In production, pipe `docker logs` or `npm start` through `npx pino-pretty` if you want the same. -Test and lint: +Test, lint and format: ```bash -npm test # Run the test suite +npm test # Run the test suite npm run test:coverage # Run tests and enforce coverage thresholds -npm run typecheck # Type-check src and tests -npm run lint # Check code style and errors +npm run typecheck # Type-check src and tests (TypeScript 7, tsc --noEmit) +npm run lint # Lint with oxlint (type-aware, via oxlint-tsgolint) +npm run lint:fix # Apply oxlint auto-fixes +npm run format # Format with oxfmt +npm run format:check # Verify formatting without writing +npm run check # format:check, lint, typecheck and test:coverage (what CI runs) ``` ### Docker From e45d2a3df8846b8bc20f6e29c313eb7316a2a89d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:28:51 +0100 Subject: [PATCH 37/77] Refresh README for the v3 toolchain Clarify that the scheduled job runs at the configured timezone, not UTC. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 15da2381..2d126864 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ Members can set their birthday using `/mybirthday set `. Birthdays ### Scheduled Daily Job -On the schedule defined by `JOB_SCHEDULE` (default: 9:00 AM UTC), three tasks run in order: +On the schedule defined by `JOB_SCHEDULE` (default: 9:00 AM in the configured `BOT_TIMEZONE`), three tasks run in order: 1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. 2. **Birthday Notifications** — Posts birthday announcements in the announcements channel for members with birthdays today From 7fefd8d846ffeb91770098c5bf7671cbca26c700 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:43:39 +0100 Subject: [PATCH 38/77] Disable install scripts so native deps use prebuilt binaries Install scripts are disabled via .npmrc because better-sqlite3 ships prebuilt binaries but npm by default runs node-gyp rebuild on install, which fails on machines without Python/compilers. The .npmrc setting ensures consistency everywhere. Co-Authored-By: Claude Haiku 4.5 --- .npmrc | 3 ++- Dockerfile | 4 ++-- README.md | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.npmrc b/.npmrc index 5660f81a..a9f827fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -registry=https://registry.npmjs.org/ \ No newline at end of file +registry=https://registry.npmjs.org/ +ignore-scripts=true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index aa4a5e1a..9a3a9bcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:24-trixie-slim WORKDIR /usr/src/app -COPY package*.json ./ -RUN npm ci --omit=dev --ignore-scripts +COPY package*.json .npmrc ./ +RUN npm ci --omit=dev COPY src ./src RUN mkdir -p data && chown node:node data USER node diff --git a/README.md b/README.md index 2d126864..fc4fbc0a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Discord bot for the Irkallu server, built with discord.js and TypeScript. Prov 1. Clone the repository 2. Install dependencies: `npm ci` + - Install scripts are disabled via `.npmrc` because the only native dependency (`better-sqlite3`) ships prebuilt binaries; if a future dependency needs a postinstall step, run `npm rebuild ` or revisit this setting. 3. Copy `.env.example` to `.env` and fill in `TOKEN` and `CLIENT_ID` from the [Discord Developer Portal](https://discord.com/developers/applications) 4. Start: `npm start` (Node runs the TypeScript source directly; there is no build step) From d7e278eb7727eb49a7f4395c6803b83ee41d5024 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:05:37 +0100 Subject: [PATCH 39/77] Draw a thin avatar ring with a translucent backdrop on welcome cards Co-Authored-By: Claude Haiku 4.5 --- src/services/WelcomeImage.ts | 32 +++++-- tests/onboarding/WelcomeImage.test.ts | 116 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/onboarding/WelcomeImage.test.ts diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts index e19add3a..ce049f8a 100644 --- a/src/services/WelcomeImage.ts +++ b/src/services/WelcomeImage.ts @@ -11,6 +11,11 @@ const FONT_PATH = path.join(import.meta.dirname, '..', 'resources', 'fonts', 'Ro const DOWNLOAD_TIMEOUT_MS = 10_000; const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024; const MIN_FONT_SIZE = 8; +const AVATAR_CENTER = { x: 125, y: 125 }; +const AVATAR_RADIUS = 75; +const RING_WIDTH = 5; +const RING_COLOUR = 'rgba(255, 255, 255, 0.7)'; +const AVATAR_BACKDROP = 'rgba(0, 0, 0, 0.4)'; let fontRegistered = false; const ensureFont = (): void => { @@ -110,16 +115,33 @@ export const render: WelcomeImageRenderer = async (app, member, backgroundUrl) = ctx.font = applyText(canvas, joinedText, 20); ctx.fillText(joinedText, 225, 200); + const cx = AVATAR_CENTER.x; + const cy = AVATAR_CENTER.y; + const r = AVATAR_RADIUS; + + // Create a clipping region for the avatar + ctx.save(); ctx.beginPath(); - ctx.arc(125, 125, 75, 0, Math.PI * 2, true); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.lineWidth = 10; - ctx.stroke(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); + // Fill the clipped region with backdrop + ctx.fillStyle = AVATAR_BACKDROP; + ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r); + + // Draw the avatar const avatar = await loadImage(member.displayAvatarURL({ extension: 'png', size: 256 })); - ctx.drawImage(avatar, 50, 50, 150, 150); + ctx.drawImage(avatar, cx - r, cy - r, 2 * r, 2 * r); + + ctx.restore(); + + // Draw the ring outside the clipped region + ctx.beginPath(); + ctx.arc(cx, cy, r + RING_WIDTH / 2, 0, Math.PI * 2); + ctx.strokeStyle = RING_COLOUR; + ctx.lineWidth = RING_WIDTH; + ctx.stroke(); return new AttachmentBuilder(await canvas.encode('png')).setName('welcome-image.png'); }; diff --git a/tests/onboarding/WelcomeImage.test.ts b/tests/onboarding/WelcomeImage.test.ts new file mode 100644 index 00000000..55a3f25f --- /dev/null +++ b/tests/onboarding/WelcomeImage.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { test } from 'node:test'; +import { createCanvas, loadImage } from '@napi-rs/canvas'; +import { render } from '../../src/services/WelcomeImage.ts'; +import { fakeMember } from '../fakes/discord.ts'; +import { createTestApp } from '../helpers/app.ts'; + +test('WelcomeImage.render draws avatar with translucent backdrop and thin ring', async (_t) => { + const app = createTestApp(); + + // Create a background canvas (blue) + const bgCanvas = createCanvas(700, 250); + const bgCtx = bgCanvas.getContext('2d'); + bgCtx.fillStyle = '#0000ff'; + bgCtx.fillRect(0, 0, 700, 250); + const bgBuffer = await bgCanvas.encode('png'); + + // Create a transparent avatar canvas (256x256, fully transparent) + const avatarCanvas = createCanvas(256, 256); + const avatarCtx = avatarCanvas.getContext('2d'); + avatarCtx.clearRect(0, 0, 256, 256); + const avatarBuffer = await avatarCanvas.encode('png'); + + // Start an HTTP server to serve the images + const server = createServer((req, res) => { + if (req.url === '/background') { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(bgBuffer); + } else if (req.url === '/avatar') { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(avatarBuffer); + } else { + res.writeHead(404); + res.end(); + } + }); + + try { + const { bgUrl, avatarUrl } = await new Promise<{ bgUrl: string; avatarUrl: string }>( + (resolve, reject) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (addr && typeof addr === 'object' && addr.port) { + const port = addr.port; + resolve({ + bgUrl: `http://127.0.0.1:${port}/background`, + avatarUrl: `http://127.0.0.1:${port}/avatar`, + }); + } else { + reject(new Error('Server did not bind to a port')); + } + }); + server.on('error', reject); + }, + ); + + // Create a fake member with displayAvatarURL returning our test avatar + const member = fakeMember('test-user', { + displayName: 'Test User', + joinedTimestamp: Date.now(), + guild: { + name: 'Test Guild', + }, + displayAvatarURL: () => avatarUrl, + }); + + // Render the welcome image + const attachment = await render(app, member, bgUrl); + const buffer = attachment.attachment as Buffer; + + // Decode the rendered image + const canvas = createCanvas(700, 250); + const ctx = canvas.getContext('2d'); + const image = await loadImage(buffer); + ctx.drawImage(image, 0, 0); + + // Sample and verify pixels + const cx = 125; + const cy = 125; + + // Pixel at avatar center (125, 125): should be backdrop blended over blue + // backdrop = rgba(0, 0, 0, 0.4), blue = rgb(0, 0, 255) + // Result: approximately r=0, g=0, b≈153 (255 * 0.6) + const centerData = ctx.getImageData(cx, cy, 1, 1).data as Uint8ClampedArray; + assert.ok( + centerData[2]! < 200 && centerData[2]! > 100, + `Center pixel blue channel should be ~153 (darkened blue), got ${centerData[2]}`, + ); + + // Pixel at (125 + 77, 125): middle of ring, should be light + const ringData = ctx.getImageData(cx + 77, cy, 1, 1).data as Uint8ClampedArray; + assert.ok( + ringData[0]! > 150 && ringData[1]! > 150, + `Ring pixel should be light (r > 150, g > 150), got r=${ringData[0]}, g=${ringData[1]}`, + ); + + // Pixel at (125 + 73, 125): just inside ring, should not be light + const insideData = ctx.getImageData(cx + 73, cy, 1, 1).data as Uint8ClampedArray; + assert.ok(insideData[0]! < 100, `Inside ring pixel r should be < 100, got ${insideData[0]}`); + + // Pixel at (125 + 83, 125): just outside ring, should be blue background + const outsideData = ctx.getImageData(cx + 83, cy, 1, 1).data as Uint8ClampedArray; + assert.ok( + outsideData[2]! > 230 && outsideData[0]! < 30, + `Outside ring pixel should be blue (b > 230, r < 30), got b=${outsideData[2]}, r=${outsideData[0]}`, + ); + } finally { + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + app.close(); + } +}); From 6436af6d63bec41d65d127c539dc42b0cf8b2292 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:36:47 +0100 Subject: [PATCH 40/77] Skip onboarding for bots while still auditing their join Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberAdd.ts | 2 +- src/events/guildMemberUpdate.ts | 3 +++ tests/onboarding/events.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 1dc5bca1..4184ca38 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -9,7 +9,7 @@ export default defineEvent({ await auditJoin(app, client, member, config); - if (!member.pending) { + if (!member.user.bot && !member.pending) { await complete(app, client, member, config); } }, diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index ce07fc78..40aa1211 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -12,6 +12,9 @@ export default defineEvent({ }); return; } + if (newMember.user.bot) { + return; + } if (!completedScreening(oldMember, newMember)) { return; } diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 18b34748..5b871f62 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -57,3 +57,31 @@ test('guildMemberAdd waits for screening when the member is pending', async () = assert.equal(env.roleAdds.length, 0); assert.equal(env.auditSends.length, 1); }); + +test('guildMemberAdd audits a bot join but skips onboarding', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + config: { welcomeMessage: 'Hi {member}' }, + }); + Object.assign(env.member, { + pending: false, + user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }), + }); + await guildMemberAdd.run(app, stub(env.client), env.member); + assert.equal(env.auditSends.length, 1); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /New member joined/); + assert.equal(env.roleAdds.length, 0); + assert.equal(env.systemSends.length, 0); +}); + +test('guildMemberUpdate ignores bots completing screening', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(env.member, { + pending: false, + user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }), + }); + const oldMember = { guild: env.guild, id: 'member-1', pending: true } as unknown as GuildMember; + await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); + assert.equal(env.roleAdds.length, 0); + assert.equal(env.auditSends.length, 0); +}); From cff4834c66956fcaa51fcea0bec85bf9e144df38 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:36:56 +0100 Subject: [PATCH 41/77] Only report birthday cleared when one was actually set Co-Authored-By: Claude Sonnet 5.5 --- src/services/UserProfileService.ts | 13 +++++++++++-- tests/birthdays/commands.test.ts | 13 +++++++++++++ tests/database/services.test.ts | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index c73542b0..71fbe82c 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,5 +1,5 @@ import type { Snowflake } from 'discord.js'; -import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm'; import type { Db } from '../db/db.ts'; import { userProfiles, type UserProfile } from '../db/schema.ts'; @@ -48,7 +48,16 @@ export async function clearBirthday( const result = db .update(userProfiles) .set({ birthdayYear: null, birthdayMonth: null, birthdayDay: null, updatedAt: new Date() }) - .where(forUser(serverId, userId)) + .where( + and( + forUser(serverId, userId), + or( + isNotNull(userProfiles.birthdayMonth), + isNotNull(userProfiles.birthdayDay), + isNotNull(userProfiles.birthdayYear), + ), + ), + ) .run(); return result.changes > 0; } diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index b84bef93..8fb8498d 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -95,3 +95,16 @@ test('profile for a member without a profile does not create one', async () => { assert.equal(field('Activity Score'), '0'); assert.equal(countProfiles(), 0); }); + +test('mybirthday clear reports no birthday for a profile with only activity', async () => { + createProfiles([{ serverId: 'guild-1', userId: 'user-1', activityScore: 3 }]); + const replies = await exec(mybirthday, { subcommand: 'clear' }); + assert.equal(lastContent(replies), "You don't have a birthday set."); + assert.equal(findProfile('user-1')?.activityScore, 3); +}); + +test('mybirthday clear confirms removal when a birthday was set', async () => { + createProfiles([{ serverId: 'guild-1', userId: 'user-1', birthdayMonth: 5, birthdayDay: 5 }]); + const replies = await exec(mybirthday, { subcommand: 'clear' }); + assert.equal(lastContent(replies), 'Your birthday has been removed.'); +}); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index 758a631c..b29d15ed 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -127,6 +127,7 @@ test('setBirthday upserts without losing the activity score; clearBirthday repor assert.equal(allProfiles().length, 1); assert.equal(await clearBirthday(db, 's1', 'u1'), true); + assert.equal(await clearBirthday(db, 's1', 'u1'), false); assert.equal(await clearBirthday(db, 's1', 'nobody'), false); assert.equal((await getServerBirthdays(db, 's1')).length, 0); }); From 1d3fc373d9cac3363ce4e6e5db0da47bcb87daa5 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:36:56 +0100 Subject: [PATCH 42/77] Force-fetch partial cached members and show unknown join time Co-Authored-By: Claude Sonnet 5.5 --- src/commands/info/profile.ts | 8 +++++++- src/services/MemberResolver.ts | 4 ++-- tests/birthdays/commands.test.ts | 16 ++++++++++++++++ tests/services/MemberResolver.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/commands/info/profile.ts b/src/commands/info/profile.ts index 2e87c795..4a59ae4e 100644 --- a/src/commands/info/profile.ts +++ b/src/commands/info/profile.ts @@ -38,7 +38,13 @@ export default defineCommand({ { name: 'Member', value: member.toString() }, { name: 'Nickname', value: member.nickname ?? 'Not set' }, { name: 'Username', value: member.user.username }, - { name: 'Joined', value: `` }, + { + name: 'Joined', + value: + member.joinedTimestamp === null + ? 'Unknown' + : ``, + }, { name: 'Onboarding', value: member.pending ? 'Not completed' : 'Completed' }, { name: 'Activity Score', value: String(profile?.activityScore ?? 0) }, ); diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts index a41bba7d..227d1e62 100644 --- a/src/services/MemberResolver.ts +++ b/src/services/MemberResolver.ts @@ -13,11 +13,11 @@ export const resolveMember = async ( ): Promise => { const id = typeof userOrId === 'string' ? userOrId : userOrId.id; const cached = guild.members.cache.get(id); - if (cached) { + if (cached && !cached.partial) { return cached; } try { - return await guild.members.fetch(id); + return await guild.members.fetch(cached ? { user: id, force: true } : id); } catch (error) { const code = errorCode(error); if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER) { diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts index 8fb8498d..71131324 100644 --- a/tests/birthdays/commands.test.ts +++ b/tests/birthdays/commands.test.ts @@ -108,3 +108,19 @@ test('mybirthday clear confirms removal when a birthday was set', async () => { const replies = await exec(mybirthday, { subcommand: 'clear' }); assert.equal(lastContent(replies), 'Your birthday has been removed.'); }); + +test('profile shows Unknown when the join time is unavailable', async () => { + const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: ['m2'] }); + const member = guild.members.cache.get('m2'); + assert.ok(member); + Object.assign(member, { + nickname: null, + pending: false, + joinedTimestamp: null, + user: { username: 'someone' }, + displayAvatarURL: () => 'https://example.com/a.png', + }); + const replies = await exec(profile, { options: { member: { id: 'm2' } }, guild }); + const embed = nth(last(replies).embeds as EmbedBuilder[] | undefined).toJSON(); + assert.equal(embed.fields?.find((f) => f.name === 'Joined')?.value, 'Unknown'); +}); diff --git a/tests/services/MemberResolver.test.ts b/tests/services/MemberResolver.test.ts index e7598d10..85901826 100644 --- a/tests/services/MemberResolver.test.ts +++ b/tests/services/MemberResolver.test.ts @@ -54,3 +54,30 @@ test('resolveTextChannel returns sendable guild text channels only', async () => assert.equal(await resolveTextChannel(guild, 'voice'), null); assert.equal(await resolveTextChannel(guild, 'missing'), null); }); + +test('partial cached member is force-fetched', async () => { + const partial = fakeMember('u4', { partial: true }); + const full = fakeMember('u4'); + const calls: unknown[] = []; + const guild = guildWith({ u4: partial }, async (arg) => { + calls.push(arg); + return full; + }); + assert.equal(await resolveMember(guild, 'u4'), full); + assert.deepEqual(calls, [{ user: 'u4', force: true }]); +}); + +test('partial cached member resolves to null when it is gone', async () => { + const guild = guildWith({ u5: fakeMember('u5', { partial: true }) }, async () => { + throw apiError(10007); + }); + assert.equal(await resolveMember(guild, 'u5'), null); +}); + +test('full cached member is not fetched', async () => { + const member = fakeMember('u6', { partial: false }); + const guild = guildWith({ u6: member }, async () => { + throw new Error('should not fetch'); + }); + assert.equal(await resolveMember(guild, 'u6'), member); +}); From 1a5fd2d01e49649f7acecd77fe8e26e89be471a5 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:37:07 +0100 Subject: [PATCH 43/77] Avoid a duplicate audit when onboarding immediately on join Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberAdd.ts | 2 +- src/services/OnboardingService.ts | 6 +++++- tests/onboarding/OnboardingService.test.ts | 17 +++++++++++++++++ tests/onboarding/events.test.ts | 10 ++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 4184ca38..a94d81de 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -10,7 +10,7 @@ export default defineEvent({ await auditJoin(app, client, member, config); if (!member.user.bot && !member.pending) { - await complete(app, client, member, config); + await complete(app, client, member, config, { skipAudit: true }); } }, }); diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index d9040562..6743595b 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -38,6 +38,8 @@ export interface OnboardingDeps { export interface OnboardingOptions { dryRun?: boolean; + /** Skip the "completed onboarding" audit when the caller already audited this member. */ + skipAudit?: boolean; } const MAX_ERROR_LENGTH = 200; @@ -285,7 +287,9 @@ export async function complete( rolesSkipped: [], }; - if (!dryRun) { + if (!dryRun && options.skipAudit) { + result.audit = 'skipped:already audited on join'; + } else if (!dryRun) { result.audit = await runStep(app, 'audit', member, async () => { const sent = await sendAudit( app, diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts index 401fb2f1..e46d01a0 100644 --- a/tests/onboarding/OnboardingService.test.ts +++ b/tests/onboarding/OnboardingService.test.ts @@ -238,3 +238,20 @@ test('debug summary audit is sent only when debug is enabled', async () => { assert.match(summary, /diagnostics/); assert.match(summary, /skipped:no guest roles configured/); }); + +test('skipAudit omits the completion audit but still lists the step', async () => { + const env = fakeOnboarding({ config: { debug: true } }); + const result = await complete( + app, + env.client, + env.member, + env.config, + { skipAudit: true }, + { renderImage: okImage }, + ); + assert.equal(result.audit, 'skipped:already audited on join'); + assert.equal(env.auditSends.length, 1); + const summary = JSON.stringify(nth(env.auditSends).embeds); + assert.match(summary, /Onboarding diagnostics/); + assert.match(summary, /Audit: skipped:already audited on join/); +}); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 5b871f62..8d72794a 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -47,6 +47,7 @@ test('guildMemberAdd audits the join and onboards immediately when not pending', Object.assign(env.member, { pending: false }); await guildMemberAdd.run(app, stub(env.client), env.member); assert.equal(env.roleAdds.length, 1); + assert.equal(env.auditSends.length, 1); assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /New member joined/); }); @@ -85,3 +86,12 @@ test('guildMemberUpdate ignores bots completing screening', async () => { assert.equal(env.roleAdds.length, 0); assert.equal(env.auditSends.length, 0); }); + +test('guildMemberUpdate sends the completion audit when screening finishes', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(env.member, { pending: false }); + const oldMember = { guild: env.guild, id: 'member-1', pending: true } as unknown as GuildMember; + await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); + assert.equal(env.auditSends.length, 1); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /Member completed onboarding/); +}); From e26ba606351dda09b494abdd19607984d186321c Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:37:20 +0100 Subject: [PATCH 44/77] Fix README boolean setting tables to match the real subcommands Co-Authored-By: Claude Sonnet 5.5 --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fc4fbc0a..6070c5c1 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,10 @@ Use `/config ` to manage server settings. All are admin-only Toggle whether the bot uses Discord's system channel for welcome messages: -| Subcommand | Description | -| ---------- | ---------------------------------------------------------- | -| `enable` | Enable bot system messages in the server's system channel | -| `disable` | Disable bot system messages in the server's system channel | -| `status` | Show the current status | +| Subcommand | Description | +| --------------- | ------------------------------------------------------------------ | +| `set ` | Enable or disable: bot system messages (`enabled` = true or false) | +| `get` | Show whether bot system messages are enabled | #### `/config honeypot` — Honeypot Channel @@ -193,11 +192,10 @@ Roles given to members when they complete onboarding: Toggle diagnostic audits for onboarding runs: -| Subcommand | Description | -| ---------- | ---------------------------------------------------------- | -| `enable` | Post a summary of each onboarding run to the audit channel | -| `disable` | Do not post onboarding summaries | -| `status` | Show the current status | +| Subcommand | Description | +| --------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `set ` | Enable or disable: onboarding diagnostics (`enabled` = true posts a summary of each onboarding run to the audit channel) | +| `get` | Show whether onboarding diagnostics are enabled | ## Behaviour From 155d3719ab807dae8a6439e937bc0f959536df4f Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:38:39 +0100 Subject: [PATCH 45/77] Restore CI job name required by branch protection Co-Authored-By: Claude Haiku 4.5 --- .github/workflows/lint-and-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 1bea9d80..d8195688 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -16,7 +16,7 @@ permissions: jobs: verify: - name: Format, Lint, Typecheck and Test + name: Lint and Build runs-on: ubuntu-latest steps: From c117cbc8968c555ef7210aaa72b77b9893614beb Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:59:26 +0100 Subject: [PATCH 46/77] Validate and bound welcome message length after {member} expansion Co-Authored-By: Claude Sonnet 5.5 --- src/commands/config/groups/welcome.ts | 17 +++++++++-- src/services/OnboardingService.ts | 17 ++++++++++- tests/onboarding/OnboardingService.test.ts | 34 +++++++++++++++++++--- tests/onboarding/commands.test.ts | 28 ++++++++++++++---- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index 4be0e9de..871ae30a 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -1,17 +1,26 @@ import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; import { UserError } from '../../../framework/errors.ts'; import { type ServerConfigPatch, updateConfig } from '../../../services/ConfigService.ts'; -import { complete } from '../../../services/OnboardingService.ts'; +import { + complete, + expandWelcomeMessage, + MAX_WELCOME_LENGTH, +} from '../../../services/OnboardingService.ts'; import { isHttpUrl, render } from '../../../services/WelcomeImage.ts'; export const MAX_WELCOME_MESSAGE_LENGTH = 1500; +/** Worst-case length once `{member}` becomes a mention (snowflakes are at most 20 digits). */ +const expandedLength = (text: string): number => expandWelcomeMessage(text, '0'.repeat(20)).length; + export const validateWelcomeMessage = (text: string): string | null => text.trim() === '' ? 'The welcome message cannot be empty. Use /config welcome clear to remove it.' : text.length > MAX_WELCOME_MESSAGE_LENGTH ? `The welcome message is too long (${text.length} characters, maximum ${MAX_WELCOME_MESSAGE_LENGTH}).` - : null; + : expandedLength(text) > MAX_WELCOME_LENGTH + ? `Once every {member} is expanded to a mention the message would be ${expandedLength(text)} characters, over Discord's ${MAX_WELCOME_LENGTH} character limit. Shorten it or use fewer {member}.` + : null; export const validateImageUrl = (url: string): string | null => isHttpUrl(url) ? null : 'The image URL must start with http:// or https://'; @@ -24,7 +33,9 @@ const setMessage = async (ctx: CommandContext) => { if (problem) { throw new UserError(problem); } - ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { welcomeMessage: text }); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + welcomeMessage: text, + }); await ctx.reply({ content: 'Welcome message saved. Use `{member}` in the text to mention the new member.', allowedMentions: NO_MENTIONS, diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 6743595b..65feb7fa 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -44,6 +44,12 @@ export interface OnboardingOptions { const MAX_ERROR_LENGTH = 200; +/** Discord's message content limit. */ +export const MAX_WELCOME_LENGTH = 2000; + +export const expandWelcomeMessage = (template: string, memberId: Snowflake): string => + template.replaceAll('{member}', `<@${memberId}>`); + const messageOf = (error: unknown): string => { const text = error instanceof Error ? error.message : String(error); return text.length > MAX_ERROR_LENGTH ? `${text.slice(0, MAX_ERROR_LENGTH)}…` : text; @@ -219,7 +225,16 @@ async function sendWelcome( const payload: WelcomePayload = {}; if (text !== '') { - payload.content = text.replaceAll('{member}', `<@${member.id}>`); + let content = expandWelcomeMessage(text, member.id); + if (content.length > MAX_WELCOME_LENGTH) { + app.logger.warn('Welcome message exceeds the Discord limit after expansion; truncating', { + guild: member.guild.id, + member: member.id, + length: content.length, + }); + content = `${content.slice(0, MAX_WELCOME_LENGTH - 1)}…`; + } + payload.content = content; } if (url) { try { diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts index e46d01a0..be6ae625 100644 --- a/tests/onboarding/OnboardingService.test.ts +++ b/tests/onboarding/OnboardingService.test.ts @@ -79,7 +79,10 @@ test('guest roles are added in one call and unassignable ones filtered', async ( }); test('no assignable roles skips the roles step without calling add', async () => { - const env = fakeOnboarding({ roles: [role('high', 10)], config: { guestRoleIds: 'high' } }); + const env = fakeOnboarding({ + roles: [role('high', 10)], + config: { guestRoleIds: 'high' }, + }); const result = await complete(app, env.client, env.member, env.config); assert.match(result.roles, /^skipped:/); assert.equal(env.roleAdds.length, 0); @@ -115,12 +118,17 @@ test('audit failure does not stop later steps', async () => { test('welcome replaces every {member} and does not ping others', async () => { const env = fakeOnboarding({ - config: { systemMessagesEnabled: true, welcomeMessage: '{member} hello {member}!' }, + config: { + systemMessagesEnabled: true, + welcomeMessage: '{member} hello {member}!', + }, }); const result = await complete(app, env.client, env.member, env.config); assert.equal(result.welcome, 'done'); assert.equal(nth(env.systemSends).content, '<@member-1> hello <@member-1>!'); - assert.deepEqual(nth(env.systemSends).allowedMentions, { users: ['member-1'] }); + assert.deepEqual(nth(env.systemSends).allowedMentions, { + users: ['member-1'], + }); }); test('welcome skip reasons', async () => { @@ -226,7 +234,10 @@ test('dryRun makes no role, send or audit calls and returns the payload', async }); test('debug summary audit is sent only when debug is enabled', async () => { - const off = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); + const off = fakeOnboarding({ + roles: [role('r1')], + config: { guestRoleIds: 'r1' }, + }); await complete(app, off.client, off.member, off.config); assert.equal(off.auditSends.length, 1); @@ -255,3 +266,18 @@ test('skipAudit omits the completion audit but still lists the step', async () = assert.match(summary, /Onboarding diagnostics/); assert.match(summary, /Audit: skipped:already audited on join/); }); + +test('a legacy welcome template that expands past 2000 characters is truncated, not failed', async () => { + const env = fakeOnboarding({ + config: { + systemMessagesEnabled: true, + welcomeMessage: '{member}'.repeat(250), + }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.equal(result.welcome, 'done'); + const content = nth(env.systemSends).content ?? ''; + assert.equal(content.length, 2000); + assert.ok(content.endsWith('…')); + assert.ok(app.logsAt('warn').some((entry) => /truncating/.test(entry.message))); +}); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index 623cc039..efb229eb 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -27,7 +27,9 @@ const handler = (group: typeof NewRolesGroup, name: string): Handler => group.subcommands.find((s) => s.name === name)!.run; test('newroles set rejects @everyone, managed and too-high roles and saves nothing', async () => { - const env = fakeOnboarding({ roles: [role('ok', 1), role('m', 1, true), role('hi', 10)] }); + const env = fakeOnboarding({ + roles: [role('ok', 1), role('m', 1, true), role('hi', 10)], + }); const { ctx, replies } = fakeCommandContext( app, { @@ -56,7 +58,11 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { const a = env.guild.roles.cache.get('a'); const { ctx, replies } = fakeCommandContext( app, - { 'role-one': a, 'role-two': env.guild.roles.cache.get('b'), 'role-three': a }, + { + 'role-one': a, + 'role-two': env.guild.roles.cache.get('b'), + 'role-three': a, + }, env, ); await handler(NewRolesGroup, 'set')(ctx); @@ -66,7 +72,10 @@ test('newroles set saves valid roles, dedupes, and audits', async () => { }); test('newroles get is read-only: no audit, marks missing roles', async () => { - const env = fakeOnboarding({ roles: [role('a')], config: { guestRoleIds: 'a,gone' } }); + const env = fakeOnboarding({ + roles: [role('a')], + config: { guestRoleIds: 'a,gone' }, + }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(NewRolesGroup, 'get')(ctx); assert.equal(env.auditSends.length, 0); @@ -81,7 +90,11 @@ test('newroles get is read-only: no audit, marks missing roles', async () => { test('newroles clear nulls roles and audits', async () => { const env = fakeOnboarding({ config: { guestRoleIds: 'a' } }); - createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1', guestRoleIds: 'a' }); + createConfig({ + serverId: 'guild-1', + auditChannelId: 'audit-1', + guestRoleIds: 'a', + }); const { ctx } = fakeCommandContext(app, {}, env); await handler(NewRolesGroup, 'clear')(ctx); assert.equal(ctx.config.guestRoleIds, null); @@ -97,6 +110,8 @@ test('welcome validators', () => { assert.equal(validateWelcomeMessage('hello'), null); assert.ok(validateWelcomeMessage('x'.repeat(MAX_WELCOME_MESSAGE_LENGTH + 1))); assert.ok(validateWelcomeMessage(' ')); + assert.ok(validateWelcomeMessage('{member}'.repeat(150))); + assert.equal(validateWelcomeMessage('{member}'.repeat(80)), null); }); test('welcome set-image rejects non-http URLs without saving', async () => { @@ -141,7 +156,10 @@ test('welcome preview explains why nothing would be sent', async () => { test('welcome preview shows the text without sending', async () => { const env = fakeOnboarding({ - config: { welcomeMessage: 'hi {member} {member}', systemMessagesEnabled: true }, + config: { + welcomeMessage: 'hi {member} {member}', + systemMessagesEnabled: true, + }, }); const { ctx, replies } = fakeCommandContext(app, {}, env); await handler(WelcomeGroup as never, 'preview')(ctx); From 5442f2579568dfc95033d3db5d13e869dad122bd Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:00:58 +0100 Subject: [PATCH 47/77] Onboard members completing screening when the old state is unknown Infer from guest roles when the previous member is partial; log at info when that is impossible because no guest roles are configured. Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberUpdate.ts | 31 ++++++++++++++++++----- tests/onboarding/events.test.ts | 45 ++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 40aa1211..5bcda76d 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,18 +1,35 @@ import { defineEvent } from '../framework/event.ts'; -import { complete, completedScreening } from '../services/OnboardingService.ts'; +import { complete, completedScreening, parseRoleIds } from '../services/OnboardingService.ts'; import { getConfig } from '../services/ConfigService.ts'; export default defineEvent({ name: 'guildMemberUpdate', run: async (app, client, oldMember, newMember) => { - if (oldMember.partial) { - app.logger.debug('Skipping onboarding: previous member state unknown', { - guild: newMember.guild.id, - member: newMember.id, - }); + if (newMember.user.bot) { return; } - if (newMember.user.bot) { + const ids = { guild: newMember.guild.id, member: newMember.id }; + + if (oldMember.partial) { + // The previous state is unknown, so infer "finished screening but not yet onboarded" + // from the guest roles: onboarding hands them out, so holding one means already done. + if (newMember.pending !== false) { + return; + } + const config = await getConfig(app, newMember.guild.id); + const guestRoleIds = parseRoleIds(config.guestRoleIds); + if (guestRoleIds.length === 0) { + app.logger.info( + 'Skipping onboarding: previous member state unknown and no guest roles', + ids, + ); + return; + } + if (guestRoleIds.some((id) => newMember.roles.cache.has(id))) { + app.logger.debug('Skipping onboarding: member already holds a guest role', ids); + return; + } + await complete(app, client, newMember, config); return; } if (!completedScreening(oldMember, newMember)) { diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 8d72794a..92cadd2f 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -37,9 +37,48 @@ test('guildMemberUpdate onboards only on pending true to false', async () => { assert.equal(await update({ pending: false }, true), 0); }); -test('guildMemberUpdate skips when the old member is partial', async () => { - assert.equal(await update({ partial: true, pending: null }, false), 0); - assert.equal(await update({ partial: true, pending: true }, false), 0); +const partialUpdate = async (opts: { held?: string[]; bot?: boolean; pending?: boolean }) => { + const env = fakeOnboarding({ roles: [role('r1')] }); + const held = new Map((opts.held ?? []).map((id) => [id, {}])); + Object.assign(env.member, { + pending: opts.pending ?? false, + roles: { cache: held, add: async (ids: string[]) => void env.roleAdds.push(ids) }, + ...(opts.bot ? { user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }) } : {}), + }); + const oldMember = { guild: env.guild, id: 'member-1', partial: true } as unknown as GuildMember; + await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); + return env.roleAdds.length; +}; + +test('guildMemberUpdate onboards a partial old member who is not pending and lacks guest roles', async () => { + assert.equal(await partialUpdate({}), 1); +}); + +test('guildMemberUpdate skips a partial old member who still has to pass screening', async () => { + assert.equal(await partialUpdate({ pending: true }), 0); +}); + +test('guildMemberUpdate skips a partial old member who already holds a guest role', async () => { + assert.equal(await partialUpdate({ held: ['r1'] }), 0); +}); + +test('guildMemberUpdate skips a partial old member when no guest roles are configured', async () => { + clearConfigs(); + createConfig({ serverId: 'guild-1', guestRoleIds: null, auditChannelId: 'audit-1' }); + assert.equal(await partialUpdate({}), 0); + assert.ok( + app + .logsAt('info') + .some( + (e) => + /previous member state unknown/.test(e.message) && + JSON.stringify(e.context).includes('member-1'), + ), + ); +}); + +test('guildMemberUpdate skips a bot with a partial old member', async () => { + assert.equal(await partialUpdate({ bot: true }), 0); }); test('guildMemberAdd audits the join and onboards immediately when not pending', async () => { From e4406f833409aea15ba2c21a371f1bbb573c1cd3 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:01:13 +0100 Subject: [PATCH 48/77] Scope the command registration hash by application id Warn when registering that the other scope may hold duplicate commands. Co-Authored-By: Claude Sonnet 5.5 --- src/framework/registrar.ts | 16 ++++++++++--- tests/registrar.test.ts | 46 +++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts index ab4da882..25f110b2 100644 --- a/src/framework/registrar.ts +++ b/src/framework/registrar.ts @@ -12,10 +12,14 @@ export interface CommandScope { stateKey: string; } -export const commandScope = (env: Pick): CommandScope => +/** The key includes the application id so a different bot sharing this database re-registers. */ +export const commandScope = (env: Pick): CommandScope => env.devGuildId - ? { label: `dev guild ${env.devGuildId}`, stateKey: `commandsHash:guild:${env.devGuildId}` } - : { label: 'globally', stateKey: 'commandsHash:global' }; + ? { + label: `dev guild ${env.devGuildId}`, + stateKey: `commandsHash:${env.clientId}:guild:${env.devGuildId}`, + } + : { label: 'globally', stateKey: `commandsHash:${env.clientId}:global` }; const canonical = (value: unknown): unknown => { if (Array.isArray(value)) { @@ -73,6 +77,12 @@ export async function syncCommands( try { const count = await put(env, commands); setState(db, scope.stateKey, hash, clock()); + logger.warn( + env.devGuildId + ? 'Commands were registered to the dev guild only; global commands may also exist and show up as duplicates. Run `npm run deploy:commands` without DEV_GUILD_ID to manage global commands, or remove them manually.' + : 'Commands were registered globally; guild-scoped commands from an earlier DEV_GUILD_ID run may still exist and show up as duplicates. Remove them manually if so.', + { scope: scope.label }, + ); logger.info(`Registered ${count} application commands ${scope.label}`, { hash: hash.slice(0, 12), }); diff --git a/tests/registrar.test.ts b/tests/registrar.test.ts index fd2e7406..b90740cd 100644 --- a/tests/registrar.test.ts +++ b/tests/registrar.test.ts @@ -37,8 +37,14 @@ test('hashCommands is stable and changes with command content', () => { }); test('commandScope distinguishes global and dev guild targets', () => { - assert.equal(commandScope({ devGuildId: undefined }).stateKey, 'commandsHash:global'); - assert.equal(commandScope({ devGuildId: '42' }).stateKey, 'commandsHash:guild:42'); + assert.equal( + commandScope({ devGuildId: undefined, clientId: '1' }).stateKey, + 'commandsHash:1:global', + ); + assert.equal( + commandScope({ devGuildId: '42', clientId: '1' }).stateKey, + 'commandsHash:1:guild:42', + ); }); test('syncCommands registers once, then skips while the hash is unchanged', async () => { @@ -48,7 +54,7 @@ test('syncCommands registers once, then skips while the hash is unchanged', asyn assert.equal(await syncCommands(app, commands, put), 'registered'); assert.equal(await syncCommands(app, commands, put), 'unchanged'); assert.deepEqual(calls, [1]); - assert.equal(getState(app.db, 'commandsHash:global'), hashCommands(commands)); + assert.equal(getState(app.db, 'commandsHash:1:global'), hashCommands(commands)); assert.ok(app.logsAt('info').some((entry) => /unchanged, skipping/.test(entry.message))); assert.equal(await syncCommands(app, [command('one'), command('two')], put), 'registered'); @@ -64,7 +70,7 @@ test('syncCommands tracks the hash per scope', async () => { assert.equal(await syncCommands(dev, commands, put), 'registered'); assert.equal(await syncCommands(dev, commands, put), 'unchanged'); assert.deepEqual(calls, [1, 1]); - assert.equal(getState(app.db, 'commandsHash:guild:42'), hashCommands(commands)); + assert.equal(getState(app.db, 'commandsHash:1:guild:42'), hashCommands(commands)); }); test('syncCommands does not store the hash when registration fails', async () => { @@ -72,6 +78,36 @@ test('syncCommands does not store the hash when registration fails', async () => throw new Error('discord down'); }; assert.equal(await syncCommands(app, [command('one')], failing), 'failed'); - assert.equal(getState(app.db, 'commandsHash:global'), undefined); + assert.equal(getState(app.db, 'commandsHash:1:global'), undefined); assert.equal(app.logsAt('error').length, 1); }); + +test('syncCommands re-registers when the application id differs', async () => { + const { calls, put } = recorder(); + const commands = [command('one')]; + assert.equal(await syncCommands(app, commands, put), 'registered'); + + const other = { ...app, env: Object.freeze({ ...app.env, clientId: '2' }) }; + assert.equal(await syncCommands(other, commands, put), 'registered'); + assert.equal(await syncCommands(other, commands, put), 'unchanged'); + assert.deepEqual(calls, [1, 1]); + assert.equal(getState(app.db, 'commandsHash:2:global'), hashCommands(commands)); +}); + +test('syncCommands ignores hashes stored under the old key format', async () => { + const { calls, put } = recorder(); + const commands = [command('one')]; + app.db.$client.exec( + `INSERT INTO BotState (key, value, updatedAt) VALUES ('commandsHash:global', '${hashCommands(commands)}', '2024-01-01')`, + ); + assert.equal(await syncCommands(app, commands, put), 'registered'); + assert.deepEqual(calls, [1]); +}); + +test('syncCommands warns that the other scope may hold duplicate commands', async () => { + const { put } = recorder(); + const dev = { ...app, env: Object.freeze({ ...app.env, devGuildId: '42' }) }; + await syncCommands(dev, [command('one')], put); + assert.ok(app.logsAt('warn').some((e) => /global commands may also exist/.test(e.message))); + assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); +}); From b7bb39d615f50182bf97bf280001853303079e0d Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:01:32 +0100 Subject: [PATCH 49/77] Backfill NULL activity scores and drop unusable UserProfiles rows Co-Authored-By: Claude Sonnet 5.5 --- src/db/migrations/005-userprofile-backfill.ts | 20 +++++++++++ src/db/migrator.ts | 2 ++ tests/database/migrations.test.ts | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/db/migrations/005-userprofile-backfill.ts diff --git a/src/db/migrations/005-userprofile-backfill.ts b/src/db/migrations/005-userprofile-backfill.ts new file mode 100644 index 00000000..0c72933a --- /dev/null +++ b/src/db/migrations/005-userprofile-backfill.ts @@ -0,0 +1,20 @@ +import type { Database } from 'better-sqlite3'; +import type { Logger } from '../../utilities/Logger.ts'; + +/** Brings legacy NULLs in line with the schema (activityScore and the ids are NOT NULL). */ +export function up(db: Database, logger: Logger): void { + const orphans = ( + db + .prepare( + 'SELECT COUNT(*) AS count FROM `UserProfiles` WHERE serverId IS NULL OR userId IS NULL', + ) + .get() as { count: number } + ).count; + if (orphans > 0) { + logger.warn('Deleting UserProfiles rows with no server or user id; they cannot be read', { + count: orphans, + }); + } + db.exec('UPDATE `UserProfiles` SET activityScore = 0 WHERE activityScore IS NULL'); + db.exec('DELETE FROM `UserProfiles` WHERE serverId IS NULL OR userId IS NULL'); +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 3c4dff03..4f9ef7df 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -4,6 +4,7 @@ import * as baseline from './migrations/001-baseline.ts'; import * as userProfileUnique from './migrations/002-userprofile-unique.ts'; import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking.ts'; import * as botState from './migrations/004-bot-state.ts'; +import * as userProfileBackfill from './migrations/005-userprofile-backfill.ts'; export interface Migration { name: string; @@ -15,6 +16,7 @@ export const migrations: Migration[] = [ { name: '002-userprofile-unique', up: userProfileUnique.up }, { name: '003-serverconfig-access-tracking', up: serverConfigAccessTracking.up }, { name: '004-bot-state', up: botState.up }, + { name: '005-userprofile-backfill', up: userProfileBackfill.up }, ]; /** Applies pending migrations in order, each in its own transaction, and returns their names. */ diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index 0953f469..b3936be9 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -15,6 +15,7 @@ const ALL = [ '002-userprofile-unique', '003-serverconfig-access-tracking', '004-bot-state', + '005-userprofile-backfill', ]; const LEGACY_SERVER_CONFIGS = @@ -248,3 +249,36 @@ test('002 rolls back entirely when it fails', () => { assert.deepEqual(allRows(db, 'SELECT name FROM `SequelizeMeta`'), [{ name: '001-baseline' }]); }); }); + +test('005 backfills NULL scores, removes unusable rows with a warning, and is idempotent', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + const at = "'2024-01-01 00:00:00.000 +00:00'"; + db.exec( + `INSERT INTO \`UserProfiles\` (serverId, userId, activityScore, createdAt, updatedAt) VALUES + ('s1', 'u1', NULL, ${at}, ${at}), + ('s1', 'u2', 7, ${at}, ${at}), + (NULL, 'u3', 4, ${at}, ${at}), + ('s1', NULL, 4, ${at}, ${at})`, + ); + runUpTo(db, '004-bot-state'); + logs.length = 0; + + assert.deepEqual(migrate(db, logger), ['005-userprofile-backfill']); + assert.deepEqual( + allRows(db, 'SELECT userId, activityScore FROM `UserProfiles` ORDER BY userId'), + [ + { userId: 'u1', activityScore: 0 }, + { userId: 'u2', activityScore: 7 }, + ], + ); + const warnings = logs.filter((entry) => entry.level === 'warn'); + assert.equal(warnings.length, 1); + assert.deepEqual(nth(warnings).context, { count: 2 }); + + logs.length = 0; + assert.deepEqual(migrate(db, logger), []); + assert.equal(logs.filter((entry) => entry.level === 'warn').length, 0); + }); +}); From 07d880a79be8fb5e933bc08469d1abd704dc2bf1 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:03:51 +0100 Subject: [PATCH 50/77] Correct README and add 2.x upgrade notes Co-Authored-By: Claude Haiku 4.5 --- README.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6070c5c1..54f02f22 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The invite URL should include all these permissions. You can generate one in the Use `DEV_GUILD_ID` in `.env` to test commands instantly in a single guild instead of waiting 1 hour for global registration. -On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). To force a registration without starting the bot, run: +On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). The hash is stored per application (`CLIENT_ID`) and per scope, so the first start after upgrading re-registers once. Switching between global and dev-guild registration can leave commands in the other scope; nothing is deleted automatically — the bot logs a warning. To force a registration without starting the bot, or to clean up orphaned commands, run: ```bash npm run deploy:commands @@ -162,13 +162,13 @@ Configure a channel that automatically bans members who post in it: #### `/config welcome` — Welcome Messages & Images -| Subcommand | Description | -| -------------------- | --------------------------------------------------------------------------------------- | -| `set-message ` | Set the welcome message (max 1500 characters). Use `{member}` to mention the new member | -| `set-image ` | Set the background image for welcome images (http/https URL) | -| `get` | Show the welcome message and image URL | -| `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | -| `preview` | Preview the welcome message as it would be sent for you | +| Subcommand | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `set-message ` | Set the welcome message (max 1500 characters). Use `{member}` to mention the new member. Rejected if, after expanding every `{member}` to a mention, the message would exceed Discord's 2000-character limit | +| `set-image ` | Set the background image for welcome images (http/https URL) | +| `get` | Show the welcome message and image URL | +| `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | +| `preview` | Preview the welcome message as it would be sent for you | #### `/config newroles` — New Member Roles @@ -205,7 +205,7 @@ When a member joins without screening enabled, or completes server screening: 1. **Audit**: A "member joined" audit is posted to the configured audit channel (if set) 2. **Guest Roles**: Configured new-member roles are assigned (if any are set) -3. **Welcome**: A welcome message is posted to the system channel or audit channel, optionally with a background image +3. **Welcome**: A welcome message is posted to the server's system channel, optionally with a background image (only if `/config sysmsgs` is enabled) 4. **Debug**: If debug mode is enabled, a diagnostic summary is posted to the audit channel showing what succeeded, failed, or was skipped Roles are only assigned if: @@ -216,6 +216,8 @@ Roles are only assigned if: If a role cannot be assigned, the error is logged and the onboarding continues for other roles and steps. +**Unknown Previous State**: If the bot didn't have the member's previous state cached (e.g. they joined while the bot was offline), a member who is no longer pending is onboarded only when new-member roles are configured and they have none of them. Otherwise the update is skipped and logged at info level. Bots are never onboarded (their join is still audited). + ### Audit Log Member events are posted to the configured audit channel: @@ -245,7 +247,7 @@ Members can set their birthday using `/mybirthday set `. Birthdays **Birthday Announcements**: On the configured schedule (default 9:00 AM daily), the bot checks all servers and posts birthday announcements in the announcements channel for members with birthdays today. If a member's birthday is Feb 29 and it's not a leap year, they are celebrated on Feb 28 instead. -**Birthday Calendar**: If configured, a pinned calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job. Members can create a new calendar with `/config birthdays create`, and it is recreated if the message or channel is deleted. +**Birthday Calendar**: If configured, a calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job by editing the message. If the message or channel is missing, the refresh logs a warning and an admin must run `/config birthdays create` again to create a new calendar. **Timezones**: All birthday calculations use the `BOT_TIMEZONE` setting. Members' birthdays are stored as month/day only (no year), and the next occurrence is calculated relative to the bot's configured time zone. @@ -266,9 +268,10 @@ All data is persisted in SQLite at the path specified by `DB_PATH` (default: `da Migrations run automatically on startup. Current migrations: - **001-baseline** — Initial schema (ServerConfigs and UserProfiles tables) -- **002-userprofile-unique** — Adds a unique constraint on (serverId, userId). If duplicates exist, they are merged: the profile with the most recent birthday is kept, and activity scores are summed. **Backups are strongly recommended before upgrading from v2 to v3** because this migration is permanent and irreversible. +- **002-userprofile-unique** — Adds a unique constraint on (serverId, userId). If duplicates exist, they are merged: the profile with the most recent birthday is kept, and the maximum activity score is preserved. **Backups are strongly recommended before upgrading from v2 to v3** because this migration is permanent and irreversible. - **003-serverconfig-access-tracking** — Adds access failure tracking for orphaned guild detection - **004-bot-state** — Adds the `BotState` key/value table (currently stores the registered command hash) +- **005-userprofile-backfill** — Sets NULL activity scores to 0 and deletes profile rows with no server or user id, logging how many ### What Gets Deleted @@ -280,6 +283,68 @@ Migrations run automatically on startup. Current migrations: **Note**: Transient errors (network timeouts, API rate limiting, temporary outages) never count toward the purge threshold. Only permanent failures (guild deleted, bot kicked) count. +## Upgrading from 2.x + +When upgrading to v3, note the following: + +### Backups + +Back up the SQLite file before the first start. Migrations 002 and 005 permanently remove rows: + +- **002-userprofile-unique**: Deduplicates UserProfiles and merges activity scores +- **005-userprofile-backfill**: Deletes profiles with no server or user id + +```bash +# Before starting v3 +cp data/dingir.sqlite data/dingir.sqlite.backup +``` + +### Container User + +The v3 image runs as the non-root `node` user (uid 1000), but v2 ran as root. If you have an existing data volume or bind mount, it is root-owned and SQLite will fail to open it. + +Either keep running as root via Docker Compose: + +```yaml +services: + dingir: + image: ghcr.io/brentoates/dingir:3.0.0 + user: '0:0' + volumes: + - ./data:/usr/src/app/data +``` + +Or (preferred, non-root) fix ownership once: + +```bash +# For a bind mount +sudo chown -R 1000:1000 ./data + +# For a named volume +docker run --rm -v :/data alpine chown -R 1000:1000 /data +``` + +### Command Registration + +Commands are re-registered once on the first start after upgrading. The command set changed in v3: + +**Renamed subcommands**: + +- `/simulate screen` → `/simulate onboard` + +**New or changed subcommands**: + +- `/mybirthday set|clear` (unchanged) +- `/config welcome set-message|set-image|get|clear|preview` (unchanged) +- `/config newroles set` (now takes up to 3 roles) +- `/config birthdays create|sync|remove` (unchanged) +- `/config sysmsgs set |get` (changed from toggle) +- `/config debug set |get` (changed from toggle) + +### Releases + +Docker images are published by pushing a git tag (e.g. `git tag 3.0.0 && git push origin 3.0.0`), not by merging to main. + ## Contributing ### Project Layout From 8a2d9a65e4fe60d6d1b2eecf431cd015170cd325 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:10:02 +0100 Subject: [PATCH 51/77] Persist onboarding completion and gate unknown-state onboarding on it Record onboardedAt on UserProfiles when onboarding runs, skip repeat onboarding, and replace the guest-role heuristic for partial old members with a not-pending, unrecorded, recently-joined check. Co-Authored-By: Claude Sonnet 5.5 --- .../006-userprofile-onboarded-at.ts | 11 +++ src/db/migrator.ts | 2 + src/db/schema.ts | 1 + src/events/guildMemberUpdate.ts | 37 +++++---- src/services/OnboardingService.ts | 6 ++ src/services/UserProfileService.ts | 23 ++++++ tests/database/migrations.test.ts | 30 +++++++- tests/onboarding/events.test.ts | 75 ++++++++++++++----- 8 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 src/db/migrations/006-userprofile-onboarded-at.ts diff --git a/src/db/migrations/006-userprofile-onboarded-at.ts b/src/db/migrations/006-userprofile-onboarded-at.ts new file mode 100644 index 00000000..232472b7 --- /dev/null +++ b/src/db/migrations/006-userprofile-onboarded-at.ts @@ -0,0 +1,11 @@ +import type { Database } from 'better-sqlite3'; + +/** Records when a member's onboarding was last run so replayed gateway events stay idempotent. */ +export function up(db: Database): void { + const columns = new Set( + (db.pragma('table_info(`UserProfiles`)') as { name: string }[]).map((column) => column.name), + ); + if (!columns.has('onboardedAt')) { + db.exec('ALTER TABLE `UserProfiles` ADD COLUMN `onboardedAt` DATETIME'); + } +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 4f9ef7df..d5f44cfd 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -5,6 +5,7 @@ import * as userProfileUnique from './migrations/002-userprofile-unique.ts'; import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking.ts'; import * as botState from './migrations/004-bot-state.ts'; import * as userProfileBackfill from './migrations/005-userprofile-backfill.ts'; +import * as userProfileOnboardedAt from './migrations/006-userprofile-onboarded-at.ts'; export interface Migration { name: string; @@ -17,6 +18,7 @@ export const migrations: Migration[] = [ { name: '003-serverconfig-access-tracking', up: serverConfigAccessTracking.up }, { name: '004-bot-state', up: botState.up }, { name: '005-userprofile-backfill', up: userProfileBackfill.up }, + { name: '006-userprofile-onboarded-at', up: userProfileOnboardedAt.up }, ]; /** Applies pending migrations in order, each in its own transaction, and returns their names. */ diff --git a/src/db/schema.ts b/src/db/schema.ts index ef82bb62..6085a9c5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -50,6 +50,7 @@ export const userProfiles = sqliteTable( birthdayMonth: integer('birthdayMonth'), birthdayDay: integer('birthdayDay'), activityScore: integer('activityScore').notNull().default(0), + onboardedAt: datetime('onboardedAt'), createdAt: datetime('createdAt') .notNull() .$defaultFn(() => new Date()), diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 5bcda76d..b02a55c5 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,6 +1,10 @@ import { defineEvent } from '../framework/event.ts'; -import { complete, completedScreening, parseRoleIds } from '../services/OnboardingService.ts'; +import { complete, completedScreening } from '../services/OnboardingService.ts'; import { getConfig } from '../services/ConfigService.ts'; +import { getOnboardedAt } from '../services/UserProfileService.ts'; + +/** How recently a member must have joined for an unknown-previous-state update to onboard them. */ +const RECENT_JOIN_MS = 7 * 24 * 60 * 60 * 1000; export default defineEvent({ name: 'guildMemberUpdate', @@ -9,32 +13,39 @@ export default defineEvent({ return; } const ids = { guild: newMember.guild.id, member: newMember.id }; + const skip = (reason: string): void => { + app.logger.info(`Skipping onboarding: ${reason}`, ids); + }; if (oldMember.partial) { - // The previous state is unknown, so infer "finished screening but not yet onboarded" - // from the guest roles: onboarding hands them out, so holding one means already done. + // The previous state is unknown, so only trust "finished screening, not yet onboarded" + // when the member is no longer pending, never onboarded, and joined recently. if (newMember.pending !== false) { return; } - const config = await getConfig(app, newMember.guild.id); - const guestRoleIds = parseRoleIds(config.guestRoleIds); - if (guestRoleIds.length === 0) { - app.logger.info( - 'Skipping onboarding: previous member state unknown and no guest roles', - ids, - ); + if (await getOnboardedAt(app.db, newMember.guild.id, newMember.id)) { + skip('previous member state unknown and onboarding already recorded'); return; } - if (guestRoleIds.some((id) => newMember.roles.cache.has(id))) { - app.logger.debug('Skipping onboarding: member already holds a guest role', ids); + const joined = newMember.joinedTimestamp; + if ( + joined === null || + joined === undefined || + app.clock().getTime() - joined > RECENT_JOIN_MS + ) { + skip('previous member state unknown and member did not join recently'); return; } - await complete(app, client, newMember, config); + await complete(app, client, newMember, await getConfig(app, newMember.guild.id)); return; } if (!completedScreening(oldMember, newMember)) { return; } + if (await getOnboardedAt(app.db, newMember.guild.id, newMember.id)) { + skip('onboarding already recorded'); + return; + } const config = await getConfig(app, newMember.guild.id); await complete(app, client, newMember, config); diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 65feb7fa..7d681c1a 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -5,6 +5,7 @@ import type { ServerConfig } from '../db/schema.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; import { memberAuditEmbed } from './AuditEmbed.ts'; import { sendAudit } from './AuditService.ts'; +import { markOnboarded } from './UserProfileService.ts'; import { WelcomeImage, type WelcomeImageRenderer } from './WelcomeImage.ts'; /** 'done' | 'skipped:' | 'failed:' */ @@ -342,6 +343,11 @@ export async function complete( result.debug = 'skipped:dry run'; } + if (!dryRun) { + // Record the attempt whatever the individual steps did, so replayed events don't repeat it. + await markOnboarded(app.db, member.guild.id, member.id, app.clock()); + } + return result; } diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 71fbe82c..2f1ef6bf 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -125,3 +125,26 @@ export async function deleteUsersByServer(db: Db, serverId: Snowflake): Promise< const result = db.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); return result.changes > 0; } + +export async function markOnboarded( + db: Db, + serverId: Snowflake, + userId: Snowflake, + at: Date, +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, onboardedAt: at }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { onboardedAt: at, updatedAt: at }, + }) + .run(); +} + +export async function getOnboardedAt( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + return (await findUserProfile(db, serverId, userId))?.onboardedAt ?? null; +} diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index b3936be9..b2753e26 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -16,6 +16,7 @@ const ALL = [ '003-serverconfig-access-tracking', '004-bot-state', '005-userprofile-backfill', + '006-userprofile-onboarded-at', ]; const LEGACY_SERVER_CONFIGS = @@ -89,6 +90,7 @@ test('migrations on an empty database create the expected schema', () => { 'activityScore', 'createdAt', 'updatedAt', + 'onboardedAt', ]); assert.ok(indexesOf(db, 'UserProfiles').find((i) => i.name === INDEX)?.unique); @@ -265,7 +267,10 @@ test('005 backfills NULL scores, removes unusable rows with a warning, and is id runUpTo(db, '004-bot-state'); logs.length = 0; - assert.deepEqual(migrate(db, logger), ['005-userprofile-backfill']); + assert.deepEqual(migrate(db, logger), [ + '005-userprofile-backfill', + '006-userprofile-onboarded-at', + ]); assert.deepEqual( allRows(db, 'SELECT userId, activityScore FROM `UserProfiles` ORDER BY userId'), [ @@ -282,3 +287,26 @@ test('005 backfills NULL scores, removes unusable rows with a warning, and is id assert.equal(logs.filter((entry) => entry.level === 'warn').length, 0); }); }); + +test('006 adds a nullable onboardedAt column and is idempotent', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + const at = "'2024-01-01 00:00:00.000 +00:00'"; + db.exec( + `INSERT INTO \`UserProfiles\` (serverId, userId, createdAt, updatedAt) VALUES ('s1', 'u1', ${at}, ${at})`, + ); + runUpTo(db, '005-userprofile-backfill'); + + assert.deepEqual(migrate(db, logger), ['006-userprofile-onboarded-at']); + assert.ok(columnsOf(db, 'UserProfiles').includes('onboardedAt')); + assert.deepEqual(allRows(db, 'SELECT onboardedAt FROM `UserProfiles`'), [ + { onboardedAt: null }, + ]); + + assert.deepEqual(migrate(db, logger), []); + const migration = nth(migrations.filter((m) => m.name === '006-userprofile-onboarded-at')); + assert.doesNotThrow(() => migration.up(db, logger)); + assert.equal(columnsOf(db, 'UserProfiles').filter((c) => c === 'onboardedAt').length, 1); + }); +}); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 92cadd2f..8556711e 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -8,6 +8,8 @@ import { stub } from '../fakes/discord.ts'; import { fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; +import { complete } from '../../src/services/OnboardingService.ts'; +import { getOnboardedAt, markOnboarded } from '../../src/services/UserProfileService.ts'; import { nth } from '../helpers/assertions.ts'; const app = createTestApp(); @@ -19,6 +21,7 @@ after(() => { beforeEach(async () => { clearConfigs(); + app.db.$client.exec('DELETE FROM `UserProfiles`'); createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); }); @@ -37,50 +40,84 @@ test('guildMemberUpdate onboards only on pending true to false', async () => { assert.equal(await update({ pending: false }, true), 0); }); -const partialUpdate = async (opts: { held?: string[]; bot?: boolean; pending?: boolean }) => { +const DAY = 24 * 60 * 60 * 1000; + +const partialUpdate = async (opts: { + bot?: boolean; + pending?: boolean; + joinedDaysAgo?: number; + recorded?: boolean; +}) => { const env = fakeOnboarding({ roles: [role('r1')] }); - const held = new Map((opts.held ?? []).map((id) => [id, {}])); Object.assign(env.member, { pending: opts.pending ?? false, - roles: { cache: held, add: async (ids: string[]) => void env.roleAdds.push(ids) }, + joinedTimestamp: app.clock().getTime() - (opts.joinedDaysAgo ?? 1) * DAY, ...(opts.bot ? { user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }) } : {}), }); + if (opts.recorded) { + await markOnboarded(app.db, 'guild-1', 'member-1', new Date('2027-01-14T00:00:00Z')); + } const oldMember = { guild: env.guild, id: 'member-1', partial: true } as unknown as GuildMember; await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); return env.roleAdds.length; }; -test('guildMemberUpdate onboards a partial old member who is not pending and lacks guest roles', async () => { - assert.equal(await partialUpdate({}), 1); -}); - -test('guildMemberUpdate skips a partial old member who still has to pass screening', async () => { - assert.equal(await partialUpdate({ pending: true }), 0); -}); - -test('guildMemberUpdate skips a partial old member who already holds a guest role', async () => { - assert.equal(await partialUpdate({ held: ['r1'] }), 0); +test('guildMemberUpdate onboards a recently joined partial old member and records it', async () => { + assert.equal(await partialUpdate({ joinedDaysAgo: 1 }), 1); + assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); }); -test('guildMemberUpdate skips a partial old member when no guest roles are configured', async () => { - clearConfigs(); - createConfig({ serverId: 'guild-1', guestRoleIds: null, auditChannelId: 'audit-1' }); - assert.equal(await partialUpdate({}), 0); +test('guildMemberUpdate skips a partial old member who joined long ago', async () => { + app.logs.length = 0; + assert.equal(await partialUpdate({ joinedDaysAgo: 30 }), 0); + assert.equal(await getOnboardedAt(app.db, 'guild-1', 'member-1'), null); assert.ok( app .logsAt('info') .some( (e) => - /previous member state unknown/.test(e.message) && - JSON.stringify(e.context).includes('member-1'), + /did not join recently/.test(e.message) && + JSON.stringify(e.context).includes('member-1') && + JSON.stringify(e.context).includes('guild-1'), ), ); }); +test('guildMemberUpdate skips a partial old member already recorded as onboarded', async () => { + assert.equal(await partialUpdate({ recorded: true }), 0); +}); + +test('guildMemberUpdate skips a partial old member who still has to pass screening', async () => { + assert.equal(await partialUpdate({ pending: true }), 0); +}); + test('guildMemberUpdate skips a bot with a partial old member', async () => { assert.equal(await partialUpdate({ bot: true }), 0); }); +test('guildMemberUpdate skips pending true to false when already recorded', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', new Date('2027-01-14T00:00:00Z')); + assert.equal(await update({ pending: true }, false), 0); +}); + +test('guildMemberUpdate records onboarding on pending true to false', async () => { + assert.equal(await update({ pending: true }, false), 1); + assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); +}); + +test('a dry run does not record onboarding', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + await complete(app, stub(env.client), env.member, env.config, { dryRun: true }); + assert.equal(await getOnboardedAt(app.db, 'guild-1', 'member-1'), null); +}); + +test('guildMemberAdd records onboarding for an immediate join', async () => { + const env = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); + Object.assign(env.member, { pending: false }); + await guildMemberAdd.run(app, stub(env.client), env.member); + assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); +}); + test('guildMemberAdd audits the join and onboards immediately when not pending', async () => { const env = fakeOnboarding({ roles: [role('r1')] }); Object.assign(env.member, { pending: false }); From 624ebe8adaa2c8dd9fa09f8d93e1173cc9e7780e Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:11:00 +0100 Subject: [PATCH 52/77] Create the new birthday calendar before removing the old one Send the placeholder and persist its path first, then best-effort delete the previous message, so a failure never leaves the server without a working calendar. A persist failure removes the new message instead. Co-Authored-By: Claude Sonnet 5.5 --- src/commands/config/groups/birthdays.ts | 24 +++++-- tests/birthdays/configCreate.test.ts | 96 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/birthdays/configCreate.test.ts diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index 42afb2b1..d80267b5 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -41,18 +41,34 @@ export const BirthdaysGroup = defineSubcommandGroup({ } const client = ctx.interaction.client; - await deleteCalendarMessage(client, ctx.config.birthdayCalendarMessagePath); + const previousPath = ctx.config.birthdayCalendarMessagePath; + // Send first and persist before touching the old calendar, so a failure at either step + // leaves the existing calendar and its stored path intact. const message = await channel.send({ content: 'Placeholder calendar message - populating...', allowedMentions: { parse: [] }, }); const path = `${message.channelId}/${message.id}`; - ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { - birthdayCalendarMessagePath: path, - }); + try { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + birthdayCalendarMessagePath: path, + }); + } catch (error) { + await message.delete().catch((deleteError: unknown) => { + ctx.app.logger.warn( + 'Could not remove the new birthday calendar message after a failed save', + { guild: ctx.guild.id }, + deleteError, + ); + }); + throw error; + } const status = await refreshCalendar(ctx.app, client, ctx.config); + if (previousPath && previousPath !== path) { + await deleteCalendarMessage(client, previousPath); + } const link = calendarMessageUrl(ctx.guild.id, path); await ctx.reply( status === 'updated' diff --git a/tests/birthdays/configCreate.test.ts b/tests/birthdays/configCreate.test.ts new file mode 100644 index 00000000..dfca29a8 --- /dev/null +++ b/tests/birthdays/configCreate.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import type { Guild } from 'discord.js'; +import BirthdaysGroup from '../../src/commands/config/groups/birthdays.ts'; +import type { Handler } from '../../src/framework/command.ts'; +import { fakeCommandContext } from '../fakes/command.ts'; +import { stub } from '../fakes/discord.ts'; +import { fakeClient, fakeEditableMessage, fakeTextChannel } from '../fakes/guild.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { last } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const apps = [createTestApp(), createTestApp()]; +after(() => { + apps.forEach((a) => a.close()); +}); + +const create: Handler = BirthdaysGroup.subcommands.find((s) => s.name === 'create')!.run; + +const setup = (app: (typeof apps)[number], opts: { failSend?: boolean } = {}) => { + const { clearConfigs, createConfig, findConfig } = dbFixtures(app); + clearConfigs(); + const order: string[] = []; + const oldMessage = fakeEditableMessage('old-msg'); + oldMessage.delete = async () => { + order.push(`delete-old(path=${findConfig('guild-1')?.birthdayCalendarMessagePath})`); + oldMessage.deleted = true; + }; + const oldChannel = fakeTextChannel('old-chan', [oldMessage]); + const target = fakeTextChannel('new-chan'); + target.send = async () => { + order.push('send-new'); + if (opts.failSend) { + throw new Error('missing access'); + } + return { id: 'sent-1', channelId: 'new-chan' }; + }; + Object.assign(target, { + permissionsFor: () => ({ has: () => true }), + }); + const guild = stub({ + id: 'guild-1', + name: 'Test Guild', + members: { me: { id: 'bot' } }, + channels: { cache: new Map([['new-chan', target]]), fetch: async () => target }, + }); + const config = createConfig({ + serverId: 'guild-1', + birthdayCalendarMessagePath: 'old-chan/old-msg', + }); + const client = fakeClient({ channels: [oldChannel, target] }); + const { ctx, replies } = fakeCommandContext( + app, + { channel: { id: 'new-chan' } }, + { guild, config, client }, + ); + return { ctx, replies, order, oldMessage, findConfig }; +}; + +test('birthdays create keeps the old calendar and path when sending the new message fails', async () => { + const app = apps[0]!; + const { ctx, order, oldMessage, findConfig } = setup(app, { failSend: true }); + await assert.rejects(create(ctx), /missing access/); + assert.equal(oldMessage.deleted, false); + assert.deepEqual(order, ['send-new']); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); +}); + +test('birthdays create deletes the old message only after the new path is saved', async () => { + const app = apps[0]!; + const { ctx, replies, order, oldMessage, findConfig } = setup(app); + await create(ctx); + assert.equal(oldMessage.deleted, true); + assert.deepEqual(order, ['send-new', 'delete-old(path=new-chan/sent-1)']); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'new-chan/sent-1'); + assert.match(last(replies).content!, /new-chan\/sent-1/); +}); + +test('birthdays create removes the new message when persisting fails and leaves the old one', async () => { + const app = apps[1]!; + const { ctx, oldMessage } = setup(app); + const deleted: string[] = []; + const guild = ctx.guild as unknown as { + channels: { cache: Map Promise }> }; + }; + const channel = guild.channels.cache.get('new-chan')!; + channel.send = async () => ({ + id: 'new-msg', + channelId: 'new-chan', + delete: async () => void deleted.push('new-msg'), + }); + app.db.$client.exec('DROP TABLE `ServerConfigs`'); + await assert.rejects(create(ctx)); + assert.deepEqual(deleted, ['new-msg']); + assert.equal(oldMessage.deleted, false); +}); From 37028bc9f2fefcf09d860b85323e82d97a1f0361 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:11:58 +0100 Subject: [PATCH 53/77] Defer routes that call Discord before replying Defer newroles set/clear (audit send), the channel setting getters (channel fetch) and /profile (member fetch) so slow network calls cannot miss the three second interaction window. Co-Authored-By: Claude Sonnet 5.5 --- src/commands/config/groups/newroles.ts | 2 ++ src/commands/info/profile.ts | 1 + src/framework/settings.ts | 1 + tests/deferRoutes.test.ts | 38 +++++++++++++++++++++++ tests/onboarding/commands.test.ts | 42 ++++++++++++++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 tests/deferRoutes.test.ts diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts index eb690840..76263e51 100644 --- a/src/commands/config/groups/newroles.ts +++ b/src/commands/config/groups/newroles.ts @@ -45,6 +45,7 @@ export const NewRolesGroup = defineSubcommandGroup({ { name: 'set', description: 'Set the roles given to members when they complete onboarding', + defer: 'ephemeral', options: (sub) => sub .addRoleOption((option) => @@ -90,6 +91,7 @@ export const NewRolesGroup = defineSubcommandGroup({ { name: 'clear', description: 'Clear the roles given to members when they complete onboarding', + defer: 'ephemeral', run: async (ctx) => { ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { guestRoleIds: null }); await auditChange(ctx, 'New member roles cleared', []); diff --git a/src/commands/info/profile.ts b/src/commands/info/profile.ts index 4a59ae4e..18822f72 100644 --- a/src/commands/info/profile.ts +++ b/src/commands/info/profile.ts @@ -10,6 +10,7 @@ export default defineCommand({ name: 'profile', description: 'Fetch profiles for server members', adminOnly: true, + defer: 'ephemeral', options: (b) => b.addUserOption((opt) => opt.setName('member').setDescription('Member to fetch profile for').setRequired(true), diff --git a/src/framework/settings.ts b/src/framework/settings.ts index 25ea4b88..15085370 100644 --- a/src/framework/settings.ts +++ b/src/framework/settings.ts @@ -78,6 +78,7 @@ export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefi { name: 'get', description: `Show the ${label.toLowerCase()}`, + defer: 'ephemeral', run: async (ctx) => { const id = ctx.config[field]; const exists = id ? await channelExists(ctx.guild, id) : false; diff --git a/tests/deferRoutes.test.ts b/tests/deferRoutes.test.ts new file mode 100644 index 00000000..85d8282f --- /dev/null +++ b/tests/deferRoutes.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { commands } from '../src/commands/index.ts'; +import { fakeInteraction } from './fakes/interaction.ts'; + +const defers = ( + command: string, + group: string | null, + subcommand: string | null, +): string | false | undefined => { + const cmd = commands.find((c) => c.name === command)!; + const { interaction } = fakeInteraction({ commandName: command, group, subcommand }); + return cmd.resolve(interaction)?.defer; +}; + +test('routes that await Discord network calls before replying defer ephemerally', () => { + const routes: [string, string | null, string | null][] = [ + ['profile', null, null], + ['config', 'newroles', 'set'], + ['config', 'newroles', 'clear'], + ['config', 'audit', 'get'], + ['config', 'announcements', 'get'], + ['config', 'honeypot', 'get'], + ['config', 'welcome', 'set-image'], + ['config', 'welcome', 'preview'], + ['config', 'birthdays', 'create'], + ['config', 'birthdays', 'sync'], + ['config', 'birthdays', 'remove'], + ['noroles', null, null], + ['rolesince', null, null], + ['post', null, null], + ['simulate', null, 'join'], + ['simulate', null, 'onboard'], + ]; + for (const [command, group, sub] of routes) { + assert.equal(defers(command, group, sub), 'ephemeral', [command, group, sub].join(' ')); + } +}); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index efb229eb..cfebe54e 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -1,5 +1,11 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; +import { Collection } from 'discord.js'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import ConfigCommand from '../../src/commands/config/config.ts'; +import interactionCreate from '../../src/events/interactionCreate.ts'; +import { stub } from '../fakes/discord.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; import type { Handler } from '../../src/framework/command.ts'; import NewRolesGroup from '../../src/commands/config/groups/newroles.ts'; import WelcomeGroup, { @@ -166,3 +172,39 @@ test('welcome preview shows the text without sending', async () => { assert.match(nth(replies).content!, /hi <@member-1> <@member-1>/); assert.equal(env.systemSends.length, 0); }); + +test('newroles set and clear defer before the audit send', async () => { + for (const sub of ['set', 'clear']) { + const env = fakeOnboarding({ roles: [role('a', 1)] }); + clearConfigs(); + createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1' }); + const order: string[] = []; + const { interaction } = fakeInteraction({ + commandName: 'config', + group: 'newroles', + subcommand: sub, + options: { 'role-one': env.guild.roles.cache.get('a') }, + guild: env.guild, + }); + Object.assign(interaction, { member: env.member, client: env.client }); + const deferReply = interaction.deferReply.bind(interaction) as (o: unknown) => unknown; + Object.assign(interaction, { + deferReply: async (options: unknown) => { + order.push('defer'); + return deferReply(options); + }, + }); + const fetchChannel = env.client.channels.fetch.bind(env.client.channels); + env.client.channels.fetch = (async (id: string) => { + order.push('audit'); + return fetchChannel(id); + }) as typeof env.client.channels.fetch; + + const client = stub({ + slashCommands: new Collection([[ConfigCommand.name, ConfigCommand]]), + }); + await interactionCreate.run(app, client, interaction as never); + assert.deepEqual(order, ['defer', 'audit'], sub); + assert.equal(env.auditSends.length, 1, sub); + } +}); From 77f669deca203e179f7cf40a860644782b19f057 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:12:44 +0100 Subject: [PATCH 54/77] Clear stale commands when the registration scope changes Record the last registered scope per application in BotState. After a successful registration, clear the previous dev guild's commands when the scope moves elsewhere; never clear global commands automatically. The deploy script shares the same logic. Co-Authored-By: Claude Sonnet 5.5 --- src/framework/registrar.ts | 103 +++++++++++++++++++++++++++++---- src/scripts/deploy-commands.ts | 9 ++- tests/registrar.test.ts | 82 ++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 14 deletions(-) diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts index 25f110b2..eafff168 100644 --- a/src/framework/registrar.ts +++ b/src/framework/registrar.ts @@ -10,6 +10,10 @@ export interface CommandScope { label: string; /** BotState key under which the last registered hash is stored. */ stateKey: string; + /** BotState key under which the last successfully registered scope is stored. */ + scopeKey: string; + /** `global` or `guild:`; what is stored under scopeKey. */ + scope: string; } /** The key includes the application id so a different bot sharing this database re-registers. */ @@ -18,8 +22,15 @@ export const commandScope = (env: Pick): Command ? { label: `dev guild ${env.devGuildId}`, stateKey: `commandsHash:${env.clientId}:guild:${env.devGuildId}`, + scopeKey: `commandsScope:${env.clientId}`, + scope: `guild:${env.devGuildId}`, } - : { label: 'globally', stateKey: `commandsHash:${env.clientId}:global` }; + : { + label: 'globally', + stateKey: `commandsHash:${env.clientId}:global`, + scopeKey: `commandsScope:${env.clientId}`, + scope: 'global', + }; const canonical = (value: unknown): unknown => { if (Array.isArray(value)) { @@ -56,17 +67,92 @@ export async function putCommands( export type PutCommands = typeof putCommands; +/** Removes every application command from one guild. */ +export async function clearGuildCommands( + env: Pick, + guildId: string, +): Promise { + const rest = new REST({ version: '10' }).setToken(env.token); + await rest.put(Routes.applicationGuildCommands(env.clientId, guildId), { body: [] }); +} + +export type ClearGuildCommands = typeof clearGuildCommands; + +export interface RegistrarDeps { + put?: PutCommands; + clear?: ClearGuildCommands; +} + +/** + * Registers commands in the env's scope, then records the hash and scope. When the last scope + * was a dev guild and the scope has changed, that guild's commands are cleared so they do not + * linger as duplicates. Global commands are never cleared automatically (they may be production). + * Throws if registration fails, in which case nothing is cleared or recorded. + */ +export async function registerCommands( + app: Pick, + commands: readonly Command[], + deps: RegistrarDeps = {}, +): Promise { + const { env, db, logger, clock } = app; + const { put = putCommands, clear = clearGuildCommands } = deps; + const scope = commandScope(env); + const hash = hashCommands(commands); + + const count = await put(env, commands); + setState(db, scope.stateKey, hash, clock()); + + const previous = getState(db, scope.scopeKey); + let cleaned = false; + let scopeRecorded = true; + if (previous?.startsWith('guild:') && previous !== scope.scope) { + const guildId = previous.slice('guild:'.length); + try { + await clear(env, guildId); + cleaned = true; + logger.info('Cleared application commands from the previous dev guild', { guild: guildId }); + } catch (error) { + scopeRecorded = false; // keep the old scope so the next run retries the cleanup + logger.warn( + 'Could not clear application commands from the previous dev guild; remove them manually', + { guild: guildId }, + error, + ); + } + } + if (scopeRecorded) { + setState(db, scope.scopeKey, scope.scope, clock()); + } + + if (env.devGuildId) { + logger.warn( + 'Commands were registered to the dev guild only; global commands may also exist and show up as duplicates. Run `npm run deploy:commands` without DEV_GUILD_ID to manage global commands, or remove them manually.', + { scope: scope.label }, + ); + } else if (!cleaned && (previous === undefined || !scopeRecorded)) { + logger.warn( + 'Commands were registered globally; guild-scoped commands from an earlier DEV_GUILD_ID run may still exist and show up as duplicates. Remove them manually if so.', + { scope: scope.label }, + ); + } + return count; +} + /** Registers commands only when they differ from what was last registered for this scope. */ export async function syncCommands( app: Pick, commands: readonly Command[], - put: PutCommands = putCommands, + put: PutCommands | RegistrarDeps = {}, ): Promise<'registered' | 'unchanged' | 'failed'> { - const { env, db, logger, clock } = app; + const { env, db, logger } = app; + const deps = typeof put === 'function' ? { put } : put; const scope = commandScope(env); const hash = hashCommands(commands); + const previousScope = getState(db, scope.scopeKey); - if (getState(db, scope.stateKey) === hash) { + // A changed scope must re-register even when this scope's stored hash still matches, so the + // previous dev guild gets cleaned up. + if (getState(db, scope.stateKey) === hash && (previousScope ?? scope.scope) === scope.scope) { logger.info(`Application commands unchanged, skipping registration ${scope.label}`, { hash: hash.slice(0, 12), }); @@ -75,14 +161,7 @@ export async function syncCommands( logger.info(`Registering ${commands.length} application commands ${scope.label}`); try { - const count = await put(env, commands); - setState(db, scope.stateKey, hash, clock()); - logger.warn( - env.devGuildId - ? 'Commands were registered to the dev guild only; global commands may also exist and show up as duplicates. Run `npm run deploy:commands` without DEV_GUILD_ID to manage global commands, or remove them manually.' - : 'Commands were registered globally; guild-scoped commands from an earlier DEV_GUILD_ID run may still exist and show up as duplicates. Remove them manually if so.', - { scope: scope.label }, - ); + const count = await registerCommands(app, commands, deps); logger.info(`Registered ${count} application commands ${scope.label}`, { hash: hash.slice(0, 12), }); diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts index 5a738231..59f544fb 100644 --- a/src/scripts/deploy-commands.ts +++ b/src/scripts/deploy-commands.ts @@ -1,19 +1,24 @@ import { commands } from '../commands/index.ts'; +import { createApp } from '../app.ts'; import { loadEnv } from '../config/env.ts'; -import { commandScope, putCommands } from '../framework/registrar.ts'; +import { commandScope, registerCommands } from '../framework/registrar.ts'; import { validateRegistry } from '../framework/registry.ts'; import { createConsoleLogger, parseLogLevel } from '../utilities/Logger.ts'; const logger = createConsoleLogger({ level: parseLogLevel(process.env.LOG_LEVEL?.trim()) }); +let app: ReturnType | undefined; try { const env = loadEnv(); validateRegistry('command', commands); const { label } = commandScope(env); + app = createApp({ env, logger }); logger.info(`Deploying ${commands.length} application commands ${label}`); - const count = await putCommands(env, commands); + const count = await registerCommands(app, commands); logger.info(`Deployed ${count} application commands ${label}`); } catch (error) { logger.error('Command deployment failed', undefined, error); process.exitCode = 1; +} finally { + app?.db.$client.close(); } diff --git a/tests/registrar.test.ts b/tests/registrar.test.ts index b90740cd..0620c9dc 100644 --- a/tests/registrar.test.ts +++ b/tests/registrar.test.ts @@ -111,3 +111,85 @@ test('syncCommands warns that the other scope may hold duplicate commands', asyn assert.ok(app.logsAt('warn').some((e) => /global commands may also exist/.test(e.message))); assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); }); + +const inScope = (devGuildId?: string) => ({ + ...app, + env: Object.freeze({ ...app.env, devGuildId }), +}); + +const clearer = () => { + const cleared: string[] = []; + return { + cleared, + clear: async (_env: unknown, guildId: string) => { + cleared.push(guildId); + }, + }; +}; + +test('switching from a dev guild to global clears the old guild', async () => { + const { put } = recorder(); + const { cleared, clear } = clearer(); + const commands = [command('one')]; + await syncCommands(inScope('42'), commands, { put, clear }); + assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); + assert.deepEqual(cleared, []); + + assert.equal(await syncCommands(inScope(), commands, { put, clear }), 'registered'); + assert.deepEqual(cleared, ['42']); + assert.equal(getState(app.db, 'commandsScope:1'), 'global'); + assert.ok(app.logsAt('info').some((e) => /Cleared application commands/.test(e.message))); +}); + +test('switching between dev guilds clears the previous one', async () => { + const { put } = recorder(); + const { cleared, clear } = clearer(); + const commands = [command('one')]; + await syncCommands(inScope('A'), commands, { put, clear }); + await syncCommands(inScope('B'), commands, { put, clear }); + assert.deepEqual(cleared, ['A']); + assert.equal(getState(app.db, 'commandsScope:1'), 'guild:B'); + // Staying put clears nothing further. + await syncCommands(inScope('B'), [command('one'), command('two')], { put, clear }); + assert.deepEqual(cleared, ['A']); +}); + +test('switching from global to a dev guild never clears global commands', async () => { + const { put } = recorder(); + const { cleared, clear } = clearer(); + const commands = [command('one')]; + await syncCommands(inScope(), commands, { put, clear }); + app.logs.length = 0; + await syncCommands(inScope('42'), commands, { put, clear }); + assert.deepEqual(cleared, []); + assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); + assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); +}); + +test('a failed registration clears nothing and keeps the recorded scope', async () => { + const { put } = recorder(); + const { cleared, clear } = clearer(); + await syncCommands(inScope('42'), [command('one')], { put, clear }); + const failing = async (): Promise => { + throw new Error('discord down'); + }; + assert.equal(await syncCommands(inScope(), [command('one')], { put: failing, clear }), 'failed'); + assert.deepEqual(cleared, []); + assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); +}); + +test('a failed cleanup keeps the old scope so the next run retries it', async () => { + const { put } = recorder(); + const commands = [command('one')]; + await syncCommands(inScope('42'), commands, { put }); + const failingClear = async (): Promise => { + throw new Error('forbidden'); + }; + assert.equal(await syncCommands(inScope(), commands, { put, clear: failingClear }), 'registered'); + assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); + + const { cleared, clear } = clearer(); + assert.equal(await syncCommands(inScope(), commands, { put, clear }), 'registered'); + assert.deepEqual(cleared, ['42']); + assert.equal(getState(app.db, 'commandsScope:1'), 'global'); +}); From f22f275332127dd9b56ea0ee9e2efdbfe4c5cb85 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:12:57 +0100 Subject: [PATCH 55/77] Snapshot profiles before fetching members in the data check Read the guild's profiles before the member fetch so profiles created while it runs are not mistaken for departed members. Co-Authored-By: Claude Sonnet 5.5 --- src/services/DataCheckService.ts | 4 +++- tests/datacheck/DataCheckService.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index 28d9a226..8710ec6b 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -12,12 +12,14 @@ import { const cleanMembers = async (app: App, client: Client, serverId: Snowflake): Promise => { const guild = await client.guilds.fetch(serverId); + // Snapshot the profiles first: anyone who joins during the (slow) member fetch gets a profile + // that is missing from the fetched list, and must survive until the next run. + const profiles = await getServerProfiles(app.db, serverId); const members = await guild.members.fetch(); if (members.size === 0) { app.logger.warn('Member list empty; skipping profile cleanup', { guild: serverId }); return; } - const profiles = await getServerProfiles(app.db, serverId); const departed = profiles.filter((p) => !members.has(p.userId)).map((p) => p.userId); if (departed.length > 0) { const removed = await deleteUsers(app.db, serverId, departed); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index e9e518a6..ae66bacc 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -117,3 +117,26 @@ test('one guild throwing does not stop the others', async () => { const remaining = allProfiles().map((p) => p.serverId); assert.deepEqual(remaining, ['g1']); }); + +test('a profile created while the member list is being fetched is not deleted', async () => { + createConfig({ serverId: 'g1' }); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g1', userId: 'gone' }, + ]); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); + const fetchMembers = guild.members.fetch.bind(guild.members); + guild.members.fetch = async (id?: string) => { + const result = await fetchMembers(id); + // The member joins after the list was assembled but before the check reads the database. + createProfiles([{ serverId: 'g1', userId: 'late-joiner' }]); + return result; + }; + await run(fakeClient({ guilds: { g1: guild } }), t0, policy); + assert.deepEqual( + allProfiles() + .map((p) => p.userId) + .sort(), + ['a', 'late-joiner'], + ); +}); From 01e6d44fa87718a5dc6eae0d46dadabca27fcd64 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:14:49 +0100 Subject: [PATCH 56/77] Document onboarding tracking, scope cleanup and calendar recreation Co-Authored-By: Claude Haiku 4.5 --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 54f02f22..a93e7b33 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The invite URL should include all these permissions. You can generate one in the Use `DEV_GUILD_ID` in `.env` to test commands instantly in a single guild instead of waiting 1 hour for global registration. -On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). The hash is stored per application (`CLIENT_ID`) and per scope, so the first start after upgrading re-registers once. Switching between global and dev-guild registration can leave commands in the other scope; nothing is deleted automatically — the bot logs a warning. To force a registration without starting the bot, or to clean up orphaned commands, run: +On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). The hash is stored per application (`CLIENT_ID`) and per scope, so the first start after upgrading re-registers once. The last registered scope is stored in BotState (`commandsScope:`). Switching from a dev guild (`DEV_GUILD_ID=X`) to global or another guild automatically clears the old dev guild's commands after the new registration succeeds. Switching from global to a dev guild does NOT remove global commands (they may be production) — remove them via `npm run deploy:commands` without `DEV_GUILD_ID` set or the Developer Portal. `npm run deploy:commands` uses the same logic and opens the database to record the scope/hash. To force a registration without starting the bot, or to clean up orphaned commands, run: ```bash npm run deploy:commands @@ -182,11 +182,11 @@ Roles given to members when they complete onboarding: #### `/config birthdays` — Birthday Calendar -| Subcommand | Description | -| ------------------ | --------------------------------------------------------------------------------------- | -| `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays) | -| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | -| `remove` | Remove the birthday calendar message | +| Subcommand | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays); the existing calendar is kept until the new one has been posted and saved, then the old message is removed | +| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | +| `remove` | Remove the birthday calendar message | #### `/config debug` — Onboarding Diagnostics @@ -216,7 +216,7 @@ Roles are only assigned if: If a role cannot be assigned, the error is logged and the onboarding continues for other roles and steps. -**Unknown Previous State**: If the bot didn't have the member's previous state cached (e.g. they joined while the bot was offline), a member who is no longer pending is onboarded only when new-member roles are configured and they have none of them. Otherwise the update is skipped and logged at info level. Bots are never onboarded (their join is still audited). +**Onboarding Tracking**: Onboarding completion is recorded per member (`onboardedAt`), so a member is never onboarded twice. Leaving the server deletes the profile, so a rejoin is onboarded again. When the bot doesn't have the member's previous state cached (e.g. they joined while the bot was offline), it onboards a member only if they're no longer pending, not a bot, have no recorded onboarding, and joined within the last 7 days; otherwise it skips and logs at info. Members who were already in the server before upgrading are not re-onboarded (except possibly someone who joined within 7 days before the upgrade). ### Audit Log @@ -255,7 +255,7 @@ Members can set their birthday using `/mybirthday set `. Birthdays On the schedule defined by `JOB_SCHEDULE` (default: 9:00 AM in the configured `BOT_TIMEZONE`), three tasks run in order: -1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. +1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. Profiles are snapshotted before fetching the member list, so members who join during the check aren't treated as departed. 2. **Birthday Notifications** — Posts birthday announcements in the announcements channel for members with birthdays today 3. **Birthday Calendars** — Updates all configured birthday calendar messages @@ -272,6 +272,7 @@ Migrations run automatically on startup. Current migrations: - **003-serverconfig-access-tracking** — Adds access failure tracking for orphaned guild detection - **004-bot-state** — Adds the `BotState` key/value table (currently stores the registered command hash) - **005-userprofile-backfill** — Sets NULL activity scores to 0 and deletes profile rows with no server or user id, logging how many +- **006-userprofile-onboarded-at** — Adds nullable `UserProfiles.onboardedAt`, recording when a member was onboarded ### What Gets Deleted From fcdf9e1d9fd2554533627c95943964b16d2d5ee8 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:22:46 +0100 Subject: [PATCH 57/77] Add screeningPendingAt column and service functions Co-Authored-By: Claude Sonnet 5.5 --- .../007-userprofile-screening-pending.ts | 11 +++++ src/db/migrator.ts | 2 + src/db/schema.ts | 1 + src/services/UserProfileService.ts | 44 +++++++++++++++++++ tests/database/migrations.test.ts | 31 ++++++++++++- tests/database/services.test.ts | 33 ++++++++++++++ 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/db/migrations/007-userprofile-screening-pending.ts diff --git a/src/db/migrations/007-userprofile-screening-pending.ts b/src/db/migrations/007-userprofile-screening-pending.ts new file mode 100644 index 00000000..3b7a76b2 --- /dev/null +++ b/src/db/migrations/007-userprofile-screening-pending.ts @@ -0,0 +1,11 @@ +import type { Database } from 'better-sqlite3'; + +/** Records when a member was seen pending membership screening, so onboarding never relies on inference. */ +export function up(db: Database): void { + const columns = new Set( + (db.pragma('table_info(`UserProfiles`)') as { name: string }[]).map((column) => column.name), + ); + if (!columns.has('screeningPendingAt')) { + db.exec('ALTER TABLE `UserProfiles` ADD COLUMN `screeningPendingAt` DATETIME'); + } +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index d5f44cfd..45c10737 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -6,6 +6,7 @@ import * as serverConfigAccessTracking from './migrations/003-serverconfig-acces import * as botState from './migrations/004-bot-state.ts'; import * as userProfileBackfill from './migrations/005-userprofile-backfill.ts'; import * as userProfileOnboardedAt from './migrations/006-userprofile-onboarded-at.ts'; +import * as userProfileScreeningPending from './migrations/007-userprofile-screening-pending.ts'; export interface Migration { name: string; @@ -19,6 +20,7 @@ export const migrations: Migration[] = [ { name: '004-bot-state', up: botState.up }, { name: '005-userprofile-backfill', up: userProfileBackfill.up }, { name: '006-userprofile-onboarded-at', up: userProfileOnboardedAt.up }, + { name: '007-userprofile-screening-pending', up: userProfileScreeningPending.up }, ]; /** Applies pending migrations in order, each in its own transaction, and returns their names. */ diff --git a/src/db/schema.ts b/src/db/schema.ts index 6085a9c5..43e83bcf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -51,6 +51,7 @@ export const userProfiles = sqliteTable( birthdayDay: integer('birthdayDay'), activityScore: integer('activityScore').notNull().default(0), onboardedAt: datetime('onboardedAt'), + screeningPendingAt: datetime('screeningPendingAt'), createdAt: datetime('createdAt') .notNull() .$defaultFn(() => new Date()), diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 2f1ef6bf..9303ba9e 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -148,3 +148,47 @@ export async function getOnboardedAt( ): Promise { return (await findUserProfile(db, serverId, userId))?.onboardedAt ?? null; } + +/** Records that the member is awaiting membership screening. Leaves onboardedAt untouched. */ +export async function markScreeningPending( + db: Db, + serverId: Snowflake, + userId: Snowflake, + at: Date, +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, screeningPendingAt: at }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { screeningPendingAt: at, updatedAt: at }, + }) + .run(); +} + +export async function clearScreeningPending( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + db.update(userProfiles) + .set({ screeningPendingAt: null, updatedAt: new Date() }) + .where(and(forUser(serverId, userId), isNotNull(userProfiles.screeningPendingAt))) + .run(); +} + +export interface OnboardingState { + screeningPendingAt: Date | null; + onboardedAt: Date | null; +} + +export async function getOnboardingState( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + const profile = await findUserProfile(db, serverId, userId); + return { + screeningPendingAt: profile?.screeningPendingAt ?? null, + onboardedAt: profile?.onboardedAt ?? null, + }; +} diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts index b2753e26..9e3b5752 100644 --- a/tests/database/migrations.test.ts +++ b/tests/database/migrations.test.ts @@ -17,6 +17,7 @@ const ALL = [ '004-bot-state', '005-userprofile-backfill', '006-userprofile-onboarded-at', + '007-userprofile-screening-pending', ]; const LEGACY_SERVER_CONFIGS = @@ -91,6 +92,7 @@ test('migrations on an empty database create the expected schema', () => { 'createdAt', 'updatedAt', 'onboardedAt', + 'screeningPendingAt', ]); assert.ok(indexesOf(db, 'UserProfiles').find((i) => i.name === INDEX)?.unique); @@ -270,6 +272,7 @@ test('005 backfills NULL scores, removes unusable rows with a warning, and is id assert.deepEqual(migrate(db, logger), [ '005-userprofile-backfill', '006-userprofile-onboarded-at', + '007-userprofile-screening-pending', ]); assert.deepEqual( allRows(db, 'SELECT userId, activityScore FROM `UserProfiles` ORDER BY userId'), @@ -298,7 +301,10 @@ test('006 adds a nullable onboardedAt column and is idempotent', () => { ); runUpTo(db, '005-userprofile-backfill'); - assert.deepEqual(migrate(db, logger), ['006-userprofile-onboarded-at']); + assert.deepEqual(migrate(db, logger), [ + '006-userprofile-onboarded-at', + '007-userprofile-screening-pending', + ]); assert.ok(columnsOf(db, 'UserProfiles').includes('onboardedAt')); assert.deepEqual(allRows(db, 'SELECT onboardedAt FROM `UserProfiles`'), [ { onboardedAt: null }, @@ -310,3 +316,26 @@ test('006 adds a nullable onboardedAt column and is idempotent', () => { assert.equal(columnsOf(db, 'UserProfiles').filter((c) => c === 'onboardedAt').length, 1); }); }); + +test('007 adds a nullable screeningPendingAt column and is idempotent', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + const at = "'2024-01-01 00:00:00.000 +00:00'"; + db.exec( + `INSERT INTO \`UserProfiles\` (serverId, userId, createdAt, updatedAt) VALUES ('s1', 'u1', ${at}, ${at})`, + ); + runUpTo(db, '006-userprofile-onboarded-at'); + + assert.deepEqual(migrate(db, logger), ['007-userprofile-screening-pending']); + assert.ok(columnsOf(db, 'UserProfiles').includes('screeningPendingAt')); + assert.deepEqual(allRows(db, 'SELECT screeningPendingAt FROM `UserProfiles`'), [ + { screeningPendingAt: null }, + ]); + + assert.deepEqual(migrate(db, logger), []); + const migration = nth(migrations.filter((m) => m.name === '007-userprofile-screening-pending')); + assert.doesNotThrow(() => migration.up(db, logger)); + assert.equal(columnsOf(db, 'UserProfiles').filter((c) => c === 'screeningPendingAt').length, 1); + }); +}); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index b29d15ed..4bf7a60d 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -9,6 +9,10 @@ import { } from '../../src/services/ConfigService.ts'; import { clearBirthday, + clearScreeningPending, + getOnboardingState, + markOnboarded, + markScreeningPending, getServerBirthdays, incrementActivityScore, setBirthday, @@ -131,3 +135,32 @@ test('setBirthday upserts without losing the activity score; clearBirthday repor assert.equal(await clearBirthday(db, 's1', 'nobody'), false); assert.equal((await getServerBirthdays(db, 's1')).length, 0); }); + +test('screening pending state is recorded, independent of onboardedAt, and cleared', async () => { + const pendingAt = new Date('2027-01-10T00:00:00Z'); + const onboardedAt = new Date('2027-01-11T00:00:00Z'); + assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { + screeningPendingAt: null, + onboardedAt: null, + }); + + await markScreeningPending(db, 'g1', 'u1', pendingAt); + assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { + screeningPendingAt: pendingAt, + onboardedAt: null, + }); + + await markOnboarded(db, 'g1', 'u1', onboardedAt); + await markScreeningPending(db, 'g1', 'u1', pendingAt); + assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { + screeningPendingAt: pendingAt, + onboardedAt, + }); + + await clearScreeningPending(db, 'g1', 'u1'); + await clearScreeningPending(db, 'g1', 'missing'); + assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { + screeningPendingAt: null, + onboardedAt, + }); +}); From 8660f34d0dd8634900c7e27b1223187909f66f0e Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:23:22 +0100 Subject: [PATCH 58/77] Require recorded pending state before onboarding from unknown member state Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberAdd.ts | 8 +- src/events/guildMemberUpdate.ts | 48 ++++++------ src/events/ready.ts | 5 ++ src/services/ScreeningService.ts | 30 +++++++ src/services/UserProfileService.ts | 2 +- tests/onboarding/events.test.ts | 121 +++++++++++++++++++++++------ 6 files changed, 162 insertions(+), 52 deletions(-) create mode 100644 src/services/ScreeningService.ts diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index a94d81de..6071df24 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,6 +1,7 @@ import { defineEvent } from '../framework/event.ts'; import { getConfig } from '../services/ConfigService.ts'; import { auditJoin, complete } from '../services/OnboardingService.ts'; +import { markScreeningPending } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberAdd', @@ -9,7 +10,12 @@ export default defineEvent({ await auditJoin(app, client, member, config); - if (!member.user.bot && !member.pending) { + if (member.user.bot) { + return; + } + if (member.pending) { + await markScreeningPending(app.db, member.guild.id, member.id, app.clock()); + } else { await complete(app, client, member, config, { skipAudit: true }); } }, diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index b02a55c5..31164606 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,10 +1,7 @@ import { defineEvent } from '../framework/event.ts'; import { complete, completedScreening } from '../services/OnboardingService.ts'; import { getConfig } from '../services/ConfigService.ts'; -import { getOnboardedAt } from '../services/UserProfileService.ts'; - -/** How recently a member must have joined for an unknown-previous-state update to onboard them. */ -const RECENT_JOIN_MS = 7 * 24 * 60 * 60 * 1000; +import { getOnboardingState, markScreeningPending } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberUpdate', @@ -16,38 +13,39 @@ export default defineEvent({ const skip = (reason: string): void => { app.logger.info(`Skipping onboarding: ${reason}`, ids); }; + const state = await getOnboardingState(app.db, newMember.guild.id, newMember.id); + + if (newMember.pending === true) { + // Keep the recorded state fresh; onboarding from an unknown state requires it. + if (state.screeningPendingAt === null && state.onboardedAt === null) { + await markScreeningPending(app.db, newMember.guild.id, newMember.id, app.clock()); + } + return; + } if (oldMember.partial) { - // The previous state is unknown, so only trust "finished screening, not yet onboarded" - // when the member is no longer pending, never onboarded, and joined recently. + // The previous state is unknown, so only a recorded pending state proves screening just ended. if (newMember.pending !== false) { return; } - if (await getOnboardedAt(app.db, newMember.guild.id, newMember.id)) { + if (state.onboardedAt) { skip('previous member state unknown and onboarding already recorded'); return; } - const joined = newMember.joinedTimestamp; - if ( - joined === null || - joined === undefined || - app.clock().getTime() - joined > RECENT_JOIN_MS - ) { - skip('previous member state unknown and member did not join recently'); + if (!state.screeningPendingAt) { + skip('previous member state unknown and no pending screening recorded'); + return; + } + } else { + if (!completedScreening(oldMember, newMember)) { + return; + } + if (state.onboardedAt) { + skip('onboarding already recorded'); return; } - await complete(app, client, newMember, await getConfig(app, newMember.guild.id)); - return; - } - if (!completedScreening(oldMember, newMember)) { - return; - } - if (await getOnboardedAt(app.db, newMember.guild.id, newMember.id)) { - skip('onboarding already recorded'); - return; } - const config = await getConfig(app, newMember.guild.id); - await complete(app, client, newMember, config); + await complete(app, client, newMember, await getConfig(app, newMember.guild.id)); }, }); diff --git a/src/events/ready.ts b/src/events/ready.ts index 817c5fda..68e1ecf0 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -2,6 +2,7 @@ import { defineEvent } from '../framework/event.ts'; import { syncCommands } from '../framework/registrar.ts'; import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService.ts'; import { runDataCheck } from '../services/DataCheckService.ts'; +import { recordPendingMembers } from '../services/ScreeningService.ts'; import { Scheduler } from '../services/Scheduler.ts'; export default defineEvent({ @@ -30,5 +31,9 @@ export default defineEvent({ } catch (error) { logger.error('Could not start scheduler; scheduled jobs are disabled', undefined, error); } + + void recordPendingMembers(app, client).catch((error: unknown) => { + logger.error('Could not record pending members', undefined, error); + }); }, }); diff --git a/src/services/ScreeningService.ts b/src/services/ScreeningService.ts new file mode 100644 index 00000000..b673bf90 --- /dev/null +++ b/src/services/ScreeningService.ts @@ -0,0 +1,30 @@ +import type { Client } from 'discord.js'; +import type { App } from '../app.ts'; +import { getOnboardingState, markScreeningPending } from './UserProfileService.ts'; + +/** + * Records every member currently pending membership screening, so a later update with an unknown + * previous state can still be trusted. Run at startup for members who joined before this was tracked. + */ +export async function recordPendingMembers(app: App, client: Client): Promise { + let recorded = 0; + for (const guild of client.guilds.cache.values()) { + try { + const members = await guild.members.fetch(); + for (const member of members.values()) { + if (member.user.bot || member.pending !== true) { + continue; + } + const state = await getOnboardingState(app.db, guild.id, member.id); + if (state.screeningPendingAt === null && state.onboardedAt === null) { + await markScreeningPending(app.db, guild.id, member.id, app.clock()); + recorded++; + } + } + } catch (error) { + app.logger.error('Could not record pending members', { guild: guild.id }, error); + } + } + app.logger.info('Recorded pending members', { count: recorded }); + return recorded; +} diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 9303ba9e..ed4a05b1 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -136,7 +136,7 @@ export async function markOnboarded( .values({ serverId, userId, onboardedAt: at }) .onConflictDoUpdate({ target: [userProfiles.serverId, userProfiles.userId], - set: { onboardedAt: at, updatedAt: at }, + set: { onboardedAt: at, screeningPendingAt: null, updatedAt: at }, }) .run(); } diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 8556711e..9c42f6ee 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; -import type { GuildMember } from 'discord.js'; +import { Collection, type Client, type Guild, type GuildMember } from 'discord.js'; import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; import type { DingirClient } from '../../src/client/DingirClient.ts'; @@ -9,7 +9,13 @@ import { fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; import { dbFixtures } from '../helpers/db.ts'; import { complete } from '../../src/services/OnboardingService.ts'; -import { getOnboardedAt, markOnboarded } from '../../src/services/UserProfileService.ts'; +import { recordPendingMembers } from '../../src/services/ScreeningService.ts'; +import { + getOnboardedAt, + getOnboardingState, + markOnboarded, + markScreeningPending, +} from '../../src/services/UserProfileService.ts'; import { nth } from '../helpers/assertions.ts'; const app = createTestApp(); @@ -40,20 +46,21 @@ test('guildMemberUpdate onboards only on pending true to false', async () => { assert.equal(await update({ pending: false }, true), 0); }); -const DAY = 24 * 60 * 60 * 1000; - const partialUpdate = async (opts: { bot?: boolean; pending?: boolean; - joinedDaysAgo?: number; + recordedPending?: boolean; recorded?: boolean; }) => { const env = fakeOnboarding({ roles: [role('r1')] }); Object.assign(env.member, { pending: opts.pending ?? false, - joinedTimestamp: app.clock().getTime() - (opts.joinedDaysAgo ?? 1) * DAY, + joinedTimestamp: app.clock().getTime() - 24 * 60 * 60 * 1000, ...(opts.bot ? { user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }) } : {}), }); + if (opts.recordedPending) { + await markScreeningPending(app.db, 'guild-1', 'member-1', new Date('2027-01-13T00:00:00Z')); + } if (opts.recorded) { await markOnboarded(app.db, 'guild-1', 'member-1', new Date('2027-01-14T00:00:00Z')); } @@ -62,33 +69,97 @@ const partialUpdate = async (opts: { return env.roleAdds.length; }; -test('guildMemberUpdate onboards a recently joined partial old member and records it', async () => { - assert.equal(await partialUpdate({ joinedDaysAgo: 1 }), 1); - assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); +test('partial old member with recorded pending and no onboarding is onboarded and cleared', async () => { + assert.equal(await partialUpdate({ recordedPending: true }), 1); + assert.deepEqual(await getOnboardingState(app.db, 'guild-1', 'member-1'), { + screeningPendingAt: null, + onboardedAt: app.clock(), + }); }); -test('guildMemberUpdate skips a partial old member who joined long ago', async () => { +test('partial old member with no recorded pending is skipped even if joined recently', async () => { app.logs.length = 0; - assert.equal(await partialUpdate({ joinedDaysAgo: 30 }), 0); - assert.equal(await getOnboardedAt(app.db, 'guild-1', 'member-1'), null); - assert.ok( - app - .logsAt('info') - .some( - (e) => - /did not join recently/.test(e.message) && - JSON.stringify(e.context).includes('member-1') && - JSON.stringify(e.context).includes('guild-1'), - ), - ); + assert.equal(await partialUpdate({}), 0); + assert.equal((await getOnboardingState(app.db, 'guild-1', 'member-1')).onboardedAt, null); + assert.ok(app.logsAt('info').some((e) => /no pending screening recorded/.test(e.message))); }); -test('guildMemberUpdate skips a partial old member already recorded as onboarded', async () => { - assert.equal(await partialUpdate({ recorded: true }), 0); +test('partial old member already onboarded is skipped', async () => { + assert.equal(await partialUpdate({ recorded: true, recordedPending: true }), 0); }); -test('guildMemberUpdate skips a partial old member who still has to pass screening', async () => { +test('partial old member still pending is recorded but not onboarded', async () => { assert.equal(await partialUpdate({ pending: true }), 0); + const state = await getOnboardingState(app.db, 'guild-1', 'member-1'); + assert.deepEqual(state.screeningPendingAt, app.clock()); + assert.equal(state.onboardedAt, null); +}); + +test('known pending true to false clears the recorded pending state', async () => { + await markScreeningPending(app.db, 'guild-1', 'member-1', new Date('2027-01-13T00:00:00Z')); + assert.equal(await update({ pending: true }, false), 1); + assert.equal((await getOnboardingState(app.db, 'guild-1', 'member-1')).screeningPendingAt, null); +}); + +test('known old state records pending when the member is still pending', async () => { + assert.equal(await update({ pending: false }, true), 0); + assert.deepEqual( + (await getOnboardingState(app.db, 'guild-1', 'member-1')).screeningPendingAt, + app.clock(), + ); +}); + +test('guildMemberAdd records pending screening without onboarding', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(env.member, { pending: true }); + await guildMemberAdd.run(app, stub(env.client), env.member); + assert.deepEqual(await getOnboardingState(app.db, 'guild-1', 'member-1'), { + screeningPendingAt: app.clock(), + onboardedAt: null, + }); +}); + +test('recordPendingMembers records only pending non-bot members and isolates failing guilds', async () => { + const member = (id: string, extra: Record) => + stub({ id, user: stub({ id, bot: false }), pending: false, ...extra }); + const guild = (id: string, fetch: () => Promise) => + stub({ id, members: { fetch } }); + const good = guild('guild-1', async () => { + const m = [ + member('p1', { pending: true }), + member('n1', {}), + member('b1', { pending: true, user: stub({ id: 'b1', bot: true }) }), + member('p2', { pending: true }), + ]; + return new Collection(m.map((x) => [x.id, x])); + }); + const bad = guild('guild-2', async () => { + throw new Error('boom'); + }); + await markScreeningPending(app.db, 'guild-1', 'p2', new Date('2027-01-01T00:00:00Z')); + const client = stub({ + guilds: { + cache: new Collection([ + ['guild-2', bad], + ['guild-1', good], + ]), + }, + }); + app.logs.length = 0; + + assert.equal(await recordPendingMembers(app, client), 1); + assert.deepEqual( + (await getOnboardingState(app.db, 'guild-1', 'p1')).screeningPendingAt, + app.clock(), + ); + assert.deepEqual( + (await getOnboardingState(app.db, 'guild-1', 'p2')).screeningPendingAt, + new Date('2027-01-01T00:00:00Z'), + ); + for (const id of ['n1', 'b1']) { + assert.equal((await getOnboardingState(app.db, 'guild-1', id)).screeningPendingAt, null); + } + assert.equal(app.logsAt('error').length, 1); }); test('guildMemberUpdate skips a bot with a partial old member', async () => { From 27639d1b08326cd563c6d760b08075a22b5ce94c Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:24:38 +0100 Subject: [PATCH 59/77] Document recorded screening state for onboarding Co-Authored-By: Claude Haiku 4.5 --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a93e7b33..b78a5506 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ The bot declares these gateway intents. The first two are **privileged** and mus - **Guild Messages** (unprivileged) - **Message Content** (privileged) +The **Guild Members** intent is required for membership screening tracking: on startup, the bot fetches all members for each server (one fetch per server) to record members who are pending screening. + ### Bot Permissions The bot requires specific permissions on a per-feature basis. When inviting the bot, ensure it has: @@ -216,7 +218,7 @@ Roles are only assigned if: If a role cannot be assigned, the error is logged and the onboarding continues for other roles and steps. -**Onboarding Tracking**: Onboarding completion is recorded per member (`onboardedAt`), so a member is never onboarded twice. Leaving the server deletes the profile, so a rejoin is onboarded again. When the bot doesn't have the member's previous state cached (e.g. they joined while the bot was offline), it onboards a member only if they're no longer pending, not a bot, have no recorded onboarding, and joined within the last 7 days; otherwise it skips and logs at info. Members who were already in the server before upgrading are not re-onboarded (except possibly someone who joined within 7 days before the upgrade). +**Onboarding Tracking**: The bot records when a member is pending membership screening (when they join pending, on any update showing them pending, and via a startup sweep that fetches each server's members after login). If the bot doesn't have the member's previous state cached, it onboards them only when a pending state was recorded and they haven't been onboarded yet; otherwise it skips (logged). Onboarding clears the pending marker and records `onboardedAt`, so nobody is onboarded twice. Members already in the server before the upgrade are never re-onboarded. Leaving the server deletes the profile, so a rejoin is onboarded again. Known limitation: a member who was already pending at deploy time and completes screening before the startup sweep records them, while not cached, won't be onboarded automatically (an admin can assign roles manually). ### Audit Log @@ -273,6 +275,7 @@ Migrations run automatically on startup. Current migrations: - **004-bot-state** — Adds the `BotState` key/value table (currently stores the registered command hash) - **005-userprofile-backfill** — Sets NULL activity scores to 0 and deletes profile rows with no server or user id, logging how many - **006-userprofile-onboarded-at** — Adds nullable `UserProfiles.onboardedAt`, recording when a member was onboarded +- **007-userprofile-screening-pending** — Adds nullable `UserProfiles.screeningPendingAt`, recording that a member is waiting on membership screening ### What Gets Deleted From 676d317ac33f85a2efa9c54176f9b08cf00a56ee Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:36:54 +0100 Subject: [PATCH 60/77] Only remove the old birthday calendar once the new one is populated If the new calendar cannot be populated, restore the previous path, delete the new placeholder and keep the existing calendar. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 10 +++--- src/commands/config/groups/birthdays.ts | 34 ++++++++++++++---- tests/birthdays/configCreate.test.ts | 47 +++++++++++++++++++++---- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b78a5506..9e47f8cb 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,11 @@ Roles given to members when they complete onboarding: #### `/config birthdays` — Birthday Calendar -| Subcommand | Description | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays); the existing calendar is kept until the new one has been posted and saved, then the old message is removed | -| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | -| `remove` | Remove the birthday calendar message | +| Subcommand | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays); the existing calendar is kept until the new one has been posted, saved and populated, then the old message is removed (if the new one can't be populated, nothing changes) | +| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | +| `remove` | Remove the birthday calendar message | #### `/config debug` — Onboarding Diagnostics diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index d80267b5..3b66c948 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -66,15 +66,37 @@ export const BirthdaysGroup = defineSubcommandGroup({ } const status = await refreshCalendar(ctx.app, client, ctx.config); + const link = calendarMessageUrl(ctx.guild.id, path); + if (status !== 'updated') { + // Keep whatever calendar existed before: restore its path and drop the new placeholder. + try { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + birthdayCalendarMessagePath: previousPath ?? null, + }); + } catch (error) { + ctx.app.logger.error( + 'Could not restore the previous birthday calendar path', + { guild: ctx.guild.id }, + error, + ); + } + await message.delete().catch((deleteError: unknown) => { + ctx.app.logger.warn( + 'Could not remove the new birthday calendar message after a failed population', + { guild: ctx.guild.id }, + deleteError, + ); + }); + throw new UserError( + previousPath + ? "I couldn't populate the new birthday calendar, so the existing calendar was kept. Try again later." + : "I couldn't populate the new birthday calendar, so nothing was changed. Try again later.", + ); + } if (previousPath && previousPath !== path) { await deleteCalendarMessage(client, previousPath); } - const link = calendarMessageUrl(ctx.guild.id, path); - await ctx.reply( - status === 'updated' - ? `Birthday calendar has been created: ${link}` - : `Birthday calendar message was created (${link}) but could not be populated yet; try \`/config birthdays sync\`.`, - ); + await ctx.reply(`Birthday calendar has been created: ${link}`); }, }, { diff --git a/tests/birthdays/configCreate.test.ts b/tests/birthdays/configCreate.test.ts index dfca29a8..01b69e92 100644 --- a/tests/birthdays/configCreate.test.ts +++ b/tests/birthdays/configCreate.test.ts @@ -10,14 +10,17 @@ import { createTestApp } from '../helpers/app.ts'; import { last } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; -const apps = [createTestApp(), createTestApp()]; +const apps = [createTestApp(), createTestApp(), createTestApp()]; after(() => { apps.forEach((a) => a.close()); }); const create: Handler = BirthdaysGroup.subcommands.find((s) => s.name === 'create')!.run; -const setup = (app: (typeof apps)[number], opts: { failSend?: boolean } = {}) => { +const setup = ( + app: (typeof apps)[number], + opts: { failSend?: boolean; failRefresh?: boolean; noPrevious?: boolean } = {}, +) => { const { clearConfigs, createConfig, findConfig } = dbFixtures(app); clearConfigs(); const order: string[] = []; @@ -27,13 +30,23 @@ const setup = (app: (typeof apps)[number], opts: { failSend?: boolean } = {}) => oldMessage.deleted = true; }; const oldChannel = fakeTextChannel('old-chan', [oldMessage]); - const target = fakeTextChannel('new-chan'); + const newMessage = fakeEditableMessage('sent-1'); + newMessage.delete = async () => { + order.push('delete-new'); + newMessage.deleted = true; + }; + if (opts.failRefresh) { + newMessage.edit = async () => { + throw new Error('edit failed'); + }; + } + const target = fakeTextChannel('new-chan', [newMessage]); target.send = async () => { order.push('send-new'); if (opts.failSend) { throw new Error('missing access'); } - return { id: 'sent-1', channelId: 'new-chan' }; + return { id: 'sent-1', channelId: 'new-chan', delete: () => newMessage.delete() }; }; Object.assign(target, { permissionsFor: () => ({ has: () => true }), @@ -46,7 +59,7 @@ const setup = (app: (typeof apps)[number], opts: { failSend?: boolean } = {}) => }); const config = createConfig({ serverId: 'guild-1', - birthdayCalendarMessagePath: 'old-chan/old-msg', + birthdayCalendarMessagePath: opts.noPrevious ? null : 'old-chan/old-msg', }); const client = fakeClient({ channels: [oldChannel, target] }); const { ctx, replies } = fakeCommandContext( @@ -54,7 +67,7 @@ const setup = (app: (typeof apps)[number], opts: { failSend?: boolean } = {}) => { channel: { id: 'new-chan' } }, { guild, config, client }, ); - return { ctx, replies, order, oldMessage, findConfig }; + return { ctx, replies, order, oldMessage, newMessage, findConfig }; }; test('birthdays create keeps the old calendar and path when sending the new message fails', async () => { @@ -72,6 +85,7 @@ test('birthdays create deletes the old message only after the new path is saved' await create(ctx); assert.equal(oldMessage.deleted, true); assert.deepEqual(order, ['send-new', 'delete-old(path=new-chan/sent-1)']); + assert.equal(last(replies).content!.startsWith('Birthday calendar has been created'), true); assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'new-chan/sent-1'); assert.match(last(replies).content!, /new-chan\/sent-1/); }); @@ -94,3 +108,24 @@ test('birthdays create removes the new message when persisting fails and leaves assert.deepEqual(deleted, ['new-msg']); assert.equal(oldMessage.deleted, false); }); + +test('birthdays create restores the old path and keeps the old calendar when population fails', async () => { + const app = apps[0]!; + const { ctx, replies, order, oldMessage, newMessage, findConfig } = setup(app, { + failRefresh: true, + }); + await assert.rejects(create(ctx), /existing calendar was kept/); + assert.equal(oldMessage.deleted, false); + assert.equal(newMessage.deleted, true); + assert.deepEqual(order, ['send-new', 'delete-new']); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); + assert.equal(replies.length, 0); +}); + +test('birthdays create clears the path when population fails and there was no calendar', async () => { + const app = apps[2]!; + const { ctx, newMessage, findConfig } = setup(app, { failRefresh: true, noPrevious: true }); + await assert.rejects(create(ctx), /nothing was changed/); + assert.equal(newMessage.deleted, true); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); +}); From 8c8fa2f45268759b54780709a934b27386a607ef Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:37:28 +0100 Subject: [PATCH 61/77] Keep the calendar path when deleting the message fails deleteCalendarMessage now reports deleted, already-missing or failed; remove only clears the stored path when the message is gone. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/commands/config/groups/birthdays.ts | 25 ++++++- src/services/BirthdayService.ts | 30 +++++++-- tests/birthdays/configCreate.test.ts | 90 ++++++++++++++++++++++++- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9e47f8cb..0463502e 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Roles given to members when they complete onboarding: | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays); the existing calendar is kept until the new one has been posted, saved and populated, then the old message is removed (if the new one can't be populated, nothing changes) | | `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | -| `remove` | Remove the birthday calendar message | +| `remove` | Remove the birthday calendar message (the stored path is kept if Discord refuses the deletion, so you can retry) | #### `/config debug` — Onboarding Diagnostics diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index 3b66c948..8b28df88 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -94,7 +94,13 @@ export const BirthdaysGroup = defineSubcommandGroup({ ); } if (previousPath && previousPath !== path) { - await deleteCalendarMessage(client, previousPath); + const deleted = await deleteCalendarMessage(ctx.app, client, previousPath); + if (deleted === 'failed') { + ctx.app.logger.warn('Previous birthday calendar message was left in place', { + guild: ctx.guild.id, + path: previousPath, + }); + } } await ctx.reply(`Birthday calendar has been created: ${link}`); }, @@ -126,11 +132,24 @@ export const BirthdaysGroup = defineSubcommandGroup({ if (!ctx.config.birthdayCalendarMessagePath) { throw new UserError('There is no birthday calendar configured.'); } - await deleteCalendarMessage(ctx.interaction.client, ctx.config.birthdayCalendarMessagePath); + const deleted = await deleteCalendarMessage( + ctx.app, + ctx.interaction.client, + ctx.config.birthdayCalendarMessagePath, + ); + if (deleted === 'failed') { + throw new UserError( + "Couldn't delete the calendar message right now; nothing was changed. Try again.", + ); + } ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { birthdayCalendarMessagePath: null, }); - await ctx.reply('Birthday calendar removed.'); + await ctx.reply( + deleted === 'deleted' + ? 'Birthday calendar removed.' + : 'Birthday calendar removed (the message was already gone).', + ); }, }, ], diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 3507105b..88dce13d 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -95,22 +95,38 @@ export const refreshCalendar = async ( } }; +export type DeleteCalendarStatus = 'deleted' | 'already-missing' | 'failed'; + +const MISSING_CODES = new Set([10003, 10008]); // Unknown Channel, Unknown Message + export const deleteCalendarMessage = async ( + app: App, client: Client, path: string | null | undefined, -): Promise => { +): Promise => { const target = parseCalendarPath(path); if (!target) { - return; + return 'already-missing'; } try { const channel = await client.channels.fetch(target.channelId); - if (channel?.isTextBased()) { - const message = await channel.messages.fetch(target.messageId); - await message.delete(); + if (!channel?.isTextBased()) { + return 'already-missing'; } - } catch { - // best effort: the message may already be gone + const message = await channel.messages.fetch(target.messageId); + if (!message) { + return 'already-missing'; + } + await message.delete(); + return 'deleted'; + } catch (error) { + const code = + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + if (typeof code === 'number' && MISSING_CODES.has(code)) { + return 'already-missing'; + } + app.logger.warn('Could not delete the birthday calendar message', { path }, error); + return 'failed'; } }; diff --git a/tests/birthdays/configCreate.test.ts b/tests/birthdays/configCreate.test.ts index 01b69e92..808e8660 100644 --- a/tests/birthdays/configCreate.test.ts +++ b/tests/birthdays/configCreate.test.ts @@ -4,8 +4,9 @@ import type { Guild } from 'discord.js'; import BirthdaysGroup from '../../src/commands/config/groups/birthdays.ts'; import type { Handler } from '../../src/framework/command.ts'; import { fakeCommandContext } from '../fakes/command.ts'; +import { deleteCalendarMessage } from '../../src/services/BirthdayService.ts'; import { stub } from '../fakes/discord.ts'; -import { fakeClient, fakeEditableMessage, fakeTextChannel } from '../fakes/guild.ts'; +import { apiError, fakeClient, fakeEditableMessage, fakeTextChannel } from '../fakes/guild.ts'; import { createTestApp } from '../helpers/app.ts'; import { last } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; @@ -129,3 +130,90 @@ test('birthdays create clears the path when population fails and there was no ca assert.equal(newMessage.deleted, true); assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); }); + +const remove: Handler = BirthdaysGroup.subcommands.find((s) => s.name === 'remove')!.run; + +const freshApp = () => { + const app = createTestApp(); + after(() => app.close()); + return app; +}; + +const setupRemove = (app: (typeof apps)[number], channelsFor: 'normal' | 'none' | 'throw') => { + const { clearConfigs, createConfig, findConfig } = dbFixtures(app); + clearConfigs(); + const message = fakeEditableMessage('old-msg'); + const channel = fakeTextChannel('old-chan', [message]); + const config = createConfig({ + serverId: 'guild-1', + birthdayCalendarMessagePath: 'old-chan/old-msg', + }); + const client = fakeClient({ + channels: channelsFor === 'none' ? [] : [channel], + ...(channelsFor === 'throw' + ? { channelFetchError: Object.assign(new Error('boom'), { code: 500 }) } + : {}), + }); + const { ctx, replies } = fakeCommandContext(app, {}, { config, client }); + return { ctx, replies, message, channel, findConfig }; +}; + +test('birthdays remove clears the path after deleting the message', async () => { + const { ctx, replies, message, findConfig } = setupRemove(freshApp(), 'normal'); + await remove(ctx); + assert.equal(message.deleted, true); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); + assert.equal(last(replies).content, 'Birthday calendar removed.'); +}); + +test('birthdays remove clears the path when the channel is already gone', async () => { + const { ctx, replies, findConfig } = setupRemove(freshApp(), 'none'); + await remove(ctx); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); + assert.match(last(replies).content!, /already gone/); +}); + +test('birthdays remove clears the path when the message is already gone (10008)', async () => { + const { ctx, replies, channel, findConfig } = setupRemove(freshApp(), 'normal'); + channel.messages.fetch = async () => { + throw apiError(10008); + }; + await remove(ctx); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); + assert.match(last(replies).content!, /already gone/); +}); + +test('birthdays remove keeps the path and errors when deletion fails', async () => { + const app = createTestApp(); + after(() => app.close()); + const { ctx, replies, channel, findConfig } = setupRemove(app, 'normal'); + channel.messages.fetch = async () => { + throw apiError(50013); + }; + await assert.rejects(remove(ctx), /nothing was changed/); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); + assert.equal(replies.length, 0); +}); + +test('deleteCalendarMessage classifies results', async () => { + const app = createTestApp(); + after(() => app.close()); + const msg = fakeEditableMessage('m'); + const chan = fakeTextChannel('c', [msg]); + assert.equal( + await deleteCalendarMessage(app, fakeClient({ channels: [chan] }), 'c/m'), + 'deleted', + ); + assert.equal(await deleteCalendarMessage(app, fakeClient(), 'c/m'), 'already-missing'); + assert.equal( + await deleteCalendarMessage(app, fakeClient({ channels: [chan] }), 'c/other'), + 'already-missing', + ); + assert.equal(await deleteCalendarMessage(app, fakeClient(), null), 'already-missing'); + const unknownChannel = fakeClient({ channelFetchError: apiError(10003) }); + assert.equal(await deleteCalendarMessage(app, unknownChannel, 'c/m'), 'already-missing'); + const forbidden = fakeClient({ channelFetchError: apiError(50013) }); + assert.equal(await deleteCalendarMessage(app, forbidden, 'c/m'), 'failed'); + const plain = fakeClient({ channelFetchError: new Error('network') }); + assert.equal(await deleteCalendarMessage(app, plain, 'c/m'), 'failed'); +}); From b5eda772380c78549512d8c93f69bd74afaa397c Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:37:52 +0100 Subject: [PATCH 62/77] Stop the data check recreating purged server configs Add updateExistingConfig (update-only) and use it for the data check's access-state writes, skipping the guild if its row is gone. Co-Authored-By: Claude Sonnet 5.5 --- src/services/ConfigService.ts | 23 +++++++++++++++++++++++ src/services/DataCheckService.ts | 8 ++++++-- tests/database/services.test.ts | 18 ++++++++++++++++++ tests/datacheck/DataCheckService.test.ts | 12 ++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 3a8d8826..947d7f0b 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -55,6 +55,29 @@ export async function updateConfig( return updated; } +/** + * Patch a config only if its row still exists (no upsert). For background jobs, which must not + * resurrect a config that was purged while they were running. + */ +export async function updateExistingConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, + patch: ServerConfigPatch, +): Promise { + configCache.delete(serverId); + const updated = db + .update(serverConfigs) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(serverConfigs.serverId, serverId)) + .returning() + .get(); + if (!updated) { + return null; + } + configCache.set(serverId, updated); + return updated; +} + export async function deleteConfig( { db, configCache }: ConfigDeps, serverId: Snowflake, diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index 8710ec6b..dc7aaa16 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,7 +1,7 @@ import type { Client } from 'discord.js'; import { type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; -import { getConfigs, purgeGuild, updateConfig } from './ConfigService.ts'; +import { getConfigs, purgeGuild, updateExistingConfig } from './ConfigService.ts'; import { deleteUsers, getServerProfiles } from './UserProfileService.ts'; import { type AccessOutcome, @@ -75,10 +75,14 @@ export const runDataCheck = async ( next.accessFailureCount !== config.accessFailureCount || next.firstAccessFailureAt?.getTime() !== config.firstAccessFailureAt?.getTime() ) { - await updateConfig(app, config.serverId, { + const updated = await updateExistingConfig(app, config.serverId, { accessFailureCount: next.accessFailureCount, firstAccessFailureAt: next.firstAccessFailureAt, }); + if (!updated) { + logger.debug('Config removed during data check; skipping guild', { guild }); + continue; + } } if (outcome === 'ok') { diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index 4bf7a60d..fe9569db 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -6,6 +6,7 @@ import { getConfigs, resetAccessFailures, updateConfig, + updateExistingConfig, } from '../../src/services/ConfigService.ts'; import { clearBirthday, @@ -17,6 +18,8 @@ import { incrementActivityScore, setBirthday, } from '../../src/services/UserProfileService.ts'; +import { eq } from 'drizzle-orm'; +import { serverConfigs, type ServerConfig } from '../../src/db/schema.ts'; import { createTestApp } from '../helpers/app.ts'; import { nth } from '../helpers/assertions.ts'; import { dbFixtures } from '../helpers/db.ts'; @@ -164,3 +167,18 @@ test('screening pending state is recorded, independent of onboardedAt, and clear onboardedAt, }); }); + +test('updateExistingConfig patches an existing row but never creates one', async () => { + await updateConfig(app, 's-existing', { debug: false }); + const patched = await updateExistingConfig(app, 's-existing', { debug: true }); + assert.equal(patched?.debug, true); + assert.equal(app.configCache.get('s-existing')?.debug, true); + + app.configCache.set('s-gone', { serverId: 's-gone' } as ServerConfig); + assert.equal(await updateExistingConfig(app, 's-gone', { debug: true }), null); + assert.equal(app.configCache.has('s-gone'), false); + assert.equal( + app.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's-gone')).get(), + undefined, + ); +}); diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts index ae66bacc..24f69dbe 100644 --- a/tests/datacheck/DataCheckService.test.ts +++ b/tests/datacheck/DataCheckService.test.ts @@ -140,3 +140,15 @@ test('a profile created while the member list is being fetched is not deleted', ['a', 'late-joiner'], ); }); + +test('a guild purged mid-run does not get its config recreated', async () => { + createConfig({ serverId: 'g1' }); + const client = fakeClient({ guilds: { g1: apiError(50001) } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + clearConfigs(); + return fetchGuild(id); + }) as typeof client.guilds.fetch; + await run(client, t0, policy); + assert.equal(reload('g1'), null); +}); From 210f9aed0b6e06882338f0f66c818f0471aa33c5 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:37:58 +0100 Subject: [PATCH 63/77] Update supported versions in SECURITY.md Co-Authored-By: Claude Sonnet 5.5 --- SECURITY.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 67843931..7933e24e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,5 @@ | Version | Supported | | ------- | ------------------ | -| 1.0.x | :no_entry: | -| 1.1.x | :no_entry: | -| 1.2.x | :no_entry: | -| 1.3.x | :no_entry: | -| 2.0.x | :white_check_mark: | +| 3.0.x | :white_check_mark: | +| < 3.0 | :x: | From 294936446e35f7ec690afe33c5ebe4a663e9037a Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:13:28 +0100 Subject: [PATCH 64/77] Validate JOB_SCHEDULE by parsing it with croner Co-Authored-By: Claude Sonnet 5.5 --- src/config/env.ts | 19 ++++++++++++++----- tests/env.test.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index f2896e82..46d99b87 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,3 +1,4 @@ +import { Cron } from 'croner'; import { IANAZone } from 'luxon'; import { DEFAULT_LOG_LEVEL, @@ -48,17 +49,25 @@ export function loadEnv(source: Source = process.env): Readonly { const clientId = required('CLIENT_ID'); const jobSchedule = read('JOB_SCHEDULE') ?? '0 9 * * *'; + const timezone = read('BOT_TIMEZONE') ?? 'Europe/London'; + const timezoneValid = IANAZone.isValidZone(timezone); + if (!timezoneValid) { + problems.push(`BOT_TIMEZONE must be a valid IANA time zone (got "${timezone}")`); + } + const cronFields = jobSchedule.split(/\s+/); if ( (cronFields.length !== 5 && cronFields.length !== 6) || !cronFields.every((field) => CRON_FIELD.test(field)) ) { problems.push(`JOB_SCHEDULE must be a 5- or 6-field cron expression (got "${jobSchedule}")`); - } - - const timezone = read('BOT_TIMEZONE') ?? 'Europe/London'; - if (!IANAZone.isValidZone(timezone)) { - problems.push(`BOT_TIMEZONE must be a valid IANA time zone (got "${timezone}")`); + } else { + try { + new Cron(jobSchedule, timezoneValid ? { paused: true, timezone } : { paused: true }).stop(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + problems.push(`JOB_SCHEDULE is not a valid cron expression: ${message}`); + } } const dbPath = read('DB_PATH') ?? 'data/dingir.sqlite'; diff --git a/tests/env.test.ts b/tests/env.test.ts index c83690df..fe771c20 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -85,3 +85,15 @@ test('parses and validates LOG_LEVEL', () => { assert.equal(loadEnv({ ...valid, LOG_LEVEL: 'debug' }).logLevel, 'debug'); assert.throws(() => loadEnv({ ...valid, LOG_LEVEL: 'bogus' }), /LOG_LEVEL must be one of/); }); + +test('rejects a cron expression croner cannot parse', () => { + assert.throws( + () => loadEnv({ ...valid, JOB_SCHEDULE: '99 99 99 99 99' }), + /JOB_SCHEDULE is not a valid cron expression/, + ); +}); + +test('accepts 5- and 6-field cron expressions', () => { + assert.equal(loadEnv({ ...valid, JOB_SCHEDULE: '0 9 * * *' }).jobSchedule, '0 9 * * *'); + assert.equal(loadEnv({ ...valid, JOB_SCHEDULE: '30 0 9 * * *' }).jobSchedule, '30 0 9 * * *'); +}); From d06e501d91ca16888c291c5b03b9530c7a881ad2 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:13:45 +0100 Subject: [PATCH 65/77] Require Read Message History to create the birthday calendar Co-Authored-By: Claude Sonnet 5.5 --- src/commands/config/groups/birthdays.ts | 7 +++++-- tests/birthdays/configCreate.test.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index 8b28df88..b366cb08 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -35,9 +35,12 @@ export const BirthdaysGroup = defineSubcommandGroup({ const perms = me ? channel.permissionsFor(me) : null; if ( !perms?.has(PermissionFlagsBits.ViewChannel) || - !perms.has(PermissionFlagsBits.SendMessages) + !perms.has(PermissionFlagsBits.SendMessages) || + !perms.has(PermissionFlagsBits.ReadMessageHistory) ) { - throw new UserError('I need permission to view and send messages in that channel.'); + throw new UserError( + 'I need permission to view the channel, send messages and read message history in that channel.', + ); } const client = ctx.interaction.client; diff --git a/tests/birthdays/configCreate.test.ts b/tests/birthdays/configCreate.test.ts index 808e8660..5859a342 100644 --- a/tests/birthdays/configCreate.test.ts +++ b/tests/birthdays/configCreate.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, test } from 'node:test'; -import type { Guild } from 'discord.js'; +import { PermissionFlagsBits, type Guild } from 'discord.js'; import BirthdaysGroup from '../../src/commands/config/groups/birthdays.ts'; import type { Handler } from '../../src/framework/command.ts'; import { fakeCommandContext } from '../fakes/command.ts'; @@ -217,3 +217,18 @@ test('deleteCalendarMessage classifies results', async () => { const plain = fakeClient({ channelFetchError: new Error('network') }); assert.equal(await deleteCalendarMessage(app, plain, 'c/m'), 'failed'); }); + +test('birthdays create requires Read Message History and sends nothing without it', async () => { + const app = apps[0]!; + const { ctx, replies, order, findConfig } = setup(app); + const channel = await ctx.guild.channels.fetch('new-chan'); + Object.assign(channel!, { + permissionsFor: () => ({ + has: (flag: bigint) => flag !== PermissionFlagsBits.ReadMessageHistory, + }), + }); + await assert.rejects(create(ctx), /read message history/); + assert.deepEqual(order, []); + assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); + assert.equal(replies.length, 0); +}); From 38572adde2540f663aacb373a0ea264efccf2288 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:14:14 +0100 Subject: [PATCH 66/77] Treat a member join as a new screening cycle Co-Authored-By: Claude Sonnet 5.5 --- src/events/guildMemberAdd.ts | 6 ++-- src/events/guildMemberUpdate.ts | 8 +++-- src/services/UserProfileService.ts | 31 +++++++++++++++++ tests/onboarding/events.test.ts | 53 ++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 6071df24..f382259b 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,7 +1,7 @@ import { defineEvent } from '../framework/event.ts'; import { getConfig } from '../services/ConfigService.ts'; import { auditJoin, complete } from '../services/OnboardingService.ts'; -import { markScreeningPending } from '../services/UserProfileService.ts'; +import { clearOnboarded, startScreeningCycle } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberAdd', @@ -14,8 +14,10 @@ export default defineEvent({ return; } if (member.pending) { - await markScreeningPending(app.db, member.guild.id, member.id, app.clock()); + await startScreeningCycle(app.db, member.guild.id, member.id, app.clock()); } else { + // A join is a new membership, so any recorded onboarding is stale. + await clearOnboarded(app.db, member.guild.id, member.id); await complete(app, client, member, config, { skipAudit: true }); } }, diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 31164606..7eb0b2cc 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -14,6 +14,10 @@ export default defineEvent({ app.logger.info(`Skipping onboarding: ${reason}`, ids); }; const state = await getOnboardingState(app.db, newMember.guild.id, newMember.id); + // An onboarding recorded before the current screening cycle began belongs to a previous membership. + const alreadyOnboarded = + state.onboardedAt !== null && + !(state.screeningPendingAt !== null && state.onboardedAt < state.screeningPendingAt); if (newMember.pending === true) { // Keep the recorded state fresh; onboarding from an unknown state requires it. @@ -28,7 +32,7 @@ export default defineEvent({ if (newMember.pending !== false) { return; } - if (state.onboardedAt) { + if (alreadyOnboarded) { skip('previous member state unknown and onboarding already recorded'); return; } @@ -40,7 +44,7 @@ export default defineEvent({ if (!completedScreening(oldMember, newMember)) { return; } - if (state.onboardedAt) { + if (alreadyOnboarded) { skip('onboarding already recorded'); return; } diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index ed4a05b1..cc7777a4 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -165,6 +165,37 @@ export async function markScreeningPending( .run(); } +/** + * Starts a fresh screening cycle for a newly joined member: records the pending time and clears any + * stale onboardedAt left over from a previous membership whose leave was missed. + */ +export async function startScreeningCycle( + db: Db, + serverId: Snowflake, + userId: Snowflake, + at: Date, +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, screeningPendingAt: at }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { screeningPendingAt: at, onboardedAt: null, updatedAt: at }, + }) + .run(); +} + +/** Forgets a recorded onboarding (a new membership has begun). Leaves screening state untouched. */ +export async function clearOnboarded( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + db.update(userProfiles) + .set({ onboardedAt: null, updatedAt: new Date() }) + .where(and(forUser(serverId, userId), isNotNull(userProfiles.onboardedAt))) + .run(); +} + export async function clearScreeningPending( db: Db, serverId: Snowflake, diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts index 9c42f6ee..dc382f5f 100644 --- a/tests/onboarding/events.test.ts +++ b/tests/onboarding/events.test.ts @@ -242,3 +242,56 @@ test('guildMemberUpdate sends the completion audit when screening finishes', asy assert.equal(env.auditSends.length, 1); assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /Member completed onboarding/); }); + +const STALE = new Date('2026-12-01T00:00:00Z'); + +test('a rejoin while pending starts a new cycle and clears the stale onboarding', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', STALE); + const env = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(env.member, { pending: true }); + await guildMemberAdd.run(app, stub(env.client), env.member); + assert.deepEqual(await getOnboardingState(app.db, 'guild-1', 'member-1'), { + screeningPendingAt: app.clock(), + onboardedAt: null, + }); + assert.equal(env.roleAdds.length, 0); +}); + +test('completing screening after a pending rejoin onboards via the known path', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', STALE); + const joining = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(joining.member, { pending: true }); + await guildMemberAdd.run(app, stub(joining.client), joining.member); + assert.equal(await update({ pending: true }, false), 1); + assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); +}); + +test('completing screening after a pending rejoin onboards via the partial path', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', STALE); + const joining = fakeOnboarding({ roles: [role('r1')] }); + Object.assign(joining.member, { pending: true }); + await guildMemberAdd.run(app, stub(joining.client), joining.member); + assert.equal(await partialUpdate({}), 1); +}); + +test('a rejoin that is not pending onboards despite a stale onboarding', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', STALE); + const env = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); + Object.assign(env.member, { pending: false }); + await guildMemberAdd.run(app, stub(env.client), env.member); + assert.equal(env.roleAdds.length, 1); + assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); +}); + +test('a plain update with a stale onboarding and no join is not re-onboarded', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', STALE); + assert.equal(await update({ pending: true }, true), 0); + assert.equal(await update({ pending: true }, false), 0); + assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), STALE); +}); + +test('an update-recorded pending newer than onboarding is treated as a new cycle', async () => { + await markOnboarded(app.db, 'guild-1', 'member-1', STALE); + await markScreeningPending(app.db, 'guild-1', 'member-1', new Date('2027-01-13T00:00:00Z')); + assert.equal(await update({ pending: true }, false), 1); +}); From 466ddf051158d5211461274e30e79019f108c241 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:18:26 +0100 Subject: [PATCH 67/77] Specify and consolidate onboarding state handling Document the onboarding state machine, claim the single onboarding per membership atomically before any step runs, drop state from previous memberships using the join time, catch up on screenings that finished while offline, and replace the piecemeal event tests with a table-driven state machine test. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/events/guildMemberAdd.ts | 20 +- src/events/guildMemberUpdate.ts | 52 +-- src/events/ready.ts | 6 +- src/services/OnboardingService.ts | 74 +++- src/services/ScreeningService.ts | 68 +++- src/services/UserProfileService.ts | 84 +++-- tests/database/services.test.ts | 33 -- tests/onboarding/events.test.ts | 297 --------------- tests/onboarding/stateMachine.test.ts | 500 ++++++++++++++++++++++++++ 10 files changed, 693 insertions(+), 443 deletions(-) delete mode 100644 tests/onboarding/events.test.ts create mode 100644 tests/onboarding/stateMachine.test.ts diff --git a/README.md b/README.md index 0463502e..df5e51f9 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The bot declares these gateway intents. The first two are **privileged** and mus - **Guild Messages** (unprivileged) - **Message Content** (privileged) -The **Guild Members** intent is required for membership screening tracking: on startup, the bot fetches all members for each server (one fetch per server) to record members who are pending screening. +The **Guild Members** intent is required for membership screening tracking: on startup, the bot fetches all members for each server (one fetch per server) to record members who are pending screening, and onboards members whose screening finished while the bot was offline. ### Bot Permissions diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index f382259b..fb156d6a 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,23 +1,25 @@ import { defineEvent } from '../framework/event.ts'; import { getConfig } from '../services/ConfigService.ts'; import { auditJoin, complete } from '../services/OnboardingService.ts'; -import { clearOnboarded, startScreeningCycle } from '../services/UserProfileService.ts'; +import { claimOnboarding, startMembership } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberAdd', run: async (app, client, member) => { const config = await getConfig(app, member.guild.id); + const ids = [app.db, member.guild.id, member.id] as const; + + // A join is a new membership. Reset and claim before the first network await, so a concurrent + // leave or update event sees the new state and the join audit cannot delay or duplicate it. + let claimed = false; + if (!member.user.bot) { + await startMembership(...ids, app.clock(), member.pending); + claimed = !member.pending && (await claimOnboarding(...ids, app.clock())); + } await auditJoin(app, client, member, config); - if (member.user.bot) { - return; - } - if (member.pending) { - await startScreeningCycle(app.db, member.guild.id, member.id, app.clock()); - } else { - // A join is a new membership, so any recorded onboarding is stale. - await clearOnboarded(app.db, member.guild.id, member.id); + if (claimed) { await complete(app, client, member, config, { skipAudit: true }); } }, diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 7eb0b2cc..af723259 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,7 +1,8 @@ import { defineEvent } from '../framework/event.ts'; -import { complete, completedScreening } from '../services/OnboardingService.ts'; +import { completedScreening, onboard } from '../services/OnboardingService.ts'; import { getConfig } from '../services/ConfigService.ts'; -import { getOnboardingState, markScreeningPending } from '../services/UserProfileService.ts'; +import { currentOnboardingState } from '../services/ScreeningService.ts'; +import { recordScreeningPending } from '../services/UserProfileService.ts'; export default defineEvent({ name: 'guildMemberUpdate', @@ -9,47 +10,24 @@ export default defineEvent({ if (newMember.user.bot) { return; } - const ids = { guild: newMember.guild.id, member: newMember.id }; - const skip = (reason: string): void => { - app.logger.info(`Skipping onboarding: ${reason}`, ids); - }; - const state = await getOnboardingState(app.db, newMember.guild.id, newMember.id); - // An onboarding recorded before the current screening cycle began belongs to a previous membership. - const alreadyOnboarded = - state.onboardedAt !== null && - !(state.screeningPendingAt !== null && state.onboardedAt < state.screeningPendingAt); + const state = await currentOnboardingState(app, newMember); if (newMember.pending === true) { - // Keep the recorded state fresh; onboarding from an unknown state requires it. - if (state.screeningPendingAt === null && state.onboardedAt === null) { - await markScreeningPending(app.db, newMember.guild.id, newMember.id, app.clock()); - } + await recordScreeningPending(app.db, newMember.guild.id, newMember.id, app.clock()); + return; + } + if (newMember.pending !== false) { return; } - if (oldMember.partial) { - // The previous state is unknown, so only a recorded pending state proves screening just ended. - if (newMember.pending !== false) { - return; - } - if (alreadyOnboarded) { - skip('previous member state unknown and onboarding already recorded'); - return; - } - if (!state.screeningPendingAt) { - skip('previous member state unknown and no pending screening recorded'); - return; - } - } else { - if (!completedScreening(oldMember, newMember)) { - return; - } - if (alreadyOnboarded) { - skip('onboarding already recorded'); - return; - } + // With an unknown previous state only a recorded pending state proves screening just ended. + const screeningEnded = oldMember.partial + ? state.screeningPendingAt !== null + : completedScreening(oldMember, newMember); + if (!screeningEnded) { + return; } - await complete(app, client, newMember, await getConfig(app, newMember.guild.id)); + await onboard(app, client, newMember, await getConfig(app, newMember.guild.id)); }, }); diff --git a/src/events/ready.ts b/src/events/ready.ts index 68e1ecf0..11ac8a5b 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -2,7 +2,7 @@ import { defineEvent } from '../framework/event.ts'; import { syncCommands } from '../framework/registrar.ts'; import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService.ts'; import { runDataCheck } from '../services/DataCheckService.ts'; -import { recordPendingMembers } from '../services/ScreeningService.ts'; +import { syncScreeningState } from '../services/ScreeningService.ts'; import { Scheduler } from '../services/Scheduler.ts'; export default defineEvent({ @@ -32,8 +32,8 @@ export default defineEvent({ logger.error('Could not start scheduler; scheduled jobs are disabled', undefined, error); } - void recordPendingMembers(app, client).catch((error: unknown) => { - logger.error('Could not record pending members', undefined, error); + void syncScreeningState(app, client).catch((error: unknown) => { + logger.error('Could not sync screening state', undefined, error); }); }, }); diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 7d681c1a..5df2581f 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -1,3 +1,47 @@ +/* + * ONBOARDING SPEC + * + * Persisted state, per (guild, user) in UserProfiles, describes the CURRENT membership only: + * screeningPendingAt member was seen awaiting membership screening (cleared on onboarding) + * onboardedAt the one onboarding of this membership has been claimed + * Rows are deleted when the member leaves (guildMemberRemove) or the guild is purged. + * + * Transitions (state = pending/onboarded; "-" = none): + * event state action new state + * join, pending any reset, no onboarding pending + * join, not pending any reset, claim, onboard onboarded + * update pending (true) - record pending pending + * update pending (true) pending/onb. nothing unchanged + * update true -> false (known) -/pending claim, onboard onboarded + * update true -> false (known) onboarded nothing (replay) onboarded + * update ? -> false (old partial) pending claim, onboard onboarded + * update ? -> false (old partial) -/onboarded nothing (cannot prove screening) unchanged + * update, any other any nothing unchanged + * leave any delete row - + * startup sweep (per member) any drop state older than the member's see rows above + * joinedTimestamp; pending -> record + * pending; recorded pending but no + * longer pending -> claim, onboard + * + * Invariants: + * 1. A member is onboarded at most once per membership: the claim is one atomic conditional + * upsert made before any step runs. A crash after the claim loses the welcome; it is never + * repeated. Individual step failures (roles, welcome, audit) are reported, never retried. + * 2. State left by a previous membership never suppresses onboarding: joins reset it, and where + * the join was missed (bot offline) any timestamp older than joinedTimestamp is dropped + * whenever the member is next seen (update event or sweep). + * 3. Dry runs (/simulate) never touch persisted state. + * 4. Bots are never onboarded or tracked. + * + * Failure handling: + * - Discord send/role calls fail: the step reports failed, an audit is attempted, other steps + * and the claim stand. + * - Old member partial or uncached: only a recorded pending state proves screening just ended. + * - Bot offline during a join/leave/update: the sweep reconciles at the next startup; members + * whose screening ended while offline are onboarded late. + * - Concurrent events during an await: the claim is the only synchronisation point and is atomic. + */ + import type { AttachmentBuilder, Client, Guild, GuildMember, Role } from 'discord.js'; import { type PartialGuildMember, type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; @@ -5,7 +49,7 @@ import type { ServerConfig } from '../db/schema.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; import { memberAuditEmbed } from './AuditEmbed.ts'; import { sendAudit } from './AuditService.ts'; -import { markOnboarded } from './UserProfileService.ts'; +import { claimOnboarding } from './UserProfileService.ts'; import { WelcomeImage, type WelcomeImageRenderer } from './WelcomeImage.ts'; /** 'done' | 'skipped:' | 'failed:' */ @@ -343,12 +387,28 @@ export async function complete( result.debug = 'skipped:dry run'; } - if (!dryRun) { - // Record the attempt whatever the individual steps did, so replayed events don't repeat it. - await markOnboarded(app.db, member.guild.id, member.id, app.clock()); - } - return result; } -export const OnboardingService = { complete }; +/** + * Onboards a member at most once per membership: the claim is recorded before any step runs, so a + * concurrent or replayed event finds it taken. Returns null when the member was already onboarded. + */ +export async function onboard( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, + options: OnboardingOptions = {}, +): Promise { + if (!(await claimOnboarding(app.db, member.guild.id, member.id, app.clock()))) { + app.logger.info('Skipping onboarding: already onboarded this membership', { + guild: member.guild.id, + member: member.id, + }); + return null; + } + return complete(app, client, member, config, options); +} + +export const OnboardingService = { complete, onboard }; diff --git a/src/services/ScreeningService.ts b/src/services/ScreeningService.ts index b673bf90..2a1fdf43 100644 --- a/src/services/ScreeningService.ts +++ b/src/services/ScreeningService.ts @@ -1,30 +1,72 @@ -import type { Client } from 'discord.js'; +import type { Client, GuildMember } from 'discord.js'; import type { App } from '../app.ts'; -import { getOnboardingState, markScreeningPending } from './UserProfileService.ts'; +import { getConfig } from './ConfigService.ts'; +import { onboard } from './OnboardingService.ts'; +import { + dropStaleOnboardingState, + getOnboardingState, + recordScreeningPending, + type OnboardingState, +} from './UserProfileService.ts'; + +/** Tolerance for clock differences between Discord's joinedTimestamp and the bot's clock. */ +const JOIN_SKEW_MS = 60_000; + +/** + * The member's recorded onboarding state, after dropping anything recorded before they last + * joined (a previous membership whose leave or join was missed while the bot was offline). + */ +export async function currentOnboardingState( + app: App, + member: GuildMember, +): Promise { + if (member.joinedTimestamp) { + await dropStaleOnboardingState( + app.db, + member.guild.id, + member.id, + new Date(member.joinedTimestamp - JOIN_SKEW_MS), + ); + } + return getOnboardingState(app.db, member.guild.id, member.id); +} /** - * Records every member currently pending membership screening, so a later update with an unknown - * previous state can still be trusted. Run at startup for members who joined before this was tracked. + * Startup reconciliation for events missed while offline: records members currently pending + * screening (so a later update with an unknown previous state can be trusted), and onboards members + * recorded as pending whose screening has since ended. */ -export async function recordPendingMembers(app: App, client: Client): Promise { +export async function syncScreeningState( + app: App, + client: Client, +): Promise<{ recorded: number; caughtUp: number }> { let recorded = 0; + let caughtUp = 0; for (const guild of client.guilds.cache.values()) { try { const members = await guild.members.fetch(); for (const member of members.values()) { - if (member.user.bot || member.pending !== true) { + // A guildDelete during the sweep purges this guild's rows; never recreate them. + if (member.user.bot || !client.guilds.cache.has(guild.id)) { continue; } - const state = await getOnboardingState(app.db, guild.id, member.id); - if (state.screeningPendingAt === null && state.onboardedAt === null) { - await markScreeningPending(app.db, guild.id, member.id, app.clock()); - recorded++; + const state = await currentOnboardingState(app, member); + if (member.pending === true) { + if (state.screeningPendingAt === null && state.onboardedAt === null) { + await recordScreeningPending(app.db, guild.id, member.id, app.clock()); + recorded++; + } + } else if (state.screeningPendingAt !== null && state.onboardedAt === null) { + const config = await getConfig(app, guild.id); + if (await onboard(app, client, member, config)) { + caughtUp++; + } } } } catch (error) { - app.logger.error('Could not record pending members', { guild: guild.id }, error); + app.logger.error('Could not sync screening state', { guild: guild.id }, error); } } - app.logger.info('Recorded pending members', { count: recorded }); - return recorded; + app.logger.info('Synced screening state', { recorded, caughtUp }); + return { recorded, caughtUp }; } diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index cc7777a4..ad818d39 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -1,5 +1,5 @@ import type { Snowflake } from 'discord.js'; -import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNotNull, isNull, lt, or, sql } from 'drizzle-orm'; import type { Db } from '../db/db.ts'; import { userProfiles, type UserProfile } from '../db/schema.ts'; @@ -126,50 +126,52 @@ export async function deleteUsersByServer(db: Db, serverId: Snowflake): Promise< return result.changes > 0; } -export async function markOnboarded( +/** + * Begins a new membership: forgets every state recorded for a previous one. Pass `pending` when + * the member is awaiting screening. + */ +export async function startMembership( db: Db, serverId: Snowflake, userId: Snowflake, at: Date, + pending: boolean, ): Promise { + const screeningPendingAt = pending ? at : null; db.insert(userProfiles) - .values({ serverId, userId, onboardedAt: at }) + .values({ serverId, userId, screeningPendingAt }) .onConflictDoUpdate({ target: [userProfiles.serverId, userProfiles.userId], - set: { onboardedAt: at, screeningPendingAt: null, updatedAt: at }, + set: { screeningPendingAt, onboardedAt: null, updatedAt: at }, }) .run(); } -export async function getOnboardedAt( - db: Db, - serverId: Snowflake, - userId: Snowflake, -): Promise { - return (await findUserProfile(db, serverId, userId))?.onboardedAt ?? null; -} - -/** Records that the member is awaiting membership screening. Leaves onboardedAt untouched. */ -export async function markScreeningPending( +/** + * Atomically claims the single onboarding of the current membership. Returns false when it was + * already claimed, so concurrent or replayed events cannot onboard a member twice. + */ +export async function claimOnboarding( db: Db, serverId: Snowflake, userId: Snowflake, at: Date, -): Promise { - db.insert(userProfiles) - .values({ serverId, userId, screeningPendingAt: at }) - .onConflictDoUpdate({ - target: [userProfiles.serverId, userProfiles.userId], - set: { screeningPendingAt: at, updatedAt: at }, - }) - .run(); -} - -/** - * Starts a fresh screening cycle for a newly joined member: records the pending time and clears any - * stale onboardedAt left over from a previous membership whose leave was missed. - */ -export async function startScreeningCycle( +): Promise { + return ( + db + .insert(userProfiles) + .values({ serverId, userId, onboardedAt: at }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { onboardedAt: at, screeningPendingAt: null, updatedAt: at }, + setWhere: isNull(userProfiles.onboardedAt), + }) + .run().changes > 0 + ); +} + +/** Records that a member is awaiting screening, unless anything is already known about them. */ +export async function recordScreeningPending( db: Db, serverId: Snowflake, userId: Snowflake, @@ -179,31 +181,27 @@ export async function startScreeningCycle( .values({ serverId, userId, screeningPendingAt: at }) .onConflictDoUpdate({ target: [userProfiles.serverId, userProfiles.userId], - set: { screeningPendingAt: at, onboardedAt: null, updatedAt: at }, + set: { screeningPendingAt: at, updatedAt: at }, + setWhere: and(isNull(userProfiles.screeningPendingAt), isNull(userProfiles.onboardedAt)), }) .run(); } -/** Forgets a recorded onboarding (a new membership has begun). Leaves screening state untouched. */ -export async function clearOnboarded( +/** Drops recorded timestamps older than `cutoff`: they belong to a membership that has ended. */ +export async function dropStaleOnboardingState( db: Db, serverId: Snowflake, userId: Snowflake, + cutoff: Date, ): Promise { + const mine = forUser(serverId, userId); db.update(userProfiles) - .set({ onboardedAt: null, updatedAt: new Date() }) - .where(and(forUser(serverId, userId), isNotNull(userProfiles.onboardedAt))) + .set({ onboardedAt: null }) + .where(and(mine, lt(userProfiles.onboardedAt, cutoff))) .run(); -} - -export async function clearScreeningPending( - db: Db, - serverId: Snowflake, - userId: Snowflake, -): Promise { db.update(userProfiles) - .set({ screeningPendingAt: null, updatedAt: new Date() }) - .where(and(forUser(serverId, userId), isNotNull(userProfiles.screeningPendingAt))) + .set({ screeningPendingAt: null }) + .where(and(mine, lt(userProfiles.screeningPendingAt, cutoff))) .run(); } diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts index fe9569db..2705bf01 100644 --- a/tests/database/services.test.ts +++ b/tests/database/services.test.ts @@ -10,10 +10,6 @@ import { } from '../../src/services/ConfigService.ts'; import { clearBirthday, - clearScreeningPending, - getOnboardingState, - markOnboarded, - markScreeningPending, getServerBirthdays, incrementActivityScore, setBirthday, @@ -139,35 +135,6 @@ test('setBirthday upserts without losing the activity score; clearBirthday repor assert.equal((await getServerBirthdays(db, 's1')).length, 0); }); -test('screening pending state is recorded, independent of onboardedAt, and cleared', async () => { - const pendingAt = new Date('2027-01-10T00:00:00Z'); - const onboardedAt = new Date('2027-01-11T00:00:00Z'); - assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { - screeningPendingAt: null, - onboardedAt: null, - }); - - await markScreeningPending(db, 'g1', 'u1', pendingAt); - assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { - screeningPendingAt: pendingAt, - onboardedAt: null, - }); - - await markOnboarded(db, 'g1', 'u1', onboardedAt); - await markScreeningPending(db, 'g1', 'u1', pendingAt); - assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { - screeningPendingAt: pendingAt, - onboardedAt, - }); - - await clearScreeningPending(db, 'g1', 'u1'); - await clearScreeningPending(db, 'g1', 'missing'); - assert.deepEqual(await getOnboardingState(db, 'g1', 'u1'), { - screeningPendingAt: null, - onboardedAt, - }); -}); - test('updateExistingConfig patches an existing row but never creates one', async () => { await updateConfig(app, 's-existing', { debug: false }); const patched = await updateExistingConfig(app, 's-existing', { debug: true }); diff --git a/tests/onboarding/events.test.ts b/tests/onboarding/events.test.ts deleted file mode 100644 index dc382f5f..00000000 --- a/tests/onboarding/events.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, beforeEach, test } from 'node:test'; -import { Collection, type Client, type Guild, type GuildMember } from 'discord.js'; -import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; -import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; -import type { DingirClient } from '../../src/client/DingirClient.ts'; -import { stub } from '../fakes/discord.ts'; -import { fakeOnboarding, role } from '../fakes/onboarding.ts'; -import { createTestApp } from '../helpers/app.ts'; -import { dbFixtures } from '../helpers/db.ts'; -import { complete } from '../../src/services/OnboardingService.ts'; -import { recordPendingMembers } from '../../src/services/ScreeningService.ts'; -import { - getOnboardedAt, - getOnboardingState, - markOnboarded, - markScreeningPending, -} from '../../src/services/UserProfileService.ts'; -import { nth } from '../helpers/assertions.ts'; - -const app = createTestApp(); -const { clearConfigs, createConfig } = dbFixtures(app); - -after(() => { - app.close(); -}); - -beforeEach(async () => { - clearConfigs(); - app.db.$client.exec('DELETE FROM `UserProfiles`'); - createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); -}); - -const update = async (oldState: Record, newPending: boolean | null) => { - const env = fakeOnboarding({ roles: [role('r1')] }); - const newMember = Object.assign(env.member, { pending: newPending }); - const oldMember = { guild: env.guild, id: 'member-1', ...oldState } as unknown as GuildMember; - await guildMemberUpdate.run(app, stub(env.client), oldMember, newMember); - return env.roleAdds.length; -}; - -test('guildMemberUpdate onboards only on pending true to false', async () => { - assert.equal(await update({ pending: true }, false), 1); - assert.equal(await update({ pending: false }, false), 0); - assert.equal(await update({ pending: true }, true), 0); - assert.equal(await update({ pending: false }, true), 0); -}); - -const partialUpdate = async (opts: { - bot?: boolean; - pending?: boolean; - recordedPending?: boolean; - recorded?: boolean; -}) => { - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { - pending: opts.pending ?? false, - joinedTimestamp: app.clock().getTime() - 24 * 60 * 60 * 1000, - ...(opts.bot ? { user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }) } : {}), - }); - if (opts.recordedPending) { - await markScreeningPending(app.db, 'guild-1', 'member-1', new Date('2027-01-13T00:00:00Z')); - } - if (opts.recorded) { - await markOnboarded(app.db, 'guild-1', 'member-1', new Date('2027-01-14T00:00:00Z')); - } - const oldMember = { guild: env.guild, id: 'member-1', partial: true } as unknown as GuildMember; - await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); - return env.roleAdds.length; -}; - -test('partial old member with recorded pending and no onboarding is onboarded and cleared', async () => { - assert.equal(await partialUpdate({ recordedPending: true }), 1); - assert.deepEqual(await getOnboardingState(app.db, 'guild-1', 'member-1'), { - screeningPendingAt: null, - onboardedAt: app.clock(), - }); -}); - -test('partial old member with no recorded pending is skipped even if joined recently', async () => { - app.logs.length = 0; - assert.equal(await partialUpdate({}), 0); - assert.equal((await getOnboardingState(app.db, 'guild-1', 'member-1')).onboardedAt, null); - assert.ok(app.logsAt('info').some((e) => /no pending screening recorded/.test(e.message))); -}); - -test('partial old member already onboarded is skipped', async () => { - assert.equal(await partialUpdate({ recorded: true, recordedPending: true }), 0); -}); - -test('partial old member still pending is recorded but not onboarded', async () => { - assert.equal(await partialUpdate({ pending: true }), 0); - const state = await getOnboardingState(app.db, 'guild-1', 'member-1'); - assert.deepEqual(state.screeningPendingAt, app.clock()); - assert.equal(state.onboardedAt, null); -}); - -test('known pending true to false clears the recorded pending state', async () => { - await markScreeningPending(app.db, 'guild-1', 'member-1', new Date('2027-01-13T00:00:00Z')); - assert.equal(await update({ pending: true }, false), 1); - assert.equal((await getOnboardingState(app.db, 'guild-1', 'member-1')).screeningPendingAt, null); -}); - -test('known old state records pending when the member is still pending', async () => { - assert.equal(await update({ pending: false }, true), 0); - assert.deepEqual( - (await getOnboardingState(app.db, 'guild-1', 'member-1')).screeningPendingAt, - app.clock(), - ); -}); - -test('guildMemberAdd records pending screening without onboarding', async () => { - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { pending: true }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.deepEqual(await getOnboardingState(app.db, 'guild-1', 'member-1'), { - screeningPendingAt: app.clock(), - onboardedAt: null, - }); -}); - -test('recordPendingMembers records only pending non-bot members and isolates failing guilds', async () => { - const member = (id: string, extra: Record) => - stub({ id, user: stub({ id, bot: false }), pending: false, ...extra }); - const guild = (id: string, fetch: () => Promise) => - stub({ id, members: { fetch } }); - const good = guild('guild-1', async () => { - const m = [ - member('p1', { pending: true }), - member('n1', {}), - member('b1', { pending: true, user: stub({ id: 'b1', bot: true }) }), - member('p2', { pending: true }), - ]; - return new Collection(m.map((x) => [x.id, x])); - }); - const bad = guild('guild-2', async () => { - throw new Error('boom'); - }); - await markScreeningPending(app.db, 'guild-1', 'p2', new Date('2027-01-01T00:00:00Z')); - const client = stub({ - guilds: { - cache: new Collection([ - ['guild-2', bad], - ['guild-1', good], - ]), - }, - }); - app.logs.length = 0; - - assert.equal(await recordPendingMembers(app, client), 1); - assert.deepEqual( - (await getOnboardingState(app.db, 'guild-1', 'p1')).screeningPendingAt, - app.clock(), - ); - assert.deepEqual( - (await getOnboardingState(app.db, 'guild-1', 'p2')).screeningPendingAt, - new Date('2027-01-01T00:00:00Z'), - ); - for (const id of ['n1', 'b1']) { - assert.equal((await getOnboardingState(app.db, 'guild-1', id)).screeningPendingAt, null); - } - assert.equal(app.logsAt('error').length, 1); -}); - -test('guildMemberUpdate skips a bot with a partial old member', async () => { - assert.equal(await partialUpdate({ bot: true }), 0); -}); - -test('guildMemberUpdate skips pending true to false when already recorded', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', new Date('2027-01-14T00:00:00Z')); - assert.equal(await update({ pending: true }, false), 0); -}); - -test('guildMemberUpdate records onboarding on pending true to false', async () => { - assert.equal(await update({ pending: true }, false), 1); - assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); -}); - -test('a dry run does not record onboarding', async () => { - const env = fakeOnboarding({ roles: [role('r1')] }); - await complete(app, stub(env.client), env.member, env.config, { dryRun: true }); - assert.equal(await getOnboardedAt(app.db, 'guild-1', 'member-1'), null); -}); - -test('guildMemberAdd records onboarding for an immediate join', async () => { - const env = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); - Object.assign(env.member, { pending: false }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); -}); - -test('guildMemberAdd audits the join and onboards immediately when not pending', async () => { - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { pending: false }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.equal(env.roleAdds.length, 1); - assert.equal(env.auditSends.length, 1); - assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /New member joined/); -}); - -test('guildMemberAdd waits for screening when the member is pending', async () => { - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { pending: true }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.equal(env.roleAdds.length, 0); - assert.equal(env.auditSends.length, 1); -}); - -test('guildMemberAdd audits a bot join but skips onboarding', async () => { - const env = fakeOnboarding({ - roles: [role('r1')], - config: { welcomeMessage: 'Hi {member}' }, - }); - Object.assign(env.member, { - pending: false, - user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }), - }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.equal(env.auditSends.length, 1); - assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /New member joined/); - assert.equal(env.roleAdds.length, 0); - assert.equal(env.systemSends.length, 0); -}); - -test('guildMemberUpdate ignores bots completing screening', async () => { - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { - pending: false, - user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }), - }); - const oldMember = { guild: env.guild, id: 'member-1', pending: true } as unknown as GuildMember; - await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); - assert.equal(env.roleAdds.length, 0); - assert.equal(env.auditSends.length, 0); -}); - -test('guildMemberUpdate sends the completion audit when screening finishes', async () => { - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { pending: false }); - const oldMember = { guild: env.guild, id: 'member-1', pending: true } as unknown as GuildMember; - await guildMemberUpdate.run(app, stub(env.client), oldMember, env.member); - assert.equal(env.auditSends.length, 1); - assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /Member completed onboarding/); -}); - -const STALE = new Date('2026-12-01T00:00:00Z'); - -test('a rejoin while pending starts a new cycle and clears the stale onboarding', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', STALE); - const env = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(env.member, { pending: true }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.deepEqual(await getOnboardingState(app.db, 'guild-1', 'member-1'), { - screeningPendingAt: app.clock(), - onboardedAt: null, - }); - assert.equal(env.roleAdds.length, 0); -}); - -test('completing screening after a pending rejoin onboards via the known path', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', STALE); - const joining = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(joining.member, { pending: true }); - await guildMemberAdd.run(app, stub(joining.client), joining.member); - assert.equal(await update({ pending: true }, false), 1); - assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); -}); - -test('completing screening after a pending rejoin onboards via the partial path', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', STALE); - const joining = fakeOnboarding({ roles: [role('r1')] }); - Object.assign(joining.member, { pending: true }); - await guildMemberAdd.run(app, stub(joining.client), joining.member); - assert.equal(await partialUpdate({}), 1); -}); - -test('a rejoin that is not pending onboards despite a stale onboarding', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', STALE); - const env = fakeOnboarding({ roles: [role('r1')], config: { guestRoleIds: 'r1' } }); - Object.assign(env.member, { pending: false }); - await guildMemberAdd.run(app, stub(env.client), env.member); - assert.equal(env.roleAdds.length, 1); - assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), app.clock()); -}); - -test('a plain update with a stale onboarding and no join is not re-onboarded', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', STALE); - assert.equal(await update({ pending: true }, true), 0); - assert.equal(await update({ pending: true }, false), 0); - assert.deepEqual(await getOnboardedAt(app.db, 'guild-1', 'member-1'), STALE); -}); - -test('an update-recorded pending newer than onboarding is treated as a new cycle', async () => { - await markOnboarded(app.db, 'guild-1', 'member-1', STALE); - await markScreeningPending(app.db, 'guild-1', 'member-1', new Date('2027-01-13T00:00:00Z')); - assert.equal(await update({ pending: true }, false), 1); -}); diff --git a/tests/onboarding/stateMachine.test.ts b/tests/onboarding/stateMachine.test.ts new file mode 100644 index 00000000..9c24f5c6 --- /dev/null +++ b/tests/onboarding/stateMachine.test.ts @@ -0,0 +1,500 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { Collection, type Client, type Guild, type GuildMember } from 'discord.js'; +import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; +import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; +import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import { complete } from '../../src/services/OnboardingService.ts'; +import { syncScreeningState } from '../../src/services/ScreeningService.ts'; +import { claimOnboarding } from '../../src/services/UserProfileService.ts'; +import { stub } from '../fakes/discord.ts'; +import { fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { createTestApp, FIXED_NOW } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { clearConfigs, clearProfiles, createConfig, createProfiles, findProfile } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + clearConfigs(); + clearProfiles(); + createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); +}); + +const NOW = FIXED_NOW; +const JOINED = new Date(NOW.getTime() - 24 * 60 * 60 * 1000); +/** Recorded during the current membership. */ +const RECENT = new Date(NOW.getTime() - 12 * 60 * 60 * 1000); +/** Recorded before the member's current join: belongs to a previous membership. */ +const STALE = new Date('2026-12-01T00:00:00Z'); + +type Stamp = Date | null; +interface State { + pending: Stamp; + onboarded: Stamp; +} + +type OldMember = boolean | 'partial'; +type Event = + | { kind: 'join'; pending: boolean; bot?: boolean } + | { kind: 'update'; old: OldMember; new: boolean | null; bot?: boolean } + | { kind: 'sweep'; pending: boolean; bot?: boolean } + | { kind: 'leave' }; + +interface Row { + name: string; + /** Undefined: the member has no profile row. */ + initial?: Partial; + event: Event; + failRolesAdd?: boolean; + /** Undefined: no profile row afterwards. */ + state?: State; + roles: number; +} + +const none = null; +const onboardedNow: State = { pending: none, onboarded: NOW }; +const pendingNow: State = { pending: NOW, onboarded: none }; + +const rows: Row[] = [ + // join + { + name: 'join pending, no row: records pending', + event: { kind: 'join', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'join pending, stale onboarding (rejoin): resets to a new cycle', + initial: { onboarded: STALE }, + event: { kind: 'join', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'join pending, stale pending: restarts the cycle', + initial: { pending: STALE }, + event: { kind: 'join', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'join not pending: onboards immediately', + event: { kind: 'join', pending: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'join not pending, stale onboarding (rejoin): onboards again', + initial: { onboarded: STALE }, + event: { kind: 'join', pending: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'join not pending, roles fail: claim stands, not retried', + event: { kind: 'join', pending: false }, + failRolesAdd: true, + state: onboardedNow, + roles: 0, + }, + { + name: 'join of a bot: no state, no onboarding', + event: { kind: 'join', pending: false, bot: true }, + roles: 0, + }, + // update, previous state known + { + name: 'update true->true, no row: records pending', + event: { kind: 'update', old: true, new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'update false->true, no row: records pending', + event: { kind: 'update', old: false, new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'update still pending: keeps the recorded time', + initial: { pending: RECENT }, + event: { kind: 'update', old: true, new: true }, + state: { pending: RECENT, onboarded: none }, + roles: 0, + }, + { + name: 'update pending, stale onboarding (missed rejoin): new cycle', + initial: { onboarded: STALE }, + event: { kind: 'update', old: true, new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'update true->false, no row: onboards', + event: { kind: 'update', old: true, new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'update true->false, pending recorded: onboards and clears pending', + initial: { pending: RECENT }, + event: { kind: 'update', old: true, new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'update true->false, already onboarded: replay is ignored', + initial: { onboarded: RECENT }, + event: { kind: 'update', old: true, new: false }, + state: { pending: none, onboarded: RECENT }, + roles: 0, + }, + { + name: 'update true->false, stale onboarding (missed rejoin): onboards', + initial: { onboarded: STALE }, + event: { kind: 'update', old: true, new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'update true->false, roles fail: claim stands', + event: { kind: 'update', old: true, new: false }, + failRolesAdd: true, + state: onboardedNow, + roles: 0, + }, + { + name: 'update false->false: ignored', + event: { kind: 'update', old: false, new: false }, + roles: 0, + }, + { + name: 'update of a bot: ignored', + event: { kind: 'update', old: true, new: false, bot: true }, + roles: 0, + }, + // update, previous state unknown (partial old member) + { + name: 'partial old, pending recorded: onboards', + initial: { pending: RECENT }, + event: { kind: 'update', old: 'partial', new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'partial old, nothing recorded: cannot prove screening, ignored', + event: { kind: 'update', old: 'partial', new: false }, + roles: 0, + }, + { + name: 'partial old, already onboarded: ignored', + initial: { pending: RECENT, onboarded: RECENT }, + event: { kind: 'update', old: 'partial', new: false }, + state: { pending: RECENT, onboarded: RECENT }, + roles: 0, + }, + { + name: 'partial old, stale pending (rejoin while offline): ignored and cleaned', + initial: { pending: STALE }, + event: { kind: 'update', old: 'partial', new: false }, + state: { pending: none, onboarded: none }, + roles: 0, + }, + { + name: 'partial old, stale onboarding (rejoin while offline): not re-onboarded, cleaned', + initial: { onboarded: STALE }, + event: { kind: 'update', old: 'partial', new: false }, + state: { pending: none, onboarded: none }, + roles: 0, + }, + { + name: 'partial old, still pending: records pending', + event: { kind: 'update', old: 'partial', new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'partial old, pending with stale onboarding (rejoin while offline): new cycle', + initial: { onboarded: STALE }, + event: { kind: 'update', old: 'partial', new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'partial old, unknown new pending: ignored', + event: { kind: 'update', old: 'partial', new: null }, + roles: 0, + }, + // startup sweep + { + name: 'sweep pending member, no row: records pending', + event: { kind: 'sweep', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'sweep pending member already recorded: unchanged', + initial: { pending: RECENT }, + event: { kind: 'sweep', pending: true }, + state: { pending: RECENT, onboarded: none }, + roles: 0, + }, + { + name: 'sweep pending member with stale onboarding (rejoined offline): new cycle', + initial: { onboarded: STALE }, + event: { kind: 'sweep', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'sweep pending member with stale pending: re-recorded', + initial: { pending: STALE }, + event: { kind: 'sweep', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'sweep settled member, no row: nothing recorded', + event: { kind: 'sweep', pending: false }, + roles: 0, + }, + { + name: 'sweep settled member, pending recorded (finished offline): onboarded late', + initial: { pending: RECENT }, + event: { kind: 'sweep', pending: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'sweep settled member, stale pending (rejoined offline): not onboarded', + initial: { pending: STALE }, + event: { kind: 'sweep', pending: false }, + state: { pending: none, onboarded: none }, + roles: 0, + }, + { + name: 'sweep settled member already onboarded: unchanged', + initial: { onboarded: RECENT }, + event: { kind: 'sweep', pending: false }, + state: { pending: none, onboarded: RECENT }, + roles: 0, + }, + { name: 'sweep ignores bots', event: { kind: 'sweep', pending: true, bot: true }, roles: 0 }, + // leave + { + name: 'leave deletes the row', + initial: { onboarded: RECENT }, + event: { kind: 'leave' }, + roles: 0, + }, + { name: 'leave with no row is harmless', event: { kind: 'leave' }, roles: 0 }, +]; + +function scenario(row: Row) { + const env = fakeOnboarding({ roles: [role('r1')], failRolesAdd: row.failRolesAdd }); + const pending = + row.event.kind === 'update' + ? row.event.new + : 'pending' in row.event + ? row.event.pending + : false; + Object.assign(env.member, { + pending, + joinedTimestamp: JOINED.getTime(), + ...('bot' in row.event && row.event.bot + ? { user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }) } + : {}), + }); + const guilds = new Collection([['guild-1', env.guild]]); + Object.assign(env.guild.members, { + fetch: async () => new Collection([['member-1', env.member]]), + }); + const client = stub( + Object.assign(env.client, { guilds: { cache: guilds } }), + ); + return { env, client }; +} + +async function play(row: Row): Promise { + const { env, client } = scenario(row); + const { event } = row; + switch (event.kind) { + case 'join': + await guildMemberAdd.run(app, client, env.member); + break; + case 'leave': + await guildMemberRemove.run(app, client, env.member); + break; + case 'sweep': + await syncScreeningState(app, client); + break; + case 'update': { + const old = + event.old === 'partial' + ? { guild: env.guild, id: 'member-1', partial: true } + : { guild: env.guild, id: 'member-1', partial: false, pending: event.old }; + await guildMemberUpdate.run(app, client, stub(old), env.member); + break; + } + } + return env.roleAdds.length; +} + +describe('onboarding state machine', () => { + for (const row of rows) { + test(row.name, async () => { + if (row.initial) { + createProfiles([ + { + serverId: 'guild-1', + userId: 'member-1', + screeningPendingAt: row.initial.pending ?? null, + onboardedAt: row.initial.onboarded ?? null, + }, + ]); + } + const roles = await play(row); + assert.equal(roles, row.roles, 'guest role assignments'); + const profile = findProfile('member-1', 'guild-1'); + if (row.state === undefined) { + assert.equal(profile, null, 'no profile row'); + } else { + assert.ok(profile, 'profile row exists'); + assert.deepEqual( + { pending: profile.screeningPendingAt, onboarded: profile.onboardedAt }, + row.state, + ); + } + }); + } +}); + +describe('onboarding invariants under failure and concurrency', () => { + test('concurrent identical updates onboard exactly once', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'update', old: true, new: false }, + roles: 1, + }); + const old = stub({ + guild: env.guild, + id: 'member-1', + partial: false, + pending: true, + }); + await Promise.all([ + guildMemberUpdate.run(app, client, old, env.member), + guildMemberUpdate.run(app, client, old, env.member), + ]); + assert.equal(env.roleAdds.length, 1); + }); + + test('an update arriving while the join audit is in flight does not onboard twice', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'join', pending: false }, + roles: 1, + }); + const old = stub({ + guild: env.guild, + id: 'member-1', + partial: false, + pending: true, + }); + await Promise.all([ + guildMemberAdd.run(app, client, env.member), + guildMemberUpdate.run(app, client, old, env.member), + ]); + assert.equal(env.roleAdds.length, 1); + }); + + test('a leave during onboarding leaves no profile behind', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'join', pending: false }, + roles: 1, + }); + const joining = guildMemberAdd.run(app, client, env.member); + await new Promise((resolve) => setImmediate(resolve)); // gateway events are separate tasks + await guildMemberRemove.run(app, client, env.member); + await joining; + assert.equal(findProfile('member-1', 'guild-1'), null); + }); + + test('onboarding is claimed once per membership', async () => { + assert.equal(await claimOnboarding(app.db, 'guild-1', 'member-1', NOW), true); + assert.equal(await claimOnboarding(app.db, 'guild-1', 'member-1', RECENT), false); + assert.deepEqual(findProfile('member-1')?.onboardedAt, NOW); + }); + + test('a dry run never touches persisted state', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + await complete(app, stub(env.client), env.member, env.config, { dryRun: true }); + assert.equal(findProfile('member-1'), null); + assert.equal(env.roleAdds.length, 0); + }); + + test('a join audits "New member joined"; a bot join is audited but not onboarded', async () => { + const human = scenario({ name: '', event: { kind: 'join', pending: false }, roles: 1 }); + await guildMemberAdd.run(app, human.client, human.env.member); + assert.equal(human.env.auditSends.length, 1); + assert.match(JSON.stringify(nth(nth(human.env.auditSends).embeds)), /New member joined/); + + const bot = scenario({ + name: '', + event: { kind: 'join', pending: false, bot: true }, + roles: 0, + }); + await guildMemberAdd.run(app, bot.client, bot.env.member); + assert.equal(bot.env.auditSends.length, 1); + assert.equal(bot.env.roleAdds.length, 0); + }); + + test('screening completion sends the completion audit', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'update', old: true, new: false }, + roles: 1, + }); + const old = stub({ + guild: env.guild, + id: 'member-1', + partial: false, + pending: true, + }); + await guildMemberUpdate.run(app, client, old, env.member); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /Member completed onboarding/); + }); + + test('the sweep isolates a failing guild and stops once its guild is removed', async () => { + const good = scenario({ name: '', event: { kind: 'sweep', pending: true }, roles: 0 }); + const bad = stub({ + id: 'guild-2', + members: { + fetch: async () => { + throw new Error('boom'); + }, + }, + }); + const cache = new Collection([ + ['guild-2', bad], + ['guild-1', good.env.guild], + ]); + const client = stub(Object.assign(good.env.client, { guilds: { cache } })); + app.logs.length = 0; + assert.deepEqual(await syncScreeningState(app, client), { recorded: 1, caughtUp: 0 }); + assert.equal(app.logsAt('error').length, 1); + + clearProfiles(); + cache.delete('guild-1'); // guildDelete purged it mid-sweep + assert.deepEqual(await syncScreeningState(app, client), { recorded: 0, caughtUp: 0 }); + assert.equal(findProfile('member-1'), null); + }); +}); From 20e3bc91f55dce9fe10d5864fbfa998b2a1669c9 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:21:46 +0100 Subject: [PATCH 68/77] Specify birthday calendar lifecycle and harden its failure handling Classify refresh failures (missing, no access, transient) instead of reporting every error as missing, serialise refreshes per guild so the last edit has the latest profiles, replace the calendar a create actually overwrote (not the one it first saw), never clobber a concurrent create on rollback, skip purged guilds in the scheduled refresh, and refresh the calendar when a member with a birthday leaves. Consolidate the calendar tests into a table-driven state machine test. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/commands/config/groups/birthdays.ts | 24 +- src/events/guildMemberRemove.ts | 11 +- src/services/BirthdayService.ts | 154 +++++-- src/services/ConfigService.ts | 37 +- src/services/UserProfileService.ts | 10 +- tests/birthdays/BirthdayService.test.ts | 24 - tests/birthdays/configCreate.test.ts | 234 ---------- tests/birthdays/stateMachine.test.ts | 585 ++++++++++++++++++++++++ 9 files changed, 764 insertions(+), 317 deletions(-) delete mode 100644 tests/birthdays/configCreate.test.ts create mode 100644 tests/birthdays/stateMachine.test.ts diff --git a/README.md b/README.md index df5e51f9..8177e1ad 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Members can set their birthday using `/mybirthday set `. Birthdays **Birthday Announcements**: On the configured schedule (default 9:00 AM daily), the bot checks all servers and posts birthday announcements in the announcements channel for members with birthdays today. If a member's birthday is Feb 29 and it's not a leap year, they are celebrated on Feb 28 instead. -**Birthday Calendar**: If configured, a calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job by editing the message. If the message or channel is missing, the refresh logs a warning and an admin must run `/config birthdays create` again to create a new calendar. +**Birthday Calendar**: If configured, a calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job by editing the message. It is also refreshed when a member with a birthday leaves. If the message or channel is missing, or the bot lacks access, the refresh logs a warning and an admin must fix permissions or run `/config birthdays create` again; a temporary Discord error never clears the stored calendar. **Timezones**: All birthday calculations use the `BOT_TIMEZONE` setting. Members' birthdays are stored as month/day only (no year), and the next occurrence is calculated relative to the bot's configured time zone. diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts index b366cb08..4938eeca 100644 --- a/src/commands/config/groups/birthdays.ts +++ b/src/commands/config/groups/birthdays.ts @@ -6,7 +6,7 @@ import { deleteCalendarMessage, refreshCalendar, } from '../../../services/BirthdayService.ts'; -import { updateConfig } from '../../../services/ConfigService.ts'; +import { findConfig, swapConfig, updateConfig } from '../../../services/ConfigService.ts'; import { resolveTextChannel } from '../../../services/MemberResolver.ts'; export const BirthdaysGroup = defineSubcommandGroup({ @@ -44,7 +44,6 @@ export const BirthdaysGroup = defineSubcommandGroup({ } const client = ctx.interaction.client; - const previousPath = ctx.config.birthdayCalendarMessagePath; // Send first and persist before touching the old calendar, so a failure at either step // leaves the existing calendar and its stored path intact. @@ -53,10 +52,15 @@ export const BirthdaysGroup = defineSubcommandGroup({ allowedMentions: { parse: [] }, }); const path = `${message.channelId}/${message.id}`; + // The calendar this create replaces is whatever is stored at the moment of saving: a + // concurrent create may have replaced the one ctx.config saw, and must not be orphaned. + let previousPath: string | null | undefined; try { - ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + const swapped = await swapConfig(ctx.app, ctx.config.serverId, { birthdayCalendarMessagePath: path, }); + previousPath = swapped.previous.birthdayCalendarMessagePath; + ctx.config = swapped.updated; } catch (error) { await message.delete().catch((deleteError: unknown) => { ctx.app.logger.warn( @@ -71,11 +75,15 @@ export const BirthdaysGroup = defineSubcommandGroup({ const status = await refreshCalendar(ctx.app, client, ctx.config); const link = calendarMessageUrl(ctx.guild.id, path); if (status !== 'updated') { - // Keep whatever calendar existed before: restore its path and drop the new placeholder. + // Keep whatever calendar existed before: restore its path (unless a concurrent create + // has since replaced ours) and drop the new placeholder. try { - ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { - birthdayCalendarMessagePath: previousPath ?? null, - }); + const stored = await findConfig(ctx.app, ctx.guild.id); + if (stored?.birthdayCalendarMessagePath === path) { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + birthdayCalendarMessagePath: previousPath ?? null, + }); + } } catch (error) { ctx.app.logger.error( 'Could not restore the previous birthday calendar path', @@ -122,6 +130,8 @@ export const BirthdaysGroup = defineSubcommandGroup({ 'The calendar channel is missing or inaccessible. Recreate it with `/config birthdays create`.', 'message-missing': 'The calendar message is missing. Recreate it with `/config birthdays create`.', + 'no-access': + 'I do not have permission to view or edit the calendar message. Fix my permissions in that channel, or recreate it with `/config birthdays create`.', failed: 'An error occurred syncing the calendar. Try again later.', } as const; await ctx.reply(replies[status]); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index ee355e83..d33031bd 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -2,6 +2,7 @@ import { defineEvent } from '../framework/event.ts'; import { EmbedColours } from '../resources/EmbedColours.ts'; import { memberAuditEmbed } from '../services/AuditEmbed.ts'; import { sendAudit } from '../services/AuditService.ts'; +import { refreshCalendar } from '../services/BirthdayService.ts'; import { getConfig } from '../services/ConfigService.ts'; import { deleteUser } from '../services/UserProfileService.ts'; @@ -13,7 +14,12 @@ export default defineEvent({ return; } - const dataDeleted = await deleteUser(app.db, member.guild.id, user.id); + const removed = await deleteUser(app.db, member.guild.id, user.id); + const config = await getConfig(app, member.guild.id); + if (removed?.birthdayMonth != null) { + // The calendar must not keep listing a member who is no longer here. + await refreshCalendar(app, client, config); + } if (app.honeypot.isActive(member.guild.id, user.id)) { return; } @@ -24,9 +30,8 @@ export default defineEvent({ 'Member left', ) .addField('ID', user.id) - .addField('Member data cleanup', dataDeleted ? 'Deleted' : 'No stored member data'); + .addField('Member data cleanup', removed ? 'Deleted' : 'No stored member data'); - const config = await getConfig(app, member.guild.id); await sendAudit(app, client, config, audit); }, }); diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index 88dce13d..d1b64f45 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -1,9 +1,47 @@ +/* + * BIRTHDAY CALENDAR SPEC + * + * Persisted state: + * ServerConfigs.birthdayCalendarMessagePath "channelId/messageId" of the calendar message, or null. + * UserProfiles.birthdayMonth/Day a member's birthday (the calendar's only data). + * The path is written only by `/config birthdays create`, and only for a message the bot just sent + * and edited successfully. It is cleared only by `remove` (after the message is deleted or + * confirmed gone) or by a create that fails before replacing a previous calendar. + * + * Transitions (path state x event): + * none create ok send, save path, populate, -> new path + * none create fails delete the new message, -> none + * old create ok send, save, populate, delete old message, -> new path + * old create fails (populate) restore old path (if still ours), delete new msg, -> old + * old create fails (send/save) nothing saved, old calendar untouched, -> old + * old remove, deleted/gone -> none + * old remove, delete fails error with reason, -> old (retry, or create to replace it) + * any refresh (sync, mybirthday, scheduled job, member leaves with a birthday) + * edit message in place; path never changes + * + * Invariants: + * 1. A stored path always points to a message the bot created. A refresh that cannot reach it + * reports why (not-configured, channel-missing, message-missing, no-access, failed) and never + * clears or rewrites the path: only the admin commands do, so a transient error cannot lose it. + * 2. Only definite "unknown channel/message" answers count as missing; permission errors are + * "no-access"; anything else (network, 5xx, rate limit) is "failed" and expected to recover. + * 3. Refreshes of one guild run one at a time and read the profiles immediately before editing, + * so the last edit always reflects the latest data. + * 4. A create never leaves two live calendars: the replaced message is deleted once the new one + * is populated, and a concurrent create's message is cleaned up by whichever finishes last. + * 5. Background jobs only act on configs that still exist (a purged guild is skipped). + * + * Failure handling: deleting an old calendar that fails is logged and left in place (never blocks + * the new calendar); a bot restart mid-create can leave an orphan placeholder message, which is + * harmless; announcements missed while the bot is offline at the scheduled time are not replayed. + */ + import type { Client } from 'discord.js'; import { type Snowflake } from 'discord.js'; import { DateTime } from 'luxon'; import type { ServerConfig } from '../db/schema.ts'; import type { App } from '../app.ts'; -import { getConfigs } from './ConfigService.ts'; +import { findConfig, getConfigs } from './ConfigService.ts'; import { getServerBirthdays } from './UserProfileService.ts'; import { type BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates.ts'; import { resolveMember, resolveTextChannel } from './MemberResolver.ts'; @@ -13,6 +51,7 @@ export type CalendarStatus = | 'not-configured' | 'channel-missing' | 'message-missing' + | 'no-access' | 'failed'; const MESSAGE_LIMIT = 2000; @@ -55,50 +94,81 @@ export const buildCalendarContent = ( const nowOf = (app: App): DateTime => DateTime.fromJSDate(app.clock()); -export const refreshCalendar = async ( +const MISSING_CODES = new Set([10003, 10008]); // Unknown Channel, Unknown Message +const NO_ACCESS_CODES = new Set([50001, 50005, 50013]); // Missing Access, Cannot edit, Missing Permissions + +const codeOf = (error: unknown): number | undefined => { + const code = + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + return typeof code === 'number' ? code : undefined; +}; + +/** Only a definite Discord answer is "missing" or "no access"; anything else may recover. */ +const statusOfError = (error: unknown, missing: CalendarStatus): CalendarStatus => { + const code = codeOf(error); + if (code !== undefined && MISSING_CODES.has(code)) { + return missing; + } + return code !== undefined && NO_ACCESS_CODES.has(code) ? 'no-access' : 'failed'; +}; + +const queues = new Map>(); + +/** Runs tasks for one guild strictly one after another. */ +const serialised = (guild: Snowflake, task: () => Promise): Promise => { + const run = (queues.get(guild) ?? Promise.resolve()).then(task, task); + const tail = run.catch(() => undefined); + queues.set(guild, tail); + void tail.then(() => { + if (queues.get(guild) === tail) { + queues.delete(guild); + } + }); + return run; +}; + +export const refreshCalendar = ( app: App, client: Client, config: ServerConfig, ): Promise => { - const { logger } = app; - const guild = config.serverId; const target = parseCalendarPath(config.birthdayCalendarMessagePath); if (!target) { - return 'not-configured'; + return Promise.resolve('not-configured'); } - try { - const channel = await client.channels.fetch(target.channelId).catch((error: unknown) => { - logger.warn('Birthday calendar channel fetch failed', { guild }, error); - return null; - }); - if (!channel || !channel.isTextBased()) { - return 'channel-missing'; - } - const message = await channel.messages.fetch(target.messageId).catch((error: unknown) => { - logger.warn('Birthday calendar message fetch failed', { guild }, error); - return null; - }); - if (!message) { - return 'message-missing'; + return serialised(config.serverId, async () => { + const guild = config.serverId; + let stage: 'channel' | 'message' | 'edit' = 'channel'; + try { + const channel = await client.channels.fetch(target.channelId); + if (!channel?.isTextBased()) { + return 'channel-missing'; + } + stage = 'message'; + const message = await channel.messages.fetch(target.messageId); + stage = 'edit'; + // Read the profiles last so this edit reflects everything saved before it. + const profiles = (await getServerBirthdays(app.db, guild)).map((p) => ({ + userId: p.userId, + month: p.birthdayMonth!, + day: p.birthdayDay!, + })); + const content = buildCalendarContent(profiles, nowOf(app), app.env.timezone); + await message.edit({ content, allowedMentions: { parse: [] } }); + return 'updated'; + } catch (error) { + const status = statusOfError( + error, + stage === 'channel' ? 'channel-missing' : 'message-missing', + ); + app.logger.warn(`Birthday calendar ${stage} step failed`, { guild, status }, error); + return status; } - const profiles = (await getServerBirthdays(app.db, guild)).map((p) => ({ - userId: p.userId, - month: p.birthdayMonth!, - day: p.birthdayDay!, - })); - const content = buildCalendarContent(profiles, nowOf(app), app.env.timezone); - await message.edit({ content, allowedMentions: { parse: [] } }); - return 'updated'; - } catch (error) { - logger.error('Birthday calendar refresh failed', { guild }, error); - return 'failed'; - } + }); }; export type DeleteCalendarStatus = 'deleted' | 'already-missing' | 'failed'; -const MISSING_CODES = new Set([10003, 10008]); // Unknown Channel, Unknown Message - export const deleteCalendarMessage = async ( app: App, client: Client, @@ -120,9 +190,8 @@ export const deleteCalendarMessage = async ( await message.delete(); return 'deleted'; } catch (error) { - const code = - typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; - if (typeof code === 'number' && MISSING_CODES.has(code)) { + const code = codeOf(error); + if (code !== undefined && MISSING_CODES.has(code)) { return 'already-missing'; } app.logger.warn('Could not delete the birthday calendar message', { path }, error); @@ -134,17 +203,22 @@ export const refreshAllCalendars = async (app: App, client: Client): Promise { + return ( + configCache.get(serverId) ?? + db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? + null + ); +} + export async function getConfigs(db: Db): Promise { return db.select().from(serverConfigs).all(); } export async function updateConfig( - { db, configCache }: ConfigDeps, + deps: ConfigDeps, serverId: Snowflake, patch: ServerConfigPatch, ): Promise { + return (await swapConfig(deps, serverId, patch)).updated; +} + +/** Like updateConfig, but also returns the config as it was in the same atomic step. */ +export async function swapConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, + patch: ServerConfigPatch, +): Promise<{ previous: ServerConfig; updated: ServerConfig }> { configCache.delete(serverId); - const updated = db.transaction((tx) => { + const result = db.transaction((tx) => { tx.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); - return tx + const previous = tx + .select() + .from(serverConfigs) + .where(eq(serverConfigs.serverId, serverId)) + .get()!; + const updated = tx .update(serverConfigs) .set({ ...patch, updatedAt: new Date() }) .where(eq(serverConfigs.serverId, serverId)) .returning() .get(); + return { previous, updated }; }); - configCache.set(serverId, updated); - return updated; + configCache.set(serverId, result.updated); + return result; } /** diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index ad818d39..357dc53b 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -116,9 +116,13 @@ export async function incrementActivityScore( .run(); } -export async function deleteUser(db: Db, serverId: Snowflake, userId: Snowflake): Promise { - const result = db.delete(userProfiles).where(forUser(serverId, userId)).run(); - return result.changes > 0; +/** Deletes a member's profile and returns what was stored, or null when there was none. */ +export async function deleteUser( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + return db.delete(userProfiles).where(forUser(serverId, userId)).returning().get() ?? null; } export async function deleteUsersByServer(db: Db, serverId: Snowflake): Promise { diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index ef32ff4c..6bfe50e0 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -4,7 +4,6 @@ import { DateTime } from 'luxon'; import type { ServerConfig } from '../../src/db/schema.ts'; import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService.ts'; import { - apiError, fakeClient, fakeGuildWithMembers, fakeEditableMessage, @@ -39,29 +38,6 @@ test('refreshCalendar: not configured', async () => { assert.equal(await refreshCalendar(appAt(), fakeClient(), config), 'not-configured'); }); -test('refreshCalendar: channel fetch rejecting keeps the stored path', async () => { - const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - const client = fakeClient({ channelFetchError: apiError(50001) }); - assert.equal(await refreshCalendar(appAt(), client, config), 'channel-missing'); - assert.equal(config.birthdayCalendarMessagePath, 'c1/m1'); -}); - -test('refreshCalendar: message missing', async () => { - const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - const client = fakeClient({ channels: [fakeTextChannel('c1')] }); - assert.equal(await refreshCalendar(appAt(), client, config), 'message-missing'); -}); - -test('refreshCalendar: edit failure reports failed', async () => { - const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); - const message = fakeEditableMessage('m1'); - message.edit = async () => { - throw new Error('boom'); - }; - const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); - assert.equal(await refreshCalendar(appAt(), client, config), 'failed'); -}); - test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', async () => { const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); createProfiles([ diff --git a/tests/birthdays/configCreate.test.ts b/tests/birthdays/configCreate.test.ts deleted file mode 100644 index 5859a342..00000000 --- a/tests/birthdays/configCreate.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, test } from 'node:test'; -import { PermissionFlagsBits, type Guild } from 'discord.js'; -import BirthdaysGroup from '../../src/commands/config/groups/birthdays.ts'; -import type { Handler } from '../../src/framework/command.ts'; -import { fakeCommandContext } from '../fakes/command.ts'; -import { deleteCalendarMessage } from '../../src/services/BirthdayService.ts'; -import { stub } from '../fakes/discord.ts'; -import { apiError, fakeClient, fakeEditableMessage, fakeTextChannel } from '../fakes/guild.ts'; -import { createTestApp } from '../helpers/app.ts'; -import { last } from '../helpers/assertions.ts'; -import { dbFixtures } from '../helpers/db.ts'; - -const apps = [createTestApp(), createTestApp(), createTestApp()]; -after(() => { - apps.forEach((a) => a.close()); -}); - -const create: Handler = BirthdaysGroup.subcommands.find((s) => s.name === 'create')!.run; - -const setup = ( - app: (typeof apps)[number], - opts: { failSend?: boolean; failRefresh?: boolean; noPrevious?: boolean } = {}, -) => { - const { clearConfigs, createConfig, findConfig } = dbFixtures(app); - clearConfigs(); - const order: string[] = []; - const oldMessage = fakeEditableMessage('old-msg'); - oldMessage.delete = async () => { - order.push(`delete-old(path=${findConfig('guild-1')?.birthdayCalendarMessagePath})`); - oldMessage.deleted = true; - }; - const oldChannel = fakeTextChannel('old-chan', [oldMessage]); - const newMessage = fakeEditableMessage('sent-1'); - newMessage.delete = async () => { - order.push('delete-new'); - newMessage.deleted = true; - }; - if (opts.failRefresh) { - newMessage.edit = async () => { - throw new Error('edit failed'); - }; - } - const target = fakeTextChannel('new-chan', [newMessage]); - target.send = async () => { - order.push('send-new'); - if (opts.failSend) { - throw new Error('missing access'); - } - return { id: 'sent-1', channelId: 'new-chan', delete: () => newMessage.delete() }; - }; - Object.assign(target, { - permissionsFor: () => ({ has: () => true }), - }); - const guild = stub({ - id: 'guild-1', - name: 'Test Guild', - members: { me: { id: 'bot' } }, - channels: { cache: new Map([['new-chan', target]]), fetch: async () => target }, - }); - const config = createConfig({ - serverId: 'guild-1', - birthdayCalendarMessagePath: opts.noPrevious ? null : 'old-chan/old-msg', - }); - const client = fakeClient({ channels: [oldChannel, target] }); - const { ctx, replies } = fakeCommandContext( - app, - { channel: { id: 'new-chan' } }, - { guild, config, client }, - ); - return { ctx, replies, order, oldMessage, newMessage, findConfig }; -}; - -test('birthdays create keeps the old calendar and path when sending the new message fails', async () => { - const app = apps[0]!; - const { ctx, order, oldMessage, findConfig } = setup(app, { failSend: true }); - await assert.rejects(create(ctx), /missing access/); - assert.equal(oldMessage.deleted, false); - assert.deepEqual(order, ['send-new']); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); -}); - -test('birthdays create deletes the old message only after the new path is saved', async () => { - const app = apps[0]!; - const { ctx, replies, order, oldMessage, findConfig } = setup(app); - await create(ctx); - assert.equal(oldMessage.deleted, true); - assert.deepEqual(order, ['send-new', 'delete-old(path=new-chan/sent-1)']); - assert.equal(last(replies).content!.startsWith('Birthday calendar has been created'), true); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'new-chan/sent-1'); - assert.match(last(replies).content!, /new-chan\/sent-1/); -}); - -test('birthdays create removes the new message when persisting fails and leaves the old one', async () => { - const app = apps[1]!; - const { ctx, oldMessage } = setup(app); - const deleted: string[] = []; - const guild = ctx.guild as unknown as { - channels: { cache: Map Promise }> }; - }; - const channel = guild.channels.cache.get('new-chan')!; - channel.send = async () => ({ - id: 'new-msg', - channelId: 'new-chan', - delete: async () => void deleted.push('new-msg'), - }); - app.db.$client.exec('DROP TABLE `ServerConfigs`'); - await assert.rejects(create(ctx)); - assert.deepEqual(deleted, ['new-msg']); - assert.equal(oldMessage.deleted, false); -}); - -test('birthdays create restores the old path and keeps the old calendar when population fails', async () => { - const app = apps[0]!; - const { ctx, replies, order, oldMessage, newMessage, findConfig } = setup(app, { - failRefresh: true, - }); - await assert.rejects(create(ctx), /existing calendar was kept/); - assert.equal(oldMessage.deleted, false); - assert.equal(newMessage.deleted, true); - assert.deepEqual(order, ['send-new', 'delete-new']); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); - assert.equal(replies.length, 0); -}); - -test('birthdays create clears the path when population fails and there was no calendar', async () => { - const app = apps[2]!; - const { ctx, newMessage, findConfig } = setup(app, { failRefresh: true, noPrevious: true }); - await assert.rejects(create(ctx), /nothing was changed/); - assert.equal(newMessage.deleted, true); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); -}); - -const remove: Handler = BirthdaysGroup.subcommands.find((s) => s.name === 'remove')!.run; - -const freshApp = () => { - const app = createTestApp(); - after(() => app.close()); - return app; -}; - -const setupRemove = (app: (typeof apps)[number], channelsFor: 'normal' | 'none' | 'throw') => { - const { clearConfigs, createConfig, findConfig } = dbFixtures(app); - clearConfigs(); - const message = fakeEditableMessage('old-msg'); - const channel = fakeTextChannel('old-chan', [message]); - const config = createConfig({ - serverId: 'guild-1', - birthdayCalendarMessagePath: 'old-chan/old-msg', - }); - const client = fakeClient({ - channels: channelsFor === 'none' ? [] : [channel], - ...(channelsFor === 'throw' - ? { channelFetchError: Object.assign(new Error('boom'), { code: 500 }) } - : {}), - }); - const { ctx, replies } = fakeCommandContext(app, {}, { config, client }); - return { ctx, replies, message, channel, findConfig }; -}; - -test('birthdays remove clears the path after deleting the message', async () => { - const { ctx, replies, message, findConfig } = setupRemove(freshApp(), 'normal'); - await remove(ctx); - assert.equal(message.deleted, true); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); - assert.equal(last(replies).content, 'Birthday calendar removed.'); -}); - -test('birthdays remove clears the path when the channel is already gone', async () => { - const { ctx, replies, findConfig } = setupRemove(freshApp(), 'none'); - await remove(ctx); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); - assert.match(last(replies).content!, /already gone/); -}); - -test('birthdays remove clears the path when the message is already gone (10008)', async () => { - const { ctx, replies, channel, findConfig } = setupRemove(freshApp(), 'normal'); - channel.messages.fetch = async () => { - throw apiError(10008); - }; - await remove(ctx); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, null); - assert.match(last(replies).content!, /already gone/); -}); - -test('birthdays remove keeps the path and errors when deletion fails', async () => { - const app = createTestApp(); - after(() => app.close()); - const { ctx, replies, channel, findConfig } = setupRemove(app, 'normal'); - channel.messages.fetch = async () => { - throw apiError(50013); - }; - await assert.rejects(remove(ctx), /nothing was changed/); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); - assert.equal(replies.length, 0); -}); - -test('deleteCalendarMessage classifies results', async () => { - const app = createTestApp(); - after(() => app.close()); - const msg = fakeEditableMessage('m'); - const chan = fakeTextChannel('c', [msg]); - assert.equal( - await deleteCalendarMessage(app, fakeClient({ channels: [chan] }), 'c/m'), - 'deleted', - ); - assert.equal(await deleteCalendarMessage(app, fakeClient(), 'c/m'), 'already-missing'); - assert.equal( - await deleteCalendarMessage(app, fakeClient({ channels: [chan] }), 'c/other'), - 'already-missing', - ); - assert.equal(await deleteCalendarMessage(app, fakeClient(), null), 'already-missing'); - const unknownChannel = fakeClient({ channelFetchError: apiError(10003) }); - assert.equal(await deleteCalendarMessage(app, unknownChannel, 'c/m'), 'already-missing'); - const forbidden = fakeClient({ channelFetchError: apiError(50013) }); - assert.equal(await deleteCalendarMessage(app, forbidden, 'c/m'), 'failed'); - const plain = fakeClient({ channelFetchError: new Error('network') }); - assert.equal(await deleteCalendarMessage(app, plain, 'c/m'), 'failed'); -}); - -test('birthdays create requires Read Message History and sends nothing without it', async () => { - const app = apps[0]!; - const { ctx, replies, order, findConfig } = setup(app); - const channel = await ctx.guild.channels.fetch('new-chan'); - Object.assign(channel!, { - permissionsFor: () => ({ - has: (flag: bigint) => flag !== PermissionFlagsBits.ReadMessageHistory, - }), - }); - await assert.rejects(create(ctx), /read message history/); - assert.deepEqual(order, []); - assert.equal(findConfig('guild-1')?.birthdayCalendarMessagePath, 'old-chan/old-msg'); - assert.equal(replies.length, 0); -}); diff --git a/tests/birthdays/stateMachine.test.ts b/tests/birthdays/stateMachine.test.ts new file mode 100644 index 00000000..1c2405b8 --- /dev/null +++ b/tests/birthdays/stateMachine.test.ts @@ -0,0 +1,585 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { PermissionFlagsBits, type Client, type Guild, type GuildMember } from 'discord.js'; +import BirthdaysGroup from '../../src/commands/config/groups/birthdays.ts'; +import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import type { Handler } from '../../src/framework/command.ts'; +import { + deleteCalendarMessage, + refreshAllCalendars, + refreshCalendar, + type CalendarStatus, +} from '../../src/services/BirthdayService.ts'; +import { fakeCommandContext } from '../fakes/command.ts'; +import { fakeAuditChannel, fakeMember, stub, type SentPayload } from '../fakes/discord.ts'; +import { + apiError, + fakeClient, + fakeEditableMessage, + fakeTextChannel, + type FakeChannel, +} from '../fakes/guild.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { contentOf, last, nth } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { clearConfigs, clearProfiles, createConfig, createProfiles, findConfig } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + clearConfigs(); + clearProfiles(); +}); + +const sub = (name: string): Handler => BirthdaysGroup.subcommands.find((s) => s.name === name)!.run; +const create = sub('create'); +const remove = sub('remove'); +const pathOf = (): string | null | undefined => findConfig('guild-1')?.birthdayCalendarMessagePath; +const network = Object.assign(new Error('network'), { code: 500 }); + +// --------------------------------------------------------------------------------------------- +// refresh: stored path x failure -> status; the stored path is never changed by a refresh. +// --------------------------------------------------------------------------------------------- + +interface RefreshRow { + name: string; + path: string | null; + channel?: 'ok' | 'unknown-channel' | Error | 'not-text'; + message?: 'ok' | Error; + edit?: Error; + status: CalendarStatus; +} + +const refreshRows: RefreshRow[] = [ + { name: 'no path', path: null, status: 'not-configured' }, + { name: 'malformed path', path: 'garbage', status: 'not-configured' }, + { name: 'all fine', path: 'c1/m1', status: 'updated' }, + { + name: 'channel not found', + path: 'c1/m1', + channel: 'unknown-channel', + status: 'channel-missing', + }, + { + name: 'channel is not text based', + path: 'c1/m1', + channel: 'not-text', + status: 'channel-missing', + }, + { + name: 'channel fetch: Unknown Channel', + path: 'c1/m1', + channel: apiError(10003), + status: 'channel-missing', + }, + { + name: 'channel fetch: Missing Access', + path: 'c1/m1', + channel: apiError(50001), + status: 'no-access', + }, + { + name: 'channel fetch: network error is transient', + path: 'c1/m1', + channel: network, + status: 'failed', + }, + { + name: 'message fetch: Unknown Message', + path: 'c1/m1', + message: apiError(10008), + status: 'message-missing', + }, + { + name: 'message fetch: Missing Permissions', + path: 'c1/m1', + message: apiError(50013), + status: 'no-access', + }, + { + name: 'message fetch: network error is transient', + path: 'c1/m1', + message: network, + status: 'failed', + }, + { + name: 'edit: message deleted meanwhile', + path: 'c1/m1', + edit: apiError(10008), + status: 'message-missing', + }, + { name: 'edit: cannot edit', path: 'c1/m1', edit: apiError(50005), status: 'no-access' }, + { name: 'edit: network error is transient', path: 'c1/m1', edit: network, status: 'failed' }, +]; + +describe('birthday calendar refresh', () => { + for (const row of refreshRows) { + test(row.name, async () => { + const config = createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: row.path }); + const message = fakeEditableMessage('m1'); + if (row.edit) { + message.edit = async () => { + throw row.edit; + }; + } + const channel = fakeTextChannel('c1', [message]); + if (row.message instanceof Error) { + channel.messages.fetch = async () => { + throw row.message as Error; + }; + } + if (row.channel === 'not-text') { + channel.isTextBased = () => false; + } + const client = fakeClient({ + channels: row.channel === 'unknown-channel' ? [] : [channel], + ...(row.channel instanceof Error ? { channelFetchError: row.channel } : {}), + }); + assert.equal(await refreshCalendar(app, client, config), row.status); + assert.equal(pathOf(), row.path, 'a refresh never changes the stored path'); + assert.equal(message.edits.length, row.status === 'updated' ? 1 : 0); + }); + } + + test('concurrent refreshes run in order, so the last edit has the latest profiles', async () => { + const config = createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: 'c1/m1' }); + createProfiles([{ serverId: 'guild-1', userId: 'alice', birthdayMonth: 3, birthdayDay: 4 }]); + const message = fakeEditableMessage('m1'); + const firstEdit = Promise.withResolvers(); + const record = (payload: unknown) => { + message.edits.push(payload as SentPayload); + return Promise.resolve(message); + }; + let calls = 0; + message.edit = async (payload) => { + if (calls++ === 0) { + await firstEdit.promise; + } + return record(payload); + }; + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + const first = refreshCalendar(app, client, config); + await new Promise((resolve) => setImmediate(resolve)); + createProfiles([{ serverId: 'guild-1', userId: 'bob', birthdayMonth: 3, birthdayDay: 5 }]); + const second = refreshCalendar(app, client, config); + await new Promise((resolve) => setImmediate(resolve)); + firstEdit.resolve(); + await Promise.all([first, second]); + assert.match(contentOf(last(message.edits)), /<@alice>/); + assert.match(contentOf(last(message.edits)), /<@bob>/); + }); + + test('a failed refresh does not block the next one', async () => { + const config = createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: 'c1/m1' }); + const bad = fakeClient({ channelFetchError: network }); + const good = fakeClient({ channels: [fakeTextChannel('c1', [fakeEditableMessage('m1')])] }); + assert.equal(await refreshCalendar(app, bad, config), 'failed'); + assert.equal(await refreshCalendar(app, good, config), 'updated'); + }); + + test('the scheduled refresh skips a config purged since it was listed', async () => { + createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: 'c1/m1' }); + const message = fakeEditableMessage('m1'); + const channel = fakeTextChannel('c1', [message]); + const client = fakeClient({ channels: [channel] }); + const fetch = client.channels.fetch.bind(client.channels); + client.channels.fetch = (async (id: string) => { + clearConfigs(); // guildDelete lands while the job is running + return fetch(id); + }) as typeof client.channels.fetch; + await refreshAllCalendars(app, client); + assert.equal(findConfig('guild-1'), null, 'no config row is recreated'); + }); +}); + +// --------------------------------------------------------------------------------------------- +// create / remove: previous path x injected failure -> stored path + message side effects. +// --------------------------------------------------------------------------------------------- + +interface CommandRow { + name: string; + command: 'create' | 'remove'; + previous: boolean; + failSend?: boolean; + failPersist?: boolean; + failPopulate?: boolean; + noReadHistory?: boolean; + oldChannel?: 'normal' | 'gone' | 'throws'; + oldMessage?: 'normal' | Error; + /** Expected error message, or undefined when the command succeeds. */ + error?: RegExp; + reply?: string | RegExp; + path: 'old' | 'new' | null; + oldDeleted: boolean; + newDeleted: boolean; +} + +const OLD = 'old-chan/old-msg'; +const commandRows: CommandRow[] = [ + { + name: 'create over an old calendar: new path saved, then old deleted', + command: 'create', + previous: true, + reply: /created/, + path: 'new', + oldDeleted: true, + newDeleted: false, + }, + { + name: 'create with no calendar: path saved', + command: 'create', + previous: false, + reply: /created/, + path: 'new', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, send fails: old calendar and path intact', + command: 'create', + previous: true, + failSend: true, + error: /missing access/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, persisting fails: new message removed, old intact', + command: 'create', + previous: true, + failPersist: true, + error: /./, + path: 'old', + oldDeleted: false, + newDeleted: true, + }, + { + name: 'create, populate fails over old: old path restored, new removed', + command: 'create', + previous: true, + failPopulate: true, + error: /existing calendar was kept/, + path: 'old', + oldDeleted: false, + newDeleted: true, + }, + { + name: 'create, populate fails with no old: path cleared, new removed', + command: 'create', + previous: false, + failPopulate: true, + error: /nothing was changed/, + path: null, + oldDeleted: false, + newDeleted: true, + }, + { + name: 'create without Read Message History: nothing sent', + command: 'create', + previous: true, + noReadHistory: true, + error: /read message history/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, old message cannot be deleted: new calendar still wins', + command: 'create', + previous: true, + oldMessage: network, + reply: /created/, + path: 'new', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, old channel gone: new calendar wins', + command: 'create', + previous: true, + oldChannel: 'gone', + reply: /created/, + path: 'new', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove: message deleted, path cleared', + command: 'remove', + previous: true, + reply: 'Birthday calendar removed.', + path: null, + oldDeleted: true, + newDeleted: false, + }, + { + name: 'remove, channel already gone: path cleared', + command: 'remove', + previous: true, + oldChannel: 'gone', + reply: /already gone/, + path: null, + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove, message already gone (10008): path cleared', + command: 'remove', + previous: true, + oldMessage: apiError(10008), + reply: /already gone/, + path: null, + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove, Missing Permissions: path kept, error', + command: 'remove', + previous: true, + oldMessage: apiError(50013), + error: /nothing was changed/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove, transient channel error: path kept, error', + command: 'remove', + previous: true, + oldChannel: 'throws', + error: /nothing was changed/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove with no calendar: error', + command: 'remove', + previous: false, + error: /no birthday calendar/, + path: null, + oldDeleted: false, + newDeleted: false, + }, +]; + +function commandEnv( + row: Pick & Partial, +) { + const order: string[] = []; + const oldMessage = fakeEditableMessage('old-msg'); + oldMessage.delete = async () => { + order.push(`delete-old(path=${pathOf()})`); + oldMessage.deleted = true; + }; + const oldChannel = fakeTextChannel('old-chan', [oldMessage]); + if (row.oldMessage instanceof Error) { + const error = row.oldMessage; + oldChannel.messages.fetch = async () => { + throw error; + }; + } + const newMessage = fakeEditableMessage('sent-1'); + newMessage.delete = async () => { + order.push('delete-new'); + newMessage.deleted = true; + }; + if (row.failPopulate) { + newMessage.edit = async () => { + throw new Error('edit failed'); + }; + } + const target = fakeTextChannel('new-chan', [newMessage]); + target.send = async () => { + order.push('send-new'); + if (row.failSend) { + throw new Error('missing access'); + } + return { id: 'sent-1', channelId: 'new-chan', delete: () => newMessage.delete() }; + }; + Object.assign(target, { + permissionsFor: () => ({ + has: (flag: bigint) => + !(row.noReadHistory && flag === PermissionFlagsBits.ReadMessageHistory), + }), + }); + const guild = stub({ + id: 'guild-1', + name: 'Test Guild', + members: { me: { id: 'bot' } }, + channels: { cache: new Map([['new-chan', target]]), fetch: async () => target }, + }); + const config = createConfig({ + serverId: 'guild-1', + birthdayCalendarMessagePath: row.previous ? OLD : null, + }); + const client = fakeClient({ + channels: row.oldChannel === 'gone' ? [target] : [oldChannel, target], + ...(row.oldChannel === 'throws' ? { channelFetchError: network } : {}), + }); + const { ctx, replies } = fakeCommandContext( + app, + { channel: { id: 'new-chan' } }, + { guild, config, client }, + ); + return { ctx, replies, order, oldMessage, newMessage, oldChannel, target, guild, client }; +} + +describe('birthday calendar create and remove', () => { + for (const row of commandRows) { + test(row.name, async () => { + const env = commandEnv(row); + if (row.failPersist) { + app.db.$client.exec('ALTER TABLE `ServerConfigs` RENAME TO `ServerConfigsX`'); + } + try { + const run = (row.command === 'create' ? create : remove)(env.ctx); + if (row.error) { + await assert.rejects(run, row.error); + } else { + await run; + } + } finally { + if (row.failPersist) { + app.db.$client.exec('ALTER TABLE `ServerConfigsX` RENAME TO `ServerConfigs`'); + } + } + const expectedPath = row.path === 'old' ? OLD : row.path === 'new' ? 'new-chan/sent-1' : null; + assert.equal(pathOf() ?? null, expectedPath, 'stored path'); + assert.equal(env.oldMessage.deleted, row.oldDeleted, 'old message deleted'); + assert.equal(env.newMessage.deleted, row.newDeleted, 'new message deleted'); + if (row.reply !== undefined) { + assert.match(contentOf(last(env.replies)), new RegExp(row.reply)); + } else { + assert.equal(env.replies.length, 0); + } + if (row.command === 'create' && row.path === 'new' && row.oldDeleted) { + assert.ok( + env.order.indexOf('delete-old(path=new-chan/sent-1)') > env.order.indexOf('send-new'), + 'the old message is deleted only after the new path is saved', + ); + } + if (row.noReadHistory) { + assert.deepEqual(env.order, [], 'nothing is sent without permission'); + } + }); + } + + test('concurrent creates leave exactly one live calendar', async () => { + const env = commandEnv({ previous: true }); + const messages = ['a', 'b'].map((id) => fakeEditableMessage(`sent-${id}`)); + const channel = fakeTextChannel('new-chan', messages); + let sends = 0; + channel.send = async () => { + const message = nth(messages, sends++); + return { id: message.id, channelId: 'new-chan', delete: () => message.delete() }; + }; + Object.assign(channel, { permissionsFor: () => ({ has: () => true }) }); + Object.assign(env.guild, { channels: { cache: new Map([['new-chan', channel]]) } }); + Object.assign(env.client, { + channels: { + fetch: async (id: string) => + id === 'new-chan' ? channel : id === 'old-chan' ? env.oldChannel : null, + }, + }); + const second = fakeCommandContext( + app, + { channel: { id: 'new-chan' } }, + { guild: env.guild, config: env.ctx.config, client: env.client }, + ); + await Promise.all([create(env.ctx), create(second.ctx)]); + assert.equal(pathOf(), 'new-chan/sent-b'); + assert.equal(env.oldMessage.deleted, true); + assert.equal(nth(messages, 0).deleted, true, 'the replaced calendar is not orphaned'); + assert.equal(nth(messages, 1).deleted, false); + }); + + test('a create whose population fails does not clobber a calendar saved meanwhile', async () => { + const env = commandEnv({ previous: true, failPopulate: true }); + env.newMessage.edit = async () => { + // A concurrent create saves its own calendar while this one is populating. + app.db.$client.exec( + "UPDATE `ServerConfigs` SET birthdayCalendarMessagePath = 'other-chan/other-msg'", + ); + app.configCache.clear(); + throw new Error('edit failed'); + }; + await assert.rejects(create(env.ctx), /existing calendar was kept/); + assert.equal(pathOf(), 'other-chan/other-msg'); + }); + + test('deleteCalendarMessage classifies results', async () => { + const msg = fakeEditableMessage('m'); + const chan = fakeTextChannel('c', [msg]); + const cases: [string, Parameters[1], string | null][] = [ + ['deleted', fakeClient({ channels: [chan] }), 'c/m'], + ['already-missing', fakeClient(), 'c/m'], + ['already-missing', fakeClient({ channels: [chan] }), 'c/other'], + ['already-missing', fakeClient(), null], + ['already-missing', fakeClient({ channelFetchError: apiError(10003) }), 'c/m'], + ['failed', fakeClient({ channelFetchError: apiError(50013) }), 'c/m'], + ['failed', fakeClient({ channelFetchError: new Error('network') }), 'c/m'], + ]; + for (const [status, client, path] of cases) { + assert.equal(await deleteCalendarMessage(app, client, path), status); + } + }); +}); + +// --------------------------------------------------------------------------------------------- +// member leaves: the calendar must stop listing them. +// --------------------------------------------------------------------------------------------- + +describe('member leaving and the calendar', () => { + const leave = async (profile: Record | null, failEdit = false) => { + createConfig({ + serverId: 'guild-1', + birthdayCalendarMessagePath: 'c1/m1', + auditChannelId: 'audit-1', + }); + if (profile) { + createProfiles([{ serverId: 'guild-1', userId: 'user-1', ...profile }]); + } + const message = fakeEditableMessage('m1'); + if (failEdit) { + message.edit = async () => { + throw network; + }; + } + const calendar: FakeChannel = fakeTextChannel('c1', [message]); + const audit = fakeAuditChannel(); + const client = stub({ + channels: { fetch: async (id: string) => (id === 'c1' ? calendar : audit.channel) }, + }); + const member = fakeMember('user-1', { guild: stub({ id: 'guild-1' }), partial: false }); + await guildMemberRemove.run(app, client, stub(member)); + return { message, audit }; + }; + + test('a departing member with a birthday triggers a calendar refresh', async () => { + const { message, audit } = await leave({ birthdayMonth: 3, birthdayDay: 4 }); + assert.equal(message.edits.length, 1); + assert.equal(audit.sent.length, 1); + }); + + test('a departing member without a birthday does not', async () => { + const { message } = await leave({ activityScore: 3 }); + assert.equal(message.edits.length, 0); + }); + + test('a failing refresh does not stop the leave being audited', async () => { + const { audit } = await leave({ birthdayMonth: 3, birthdayDay: 4 }, true); + assert.equal(audit.sent.length, 1); + }); + + test('profile data is deleted whatever happens to the calendar', async () => { + await leave({ birthdayMonth: 3, birthdayDay: 4 }, true); + assert.equal(dbFixtures(app).findProfile('user-1'), null); + }); +}); From adecc2880638da2a897ee46402fbc048843a3dd7 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:22:53 +0100 Subject: [PATCH 69/77] Specify command registration and make scope switching robust Trust a stored hash only while the recorded scope matches the current one (an unrecorded scope re-registers to record it), and stop retrying the old dev guild cleanup on every start once the bot can no longer reach that guild. Replace the piecemeal registrar tests with a table-driven state machine test. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/framework/registrar.ts | 55 +++++- tests/registrar.test.ts | 195 -------------------- tests/registrar/stateMachine.test.ts | 259 +++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 205 deletions(-) delete mode 100644 tests/registrar.test.ts create mode 100644 tests/registrar/stateMachine.test.ts diff --git a/README.md b/README.md index 8177e1ad..9c89a623 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The invite URL should include all these permissions. You can generate one in the Use `DEV_GUILD_ID` in `.env` to test commands instantly in a single guild instead of waiting 1 hour for global registration. -On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). The hash is stored per application (`CLIENT_ID`) and per scope, so the first start after upgrading re-registers once. The last registered scope is stored in BotState (`commandsScope:`). Switching from a dev guild (`DEV_GUILD_ID=X`) to global or another guild automatically clears the old dev guild's commands after the new registration succeeds. Switching from global to a dev guild does NOT remove global commands (they may be production) — remove them via `npm run deploy:commands` without `DEV_GUILD_ID` set or the Developer Portal. `npm run deploy:commands` uses the same logic and opens the database to record the scope/hash. To force a registration without starting the bot, or to clean up orphaned commands, run: +On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). The hash is stored per application (`CLIENT_ID`) and per scope, so the first start after upgrading re-registers once. The last registered scope is stored in BotState (`commandsScope:`). Switching from a dev guild (`DEV_GUILD_ID=X`) to global or another guild automatically clears the old dev guild's commands after the new registration succeeds (a failed cleanup is retried on the next start, unless the bot is no longer in that guild). Switching from global to a dev guild does NOT remove global commands (they may be production) — remove them via `npm run deploy:commands` without `DEV_GUILD_ID` set or the Developer Portal. `npm run deploy:commands` uses the same logic and opens the database to record the scope/hash. To force a registration without starting the bot, or to clean up orphaned commands, run: ```bash npm run deploy:commands diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts index eafff168..25771236 100644 --- a/src/framework/registrar.ts +++ b/src/framework/registrar.ts @@ -1,8 +1,40 @@ +/* + * COMMAND REGISTRATION SPEC + * + * Persisted state (BotState, keyed by application id so another bot sharing the database is + * independent): + * commandsHash:: hash of the commands last registered successfully in + * commandsScope: the scope last registered successfully and fully cleaned up + * A scope is `global` or `guild:`; the env's DEV_GUILD_ID picks the current one. + * + * Transitions (last recorded scope -> current scope, on startup or `npm run deploy:commands`): + * same scope, hash equal skip (startup) / register again (deploy script) + * same scope, hash differs or missing register, record hash + * none recorded register, record hash and scope (re-registers once) + * guild:X -> global | guild:Y register, then clear guild X, then record the scope + * global -> guild:Y register in Y; global commands are never cleared + * automatically (may be production), a warning says so + * + * Invariants: + * 1. The hash is recorded only after a successful register, so a failed or interrupted + * registration is retried on the next start; nothing is cleared or recorded when it fails. + * 2. The scope is recorded only after the previous dev guild was cleaned up (or cannot be: + * the bot is gone from it), so a failed cleanup is retried on every start until it works. + * 3. A recorded hash is trusted only while the recorded scope equals the current scope, so + * switching scope always re-registers, even back to a scope whose old hash still matches. + * 4. Registration failure at startup is logged and never stops the bot. + * + * Failure handling: Discord errors on register -> 'failed' (retry next start); on cleanup -> + * keep the old scope (retry), except Unknown Guild / Missing Access, where the guild's commands + * are unreachable and nothing remains to be done. + */ + import { createHash } from 'node:crypto'; import { REST, Routes } from 'discord.js'; import type { App } from '../app.ts'; import type { Env } from '../config/env.ts'; import { getState, setState } from '../services/BotState.ts'; +import { classifyGuildFetchError } from '../services/RetentionPolicy.ts'; import type { Command } from './command.ts'; export interface CommandScope { @@ -112,12 +144,17 @@ export async function registerCommands( cleaned = true; logger.info('Cleared application commands from the previous dev guild', { guild: guildId }); } catch (error) { - scopeRecorded = false; // keep the old scope so the next run retries the cleanup - logger.warn( - 'Could not clear application commands from the previous dev guild; remove them manually', - { guild: guildId }, - error, - ); + if (classifyGuildFetchError(error) === 'gone') { + // The bot is no longer in that guild, so its commands there are gone with it. + logger.info('Previous dev guild is unreachable; nothing to clear', { guild: guildId }); + } else { + scopeRecorded = false; // keep the old scope so the next run retries the cleanup + logger.warn( + 'Could not clear application commands from the previous dev guild; remove them manually', + { guild: guildId }, + error, + ); + } } } if (scopeRecorded) { @@ -150,9 +187,9 @@ export async function syncCommands( const hash = hashCommands(commands); const previousScope = getState(db, scope.scopeKey); - // A changed scope must re-register even when this scope's stored hash still matches, so the - // previous dev guild gets cleaned up. - if (getState(db, scope.stateKey) === hash && (previousScope ?? scope.scope) === scope.scope) { + // A changed (or unrecorded) scope must re-register even when this scope's stored hash still + // matches, so the previous dev guild gets cleaned up and the scope gets recorded. + if (getState(db, scope.stateKey) === hash && previousScope === scope.scope) { logger.info(`Application commands unchanged, skipping registration ${scope.label}`, { hash: hash.slice(0, 12), }); diff --git a/tests/registrar.test.ts b/tests/registrar.test.ts deleted file mode 100644 index 0620c9dc..00000000 --- a/tests/registrar.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, beforeEach, test } from 'node:test'; -import { defineCommand, type Command } from '../src/framework/command.ts'; -import { commandScope, hashCommands, syncCommands } from '../src/framework/registrar.ts'; -import { getState } from '../src/services/BotState.ts'; -import { createTestApp } from './helpers/app.ts'; - -const app = createTestApp(); - -after(() => { - app.close(); -}); - -const command = (name: string, description = 'd'): Command => - defineCommand({ name, description, run: async () => {} }); - -const recorder = () => { - const calls: number[] = []; - return { - calls, - put: async (_env: unknown, commands: readonly Command[]) => { - calls.push(commands.length); - return commands.length; - }, - }; -}; - -beforeEach(() => { - app.db.$client.exec('DELETE FROM `BotState`'); -}); - -test('hashCommands is stable and changes with command content', () => { - const a = [command('one'), command('two')]; - assert.equal(hashCommands(a), hashCommands([command('one'), command('two')])); - assert.notEqual(hashCommands(a), hashCommands([command('one'), command('two', 'changed')])); - assert.notEqual(hashCommands(a), hashCommands([command('one')])); -}); - -test('commandScope distinguishes global and dev guild targets', () => { - assert.equal( - commandScope({ devGuildId: undefined, clientId: '1' }).stateKey, - 'commandsHash:1:global', - ); - assert.equal( - commandScope({ devGuildId: '42', clientId: '1' }).stateKey, - 'commandsHash:1:guild:42', - ); -}); - -test('syncCommands registers once, then skips while the hash is unchanged', async () => { - const { calls, put } = recorder(); - const commands = [command('one')]; - - assert.equal(await syncCommands(app, commands, put), 'registered'); - assert.equal(await syncCommands(app, commands, put), 'unchanged'); - assert.deepEqual(calls, [1]); - assert.equal(getState(app.db, 'commandsHash:1:global'), hashCommands(commands)); - assert.ok(app.logsAt('info').some((entry) => /unchanged, skipping/.test(entry.message))); - - assert.equal(await syncCommands(app, [command('one'), command('two')], put), 'registered'); - assert.deepEqual(calls, [1, 2]); -}); - -test('syncCommands tracks the hash per scope', async () => { - const { calls, put } = recorder(); - const commands = [command('one')]; - await syncCommands(app, commands, put); - - const dev = { ...app, env: Object.freeze({ ...app.env, devGuildId: '42' }) }; - assert.equal(await syncCommands(dev, commands, put), 'registered'); - assert.equal(await syncCommands(dev, commands, put), 'unchanged'); - assert.deepEqual(calls, [1, 1]); - assert.equal(getState(app.db, 'commandsHash:1:guild:42'), hashCommands(commands)); -}); - -test('syncCommands does not store the hash when registration fails', async () => { - const failing = async (): Promise => { - throw new Error('discord down'); - }; - assert.equal(await syncCommands(app, [command('one')], failing), 'failed'); - assert.equal(getState(app.db, 'commandsHash:1:global'), undefined); - assert.equal(app.logsAt('error').length, 1); -}); - -test('syncCommands re-registers when the application id differs', async () => { - const { calls, put } = recorder(); - const commands = [command('one')]; - assert.equal(await syncCommands(app, commands, put), 'registered'); - - const other = { ...app, env: Object.freeze({ ...app.env, clientId: '2' }) }; - assert.equal(await syncCommands(other, commands, put), 'registered'); - assert.equal(await syncCommands(other, commands, put), 'unchanged'); - assert.deepEqual(calls, [1, 1]); - assert.equal(getState(app.db, 'commandsHash:2:global'), hashCommands(commands)); -}); - -test('syncCommands ignores hashes stored under the old key format', async () => { - const { calls, put } = recorder(); - const commands = [command('one')]; - app.db.$client.exec( - `INSERT INTO BotState (key, value, updatedAt) VALUES ('commandsHash:global', '${hashCommands(commands)}', '2024-01-01')`, - ); - assert.equal(await syncCommands(app, commands, put), 'registered'); - assert.deepEqual(calls, [1]); -}); - -test('syncCommands warns that the other scope may hold duplicate commands', async () => { - const { put } = recorder(); - const dev = { ...app, env: Object.freeze({ ...app.env, devGuildId: '42' }) }; - await syncCommands(dev, [command('one')], put); - assert.ok(app.logsAt('warn').some((e) => /global commands may also exist/.test(e.message))); - assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); -}); - -const inScope = (devGuildId?: string) => ({ - ...app, - env: Object.freeze({ ...app.env, devGuildId }), -}); - -const clearer = () => { - const cleared: string[] = []; - return { - cleared, - clear: async (_env: unknown, guildId: string) => { - cleared.push(guildId); - }, - }; -}; - -test('switching from a dev guild to global clears the old guild', async () => { - const { put } = recorder(); - const { cleared, clear } = clearer(); - const commands = [command('one')]; - await syncCommands(inScope('42'), commands, { put, clear }); - assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); - assert.deepEqual(cleared, []); - - assert.equal(await syncCommands(inScope(), commands, { put, clear }), 'registered'); - assert.deepEqual(cleared, ['42']); - assert.equal(getState(app.db, 'commandsScope:1'), 'global'); - assert.ok(app.logsAt('info').some((e) => /Cleared application commands/.test(e.message))); -}); - -test('switching between dev guilds clears the previous one', async () => { - const { put } = recorder(); - const { cleared, clear } = clearer(); - const commands = [command('one')]; - await syncCommands(inScope('A'), commands, { put, clear }); - await syncCommands(inScope('B'), commands, { put, clear }); - assert.deepEqual(cleared, ['A']); - assert.equal(getState(app.db, 'commandsScope:1'), 'guild:B'); - // Staying put clears nothing further. - await syncCommands(inScope('B'), [command('one'), command('two')], { put, clear }); - assert.deepEqual(cleared, ['A']); -}); - -test('switching from global to a dev guild never clears global commands', async () => { - const { put } = recorder(); - const { cleared, clear } = clearer(); - const commands = [command('one')]; - await syncCommands(inScope(), commands, { put, clear }); - app.logs.length = 0; - await syncCommands(inScope('42'), commands, { put, clear }); - assert.deepEqual(cleared, []); - assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); - assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); -}); - -test('a failed registration clears nothing and keeps the recorded scope', async () => { - const { put } = recorder(); - const { cleared, clear } = clearer(); - await syncCommands(inScope('42'), [command('one')], { put, clear }); - const failing = async (): Promise => { - throw new Error('discord down'); - }; - assert.equal(await syncCommands(inScope(), [command('one')], { put: failing, clear }), 'failed'); - assert.deepEqual(cleared, []); - assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); -}); - -test('a failed cleanup keeps the old scope so the next run retries it', async () => { - const { put } = recorder(); - const commands = [command('one')]; - await syncCommands(inScope('42'), commands, { put }); - const failingClear = async (): Promise => { - throw new Error('forbidden'); - }; - assert.equal(await syncCommands(inScope(), commands, { put, clear: failingClear }), 'registered'); - assert.equal(getState(app.db, 'commandsScope:1'), 'guild:42'); - - const { cleared, clear } = clearer(); - assert.equal(await syncCommands(inScope(), commands, { put, clear }), 'registered'); - assert.deepEqual(cleared, ['42']); - assert.equal(getState(app.db, 'commandsScope:1'), 'global'); -}); diff --git a/tests/registrar/stateMachine.test.ts b/tests/registrar/stateMachine.test.ts new file mode 100644 index 00000000..c5dbad47 --- /dev/null +++ b/tests/registrar/stateMachine.test.ts @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { defineCommand, type Command } from '../../src/framework/command.ts'; +import { commandScope, hashCommands, syncCommands } from '../../src/framework/registrar.ts'; +import { getState, setState } from '../../src/services/BotState.ts'; +import { apiError } from '../fakes/discord.ts'; +import { createTestApp } from '../helpers/app.ts'; + +const app = createTestApp(); + +after(() => { + app.close(); +}); + +beforeEach(() => { + app.db.$client.exec('DELETE FROM `BotState`'); + app.logs.length = 0; +}); + +const command = (name: string, description = 'd'): Command => + defineCommand({ name, description, run: async () => {} }); + +const V1 = [command('one')]; +const V2 = [command('one'), command('two')]; + +test('hashCommands is stable and changes with command content', () => { + const a = [command('one'), command('two')]; + assert.equal(hashCommands(a), hashCommands([command('one'), command('two')])); + assert.notEqual(hashCommands(a), hashCommands([command('one'), command('two', 'changed')])); + assert.notEqual(hashCommands(a), hashCommands([command('one')])); +}); + +test('commandScope distinguishes global and dev guild targets and applications', () => { + const key = (devGuildId: string | undefined, clientId = '1') => + commandScope({ devGuildId, clientId }).stateKey; + assert.equal(key(undefined), 'commandsHash:1:global'); + assert.equal(key('42'), 'commandsHash:1:guild:42'); + assert.equal(key(undefined, '2'), 'commandsHash:2:global'); +}); + +type Result = 'registered' | 'unchanged' | 'failed'; + +interface Step { + /** DEV_GUILD_ID for this start; undefined is the global scope. */ + dev?: string; + commands?: readonly Command[]; + clientId?: string; + put?: 'ok' | 'fail'; + clear?: 'ok' | 'fail' | 'unknown-guild' | 'missing-access'; + result: Result; + puts: number; + /** Guilds whose commands were cleared during this step. */ + cleared?: string[]; + /** BotState scope afterwards (for the step's client id). */ + scope?: string; +} + +interface Row { + name: string; + /** Pre-existing BotState rows. */ + state?: Record; + steps: Step[]; + /** A warning matching this must have been logged by the last step. */ + warns?: RegExp; +} + +const rows: Row[] = [ + { + name: 'first start registers, then skips while the hash is unchanged', + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + { commands: V2, result: 'registered', puts: 1, scope: 'global' }, + ], + }, + { + name: 'hash is tracked per scope and per application', + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { dev: '42', result: 'unchanged', puts: 0, scope: 'guild:42' }, + { clientId: '2', result: 'registered', puts: 1, scope: 'global' }, + { clientId: '2', result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'a failed registration records nothing and is retried', + steps: [ + { put: 'fail', result: 'failed', puts: 1 }, + { result: 'registered', puts: 1, scope: 'global' }, + ], + }, + { + name: 'hashes under the old key format are ignored', + state: { 'commandsHash:global': 'stale' }, + steps: [{ result: 'registered', puts: 1, scope: 'global' }], + }, + { + name: 'a hash without a recorded scope (interrupted run) re-registers to record it', + state: { 'commandsHash:1:global': hashCommands(V1) }, + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'dev guild to global clears the old guild', + steps: [ + { dev: '42', result: 'registered', puts: 1, cleared: [], scope: 'guild:42' }, + { result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + ], + }, + { + name: 'dev guild A to B clears A once, and staying put clears nothing more', + steps: [ + { dev: 'A', result: 'registered', puts: 1, scope: 'guild:A' }, + { dev: 'B', result: 'registered', puts: 1, cleared: ['A'], scope: 'guild:B' }, + { dev: 'B', commands: V2, result: 'registered', puts: 1, cleared: [], scope: 'guild:B' }, + ], + }, + { + name: 'global to a dev guild never clears global commands and warns', + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { dev: '42', result: 'registered', puts: 1, cleared: [], scope: 'guild:42' }, + ], + warns: /deploy:commands/, + }, + { + name: 'returning to a scope whose stored hash still matches re-registers', + steps: [ + { dev: 'A', result: 'registered', puts: 1, scope: 'guild:A' }, + { result: 'registered', puts: 1, cleared: ['A'], scope: 'global' }, + { dev: 'A', result: 'registered', puts: 1, cleared: [], scope: 'guild:A' }, + ], + }, + { + name: 'a failed registration while switching clears nothing and keeps the scope', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { put: 'fail', result: 'failed', puts: 1, cleared: [], scope: 'guild:42' }, + { result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + ], + }, + { + name: 'a failed cleanup keeps the old scope so every start retries it', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { clear: 'fail', result: 'registered', puts: 1, cleared: ['42'], scope: 'guild:42' }, + { clear: 'fail', result: 'registered', puts: 1, cleared: ['42'], scope: 'guild:42' }, + { result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'a dev guild the bot has left cannot be cleared and is not retried forever', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { clear: 'unknown-guild', result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'Missing Access on cleanup is treated the same as an unknown guild', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { clear: 'missing-access', result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + ], + }, +]; + +describe('command registration state machine', () => { + for (const row of rows) { + test(row.name, async () => { + for (const [key, value] of Object.entries(row.state ?? {})) { + setState(app.db, key, value); + } + const hashes = new Map(); + for (const [index, step] of row.steps.entries()) { + let puts = 0; + const cleared: string[] = []; + const instance = { + ...app, + env: Object.freeze({ ...app.env, devGuildId: step.dev, clientId: step.clientId ?? '1' }), + }; + app.logs.length = 0; + const result = await syncCommands(instance, step.commands ?? V1, { + put: async (_env, commands) => { + puts++; + if (step.put === 'fail') { + throw new Error('discord down'); + } + return commands.length; + }, + clear: async (_env, guildId) => { + cleared.push(guildId); + if (step.clear === 'fail') { + throw new Error('forbidden'); + } + if (step.clear === 'unknown-guild') { + throw apiError(10004); + } + if (step.clear === 'missing-access') { + throw apiError(50001); + } + }, + }); + const at = `step ${index + 1}`; + assert.equal(result, step.result, `${at}: result`); + assert.equal(puts, step.puts, `${at}: register calls`); + assert.deepEqual(cleared, step.cleared ?? [], `${at}: cleared guilds`); + const clientId = step.clientId ?? '1'; + if ('scope' in step) { + assert.equal(getState(app.db, `commandsScope:${clientId}`), step.scope, `${at}: scope`); + } + const stateKey = commandScope(instance.env).stateKey; + if (step.result === 'failed') { + assert.equal( + getState(app.db, stateKey), + hashes.get(stateKey), + `${at}: a failed registration records no hash`, + ); + } else { + assert.equal( + getState(app.db, stateKey), + hashCommands(step.commands ?? V1), + `${at}: hash`, + ); + hashes.set(stateKey, hashCommands(step.commands ?? V1)); + } + } + if (row.warns) { + assert.ok(app.logsAt('warn').some((e) => row.warns!.test(e.message))); + } + }); + } + + test('a failed registration is logged once as an error', async () => { + const failing = async (): Promise => { + throw new Error('discord down'); + }; + assert.equal(await syncCommands(app, V1, failing), 'failed'); + assert.equal(app.logsAt('error').length, 1); + }); + + test('registering in a dev guild warns that global commands may remain', async () => { + const dev = { ...app, env: Object.freeze({ ...app.env, devGuildId: '42' }) }; + await syncCommands(dev, V1, async (_env, commands) => commands.length); + assert.ok(app.logsAt('warn').some((e) => /global commands may also exist/.test(e.message))); + assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); + }); + + test('skipping logs that commands are unchanged', async () => { + const put = async (_env: unknown, commands: readonly Command[]) => commands.length; + await syncCommands(app, V1, put); + await syncCommands(app, V1, put); + assert.ok(app.logsAt('info').some((e) => /unchanged, skipping/.test(e.message))); + }); +}); From 525c1e8f831b7bc1963c1081a31caf23603d0e27 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:25:57 +0100 Subject: [PATCH 70/77] Specify data retention and close its races Re-read each config before the data check acts on it, never purge a guild the gateway still lists, delete departed members' profiles by row id so a rejoiner's new profile survives, and re-record pending screenings when the bot is added to a guild. Consolidate the data check tests into a table-driven state machine test with the concurrent guildDelete/guildCreate cases. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- src/events/guildCreate.ts | 5 +- src/services/DataCheckService.ts | 57 +++- src/services/ScreeningService.ts | 75 +++-- src/services/UserProfileService.ts | 22 +- tests/datacheck/DataCheckService.test.ts | 154 --------- tests/datacheck/stateMachine.test.ts | 401 +++++++++++++++++++++++ 7 files changed, 509 insertions(+), 207 deletions(-) delete mode 100644 tests/datacheck/DataCheckService.test.ts create mode 100644 tests/datacheck/stateMachine.test.ts diff --git a/README.md b/README.md index 9c89a623..5e166d1d 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ Members can set their birthday using `/mybirthday set `. Birthdays On the schedule defined by `JOB_SCHEDULE` (default: 9:00 AM in the configured `BOT_TIMEZONE`), three tasks run in order: -1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. Profiles are snapshotted before fetching the member list, so members who join during the check aren't treated as departed. +1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. Profiles are snapshotted before fetching the member list, so members who join during the check aren't treated as departed. A guild the bot is still connected to is never purged. 2. **Birthday Notifications** — Posts birthday announcements in the announcements channel for members with birthdays today 3. **Birthday Calendars** — Updates all configured birthday calendar messages diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 2d1b99b7..d216a338 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,11 +1,14 @@ import { defineEvent } from '../framework/event.ts'; import { getConfig, resetAccessFailures } from '../services/ConfigService.ts'; +import { syncGuildScreening } from '../services/ScreeningService.ts'; export default defineEvent({ name: 'guildCreate', - run: async (app, _client, guild) => { + run: async (app, client, guild) => { const config = await getConfig(app, guild.id); await resetAccessFailures(app, config); app.logger.info('Bot added to guild', { guild: guild.id, name: guild.name }); + // Members already pending screening when the bot arrives have no recorded state yet. + await syncGuildScreening(app, client, guild); }, }); diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts index dc7aaa16..2a4143f0 100644 --- a/src/services/DataCheckService.ts +++ b/src/services/DataCheckService.ts @@ -1,8 +1,42 @@ +/* + * DATA RETENTION SPEC + * + * Persisted state: + * ServerConfigs one row per guild; accessFailureCount / firstAccessFailureAt count consecutive + * "gone" answers (Unknown Guild / Missing Access) from the scheduled data check. + * UserProfiles per (guild, user) member data, deleted when the member leaves. + * + * Deletion paths and their triggers (nothing else may delete a guild's data): + * guildDelete Discord says the bot was removed purge the guild at once + * guildMemberRemove the member left delete that member's profile + * data check, gone guild fetch says gone, count >= N and purge the guild + * first failure >= G days ago (RetentionPolicy) + * data check, members profile row read BEFORE the member list delete those rows by row id + * was fetched, and its user is not in it + * Events: guildCreate resets the failure counters (and re-records pending screenings). + * + * Transitions of the data check, per config row (outcome of fetching the guild): + * ok reset counters, then clean departed members' profiles + * transient (any other error) change nothing and delete nothing + * gone count + 1; purge only past both thresholds, otherwise just record the count + * + * Invariants: + * 1. A transient error never changes counters or deletes data; only a definite "gone" answer, + * repeated over a grace period, can purge, and a guild the gateway still lists never is. + * 2. Background jobs never create rows: counters are written with updateExistingConfig, and the + * config is re-read per guild, so a guild purged by a concurrent guildDelete is skipped. + * purgeGuild is one transaction and idempotent, so racing purges are harmless. + * 3. Member cleanup never deletes a profile created after it read the profiles (joiners during + * the slow member fetch, or a member who left and rejoined), and never runs on an empty + * member list. + * 4. A failure in one guild is logged and does not stop the others. + */ + import type { Client } from 'discord.js'; import { type Snowflake } from 'discord.js'; import type { App } from '../app.ts'; -import { getConfigs, purgeGuild, updateExistingConfig } from './ConfigService.ts'; -import { deleteUsers, getServerProfiles } from './UserProfileService.ts'; +import { findConfig, getConfigs, purgeGuild, updateExistingConfig } from './ConfigService.ts'; +import { deleteProfiles, getServerProfiles } from './UserProfileService.ts'; import { type AccessOutcome, type RetentionPolicyConfig, @@ -20,9 +54,9 @@ const cleanMembers = async (app: App, client: Client, serverId: Snowflake): Prom app.logger.warn('Member list empty; skipping profile cleanup', { guild: serverId }); return; } - const departed = profiles.filter((p) => !members.has(p.userId)).map((p) => p.userId); + const departed = profiles.filter((p) => !members.has(p.userId)).map((p) => p.id); if (departed.length > 0) { - const removed = await deleteUsers(app.db, serverId, departed); + const removed = await deleteProfiles(app.db, serverId, departed); app.logger.info('Removed profiles of departed members', { guild: serverId, removed }); } }; @@ -39,9 +73,15 @@ export const runDataCheck = async ( const now = app.clock(); logger.info('Running data check'); const configs = await getConfigs(app.db); - for (const config of configs) { - const guild = config.serverId; + for (const listed of configs) { + const guild = listed.serverId; try { + // Re-read: a guildDelete may have purged this guild since the list was taken. + const config = await findConfig(app, guild); + if (!config) { + logger.debug('Config removed before data check; skipping guild', { guild }); + continue; + } let outcome: AccessOutcome = 'ok'; try { await client.guilds.fetch(guild); @@ -61,6 +101,11 @@ export const runDataCheck = async ( ); if (next.purge) { + if (client.guilds.cache.has(guild)) { + // The gateway says the bot is in this guild, so the fetch result was stale. + logger.warn('Guild is in the gateway cache; not purging', { guild }); + continue; + } const result = await purgeGuild(app, guild); logger.warn('Purged guild after sustained loss of access', { guild, diff --git a/src/services/ScreeningService.ts b/src/services/ScreeningService.ts index 2a1fdf43..4958fa26 100644 --- a/src/services/ScreeningService.ts +++ b/src/services/ScreeningService.ts @@ -1,4 +1,4 @@ -import type { Client, GuildMember } from 'discord.js'; +import type { Client, Guild, GuildMember } from 'discord.js'; import type { App } from '../app.ts'; import { getConfig } from './ConfigService.ts'; import { onboard } from './OnboardingService.ts'; @@ -31,42 +31,57 @@ export async function currentOnboardingState( return getOnboardingState(app.db, member.guild.id, member.id); } +export interface SyncResult { + recorded: number; + caughtUp: number; +} + /** - * Startup reconciliation for events missed while offline: records members currently pending - * screening (so a later update with an unknown previous state can be trusted), and onboards members - * recorded as pending whose screening has since ended. + * Reconciles one guild's members with the recorded screening state, for events missed while the + * bot was offline or not yet in the guild: records members currently pending screening (so a later + * update with an unknown previous state can be trusted), and onboards members recorded as pending + * whose screening has since ended. Failures are logged and leave the state as it was. */ -export async function syncScreeningState( +export async function syncGuildScreening( app: App, client: Client, -): Promise<{ recorded: number; caughtUp: number }> { - let recorded = 0; - let caughtUp = 0; - for (const guild of client.guilds.cache.values()) { - try { - const members = await guild.members.fetch(); - for (const member of members.values()) { - // A guildDelete during the sweep purges this guild's rows; never recreate them. - if (member.user.bot || !client.guilds.cache.has(guild.id)) { - continue; + guild: Guild, +): Promise { + const result: SyncResult = { recorded: 0, caughtUp: 0 }; + try { + const members = await guild.members.fetch(); + for (const member of members.values()) { + // A guildDelete during the sweep purges this guild's rows; never recreate them. + if (member.user.bot || !client.guilds.cache.has(guild.id)) { + continue; + } + const state = await currentOnboardingState(app, member); + if (member.pending === true) { + if (state.screeningPendingAt === null && state.onboardedAt === null) { + await recordScreeningPending(app.db, guild.id, member.id, app.clock()); + result.recorded++; } - const state = await currentOnboardingState(app, member); - if (member.pending === true) { - if (state.screeningPendingAt === null && state.onboardedAt === null) { - await recordScreeningPending(app.db, guild.id, member.id, app.clock()); - recorded++; - } - } else if (state.screeningPendingAt !== null && state.onboardedAt === null) { - const config = await getConfig(app, guild.id); - if (await onboard(app, client, member, config)) { - caughtUp++; - } + } else if (state.screeningPendingAt !== null && state.onboardedAt === null) { + const config = await getConfig(app, guild.id); + if (await onboard(app, client, member, config)) { + result.caughtUp++; } } - } catch (error) { - app.logger.error('Could not sync screening state', { guild: guild.id }, error); } + } catch (error) { + app.logger.error('Could not sync screening state', { guild: guild.id }, error); + } + return result; +} + +/** Startup reconciliation of every guild the bot is in. */ +export async function syncScreeningState(app: App, client: Client): Promise { + const total: SyncResult = { recorded: 0, caughtUp: 0 }; + for (const guild of client.guilds.cache.values()) { + const result = await syncGuildScreening(app, client, guild); + total.recorded += result.recorded; + total.caughtUp += result.caughtUp; } - app.logger.info('Synced screening state', { recorded, caughtUp }); - return { recorded, caughtUp }; + app.logger.info('Synced screening state', { ...total }); + return total; } diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts index 357dc53b..038d64b2 100644 --- a/src/services/UserProfileService.ts +++ b/src/services/UserProfileService.ts @@ -62,20 +62,17 @@ export async function clearBirthday( return result.changes > 0; } -export async function deleteUsers( - db: Db, - serverId: Snowflake, - userIds: Snowflake[], -): Promise { +/** + * Deletes profiles by row id. A row id is never reused, so a profile created after the ids were + * read (a member who left and rejoined) is not deleted along with the stale row. + */ +export async function deleteProfiles(db: Db, serverId: Snowflake, ids: number[]): Promise { let removed = 0; - for (let i = 0; i < userIds.length; i += 500) { + for (let i = 0; i < ids.length; i += 500) { removed += db .delete(userProfiles) .where( - and( - eq(userProfiles.serverId, serverId), - inArray(userProfiles.userId, userIds.slice(i, i + 500)), - ), + and(eq(userProfiles.serverId, serverId), inArray(userProfiles.id, ids.slice(i, i + 500))), ) .run().changes; } @@ -125,11 +122,6 @@ export async function deleteUser( return db.delete(userProfiles).where(forUser(serverId, userId)).returning().get() ?? null; } -export async function deleteUsersByServer(db: Db, serverId: Snowflake): Promise { - const result = db.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); - return result.changes > 0; -} - /** * Begins a new membership: forgets every state recorded for a previous one. Pass `pending` when * the member is awaiting screening. diff --git a/tests/datacheck/DataCheckService.test.ts b/tests/datacheck/DataCheckService.test.ts deleted file mode 100644 index 24f69dbe..00000000 --- a/tests/datacheck/DataCheckService.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, beforeEach, test } from 'node:test'; -import type { ServerConfig } from '../../src/db/schema.ts'; -import { runDataCheck } from '../../src/services/DataCheckService.ts'; -import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; -import { createTestApp } from '../helpers/app.ts'; -import { dbFixtures } from '../helpers/db.ts'; - -const policy = { minFailures: 3, graceDays: 7 }; -const DAY = 24 * 60 * 60 * 1000; -const t0 = new Date('2027-01-01T00:00:00Z'); - -const base = createTestApp(); -const { - allProfiles, - clearConfigs, - clearProfiles, - countProfiles, - createConfig, - createConfigs, - createProfiles, - findConfig, -} = dbFixtures(base); -const run = (client: ReturnType, now: Date, p = policy): Promise => - runDataCheck(createTestApp({ db: base.db, clock: () => now }), client, p); - -after(() => { - base.close(); -}); - -beforeEach(() => { - clearProfiles(); - clearConfigs(); -}); - -const reload = (id: string): ServerConfig | null => findConfig(id); - -test('transient errors never purge or change counters', async () => { - createConfig({ serverId: 'g1', accessFailureCount: 5, firstAccessFailureAt: t0 }); - createProfiles([{ serverId: 'g1', userId: 'a' }]); - const client = fakeClient({ guilds: { g1: apiError(500) } }); - await run(client, new Date(t0.getTime() + 30 * DAY), policy); - const config = reload('g1'); - assert.equal(config?.accessFailureCount, 5); - assert.equal(countProfiles(), 1); -}); - -test('gone guild is purged only after min failures and grace period', async () => { - createConfig({ serverId: 'g1' }); - createProfiles([{ serverId: 'g1', userId: 'a' }]); - const client = fakeClient({ guilds: { g1: apiError(50001) } }); - - await run(client, t0, policy); - await run(client, new Date(t0.getTime() + 3 * DAY), policy); - await run(client, new Date(t0.getTime() + 6 * DAY), policy); - const config = reload('g1'); - assert.equal(config?.accessFailureCount, 3); - assert.equal(countProfiles(), 1); - - await run(client, new Date(t0.getTime() + 7 * DAY), policy); - assert.equal(reload('g1'), null); - assert.equal(countProfiles(), 0); -}); - -test('guild missing from the client entirely counts as gone', async () => { - createConfig({ serverId: 'g1' }); - await run(fakeClient(), t0, policy); - assert.equal(reload('g1')?.accessFailureCount, 1); -}); - -test('a successful fetch resets counters', async () => { - createConfig({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); - const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); - await run(fakeClient({ guilds: { g1: guild } }), new Date(t0.getTime() + DAY), policy); - const config = reload('g1'); - assert.equal(config?.accessFailureCount, 0); - assert.equal(config?.firstAccessFailureAt, null); -}); - -test('profiles of departed members are deleted, including non-birthday ones', async () => { - createConfig({ serverId: 'g1' }); - createProfiles([ - { serverId: 'g1', userId: 'here' }, - { serverId: 'g1', userId: 'gone-plain' }, - { serverId: 'g1', userId: 'gone-bday', birthdayMonth: 1, birthdayDay: 1 }, - { serverId: 'other', userId: 'gone-plain' }, - ]); - const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['here'] }); - await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - const remaining = allProfiles() - .map((p) => `${p.serverId}/${p.userId}`) - .sort(); - assert.deepEqual(remaining, ['g1/here', 'other/gone-plain']); -}); - -test('an empty member list does not wipe profiles', async () => { - createConfig({ serverId: 'g1' }); - createProfiles([{ serverId: 'g1', userId: 'a' }]); - const guild = fakeGuildWithMembers({ id: 'g1', memberIds: [] }); - await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - assert.equal(countProfiles(), 1); -}); - -test('one guild throwing does not stop the others', async () => { - createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); - createProfiles([ - { serverId: 'g1', userId: 'x' }, - { serverId: 'g2', userId: 'x' }, - ]); - const broken = fakeGuildWithMembers({ - id: 'g1', - memberIds: ['a'], - membersFetchError: new Error('members unavailable'), - }); - const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); - await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0, policy); - const remaining = allProfiles().map((p) => p.serverId); - assert.deepEqual(remaining, ['g1']); -}); - -test('a profile created while the member list is being fetched is not deleted', async () => { - createConfig({ serverId: 'g1' }); - createProfiles([ - { serverId: 'g1', userId: 'a' }, - { serverId: 'g1', userId: 'gone' }, - ]); - const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); - const fetchMembers = guild.members.fetch.bind(guild.members); - guild.members.fetch = async (id?: string) => { - const result = await fetchMembers(id); - // The member joins after the list was assembled but before the check reads the database. - createProfiles([{ serverId: 'g1', userId: 'late-joiner' }]); - return result; - }; - await run(fakeClient({ guilds: { g1: guild } }), t0, policy); - assert.deepEqual( - allProfiles() - .map((p) => p.userId) - .sort(), - ['a', 'late-joiner'], - ); -}); - -test('a guild purged mid-run does not get its config recreated', async () => { - createConfig({ serverId: 'g1' }); - const client = fakeClient({ guilds: { g1: apiError(50001) } }); - const fetchGuild = client.guilds.fetch.bind(client.guilds); - client.guilds.fetch = (async (id: string) => { - clearConfigs(); - return fetchGuild(id); - }) as typeof client.guilds.fetch; - await run(client, t0, policy); - assert.equal(reload('g1'), null); -}); diff --git a/tests/datacheck/stateMachine.test.ts b/tests/datacheck/stateMachine.test.ts new file mode 100644 index 00000000..e3b86afb --- /dev/null +++ b/tests/datacheck/stateMachine.test.ts @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { Collection, type Client, type Guild } from 'discord.js'; +import guildCreate from '../../src/events/guildCreate.ts'; +import guildDelete from '../../src/events/guildDelete.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import { runDataCheck } from '../../src/services/DataCheckService.ts'; +import { stub } from '../fakes/discord.ts'; +import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const policy = { minFailures: 3, graceDays: 7 }; +const DAY = 24 * 60 * 60 * 1000; +const t0 = new Date('2027-01-01T00:00:00Z'); +const at = (days: number): Date => new Date(t0.getTime() + days * DAY); + +const base = createTestApp(); +const { + allProfiles, + clearConfigs, + clearProfiles, + countProfiles, + createConfig, + createConfigs, + createProfiles, + findConfig, +} = dbFixtures(base); +const run = (client: ReturnType, now: Date): Promise => + runDataCheck(createTestApp({ db: base.db, clock: () => now }), client, policy); + +after(() => { + base.close(); +}); + +beforeEach(() => { + clearProfiles(); + clearConfigs(); +}); + +type Outcome = 'ok' | 'missing' | number; + +interface Row { + name: string; + /** Stored failure counters before the run. */ + counters?: { count: number; first: Date | null }; + /** What fetching the guild returns: ok, an API error code, or "not found at all". */ + fetch: Outcome; + /** Fetching the guild fails but the gateway cache still lists it (stale fetch). */ + cached?: boolean; + members?: string[]; + membersFail?: boolean; + profiles?: string[]; + days: number; + /** Expected config afterwards: null when purged. */ + config: { count: number; first: Date | null } | null; + /** Expected remaining profile user ids. */ + remaining: string[]; +} + +const reset = { count: 0, first: null }; +const rows: Row[] = [ + { + name: 'transient error: nothing changes, however long it lasts', + counters: { count: 5, first: t0 }, + fetch: 500, + profiles: ['a'], + days: 30, + config: { count: 5, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone, first time: counts but keeps everything', + fetch: 50001, + profiles: ['a'], + days: 0, + config: { count: 1, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone, enough failures but inside the grace period: kept', + counters: { count: 2, first: t0 }, + fetch: 50001, + profiles: ['a'], + days: 6, + config: { count: 3, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone, grace period over but too few failures: kept', + counters: { count: 1, first: t0 }, + fetch: 50001, + profiles: ['a'], + days: 30, + config: { count: 2, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone past both thresholds: purged with its profiles', + counters: { count: 2, first: t0 }, + fetch: 50001, + profiles: ['a'], + days: 7, + config: null, + remaining: [], + }, + { + name: 'Unknown Guild counts as gone', + counters: { count: 2, first: t0 }, + fetch: 10004, + profiles: ['a'], + days: 7, + config: null, + remaining: [], + }, + { + name: 'guild missing from the client entirely counts as gone', + fetch: 'missing', + days: 0, + config: { count: 1, first: t0 }, + remaining: [], + }, + { + name: 'gone but the gateway still lists the guild: never purged', + counters: { count: 2, first: t0 }, + fetch: 50001, + cached: true, + profiles: ['a'], + days: 30, + config: { count: 2, first: t0 }, + remaining: ['a'], + }, + { + name: 'ok resets the counters', + counters: { count: 2, first: t0 }, + fetch: 'ok', + members: ['a'], + days: 1, + config: reset, + remaining: [], + }, + { + name: 'ok deletes departed members, including non-birthday profiles', + fetch: 'ok', + members: ['here'], + profiles: ['here', 'gone-plain', 'gone-bday'], + days: 0, + config: reset, + remaining: ['here'], + }, + { + name: 'ok with an empty member list deletes nothing', + fetch: 'ok', + members: [], + profiles: ['a'], + days: 0, + config: reset, + remaining: ['a'], + }, + { + name: 'ok but the member fetch fails: nothing deleted, counters still reset', + counters: { count: 1, first: t0 }, + fetch: 'ok', + members: ['a'], + membersFail: true, + profiles: ['x'], + days: 0, + config: reset, + remaining: ['x'], + }, +]; + +describe('data check', () => { + for (const row of rows) { + test(row.name, async () => { + createConfig({ + serverId: 'g1', + accessFailureCount: row.counters?.count ?? 0, + firstAccessFailureAt: row.counters?.first ?? null, + }); + createProfiles([ + ...(row.profiles ?? []).map((userId) => ({ + serverId: 'g1', + userId, + ...(userId === 'gone-bday' ? { birthdayMonth: 1, birthdayDay: 1 } : {}), + })), + { serverId: 'other', userId: 'gone-plain' }, + ]); + const guilds: Record | Error> = + row.fetch === 'ok' + ? { + g1: fakeGuildWithMembers({ + id: 'g1', + memberIds: row.members ?? [], + ...(row.membersFail ? { membersFetchError: new Error('members unavailable') } : {}), + }), + } + : row.fetch === 'missing' + ? {} + : { g1: apiError(row.fetch) }; + const client = fakeClient({ guilds }); + if (row.cached) { + client.guilds.cache.set('g1', stub({ id: 'g1' })); + } + await run(client, at(row.days)); + + const config = findConfig('g1'); + if (row.config === null) { + assert.equal(config, null, 'config purged'); + } else { + assert.equal(config?.accessFailureCount, row.config.count); + assert.deepEqual(config?.firstAccessFailureAt ?? null, row.config.first); + } + assert.deepEqual( + allProfiles() + .filter((p) => p.serverId === 'g1') + .map((p) => p.userId) + .sort(), + [...row.remaining].sort(), + ); + assert.equal( + allProfiles().filter((p) => p.serverId === 'other').length, + 1, + 'other guilds are untouched', + ); + }); + } + + test('one guild throwing does not stop the others', async () => { + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + createProfiles([ + { serverId: 'g1', userId: 'x' }, + { serverId: 'g2', userId: 'x' }, + ]); + const broken = fakeGuildWithMembers({ + id: 'g1', + memberIds: ['a'], + membersFetchError: new Error('members unavailable'), + }); + const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); + await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0); + assert.deepEqual( + allProfiles().map((p) => p.serverId), + ['g1'], + ); + }); +}); + +describe('data check racing with other events', () => { + test('a profile created while the member list is being fetched is not deleted', async () => { + createConfig({ serverId: 'g1' }); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g1', userId: 'gone' }, + ]); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); + const fetchMembers = guild.members.fetch.bind(guild.members); + guild.members.fetch = async (id?: string) => { + const result = await fetchMembers(id); + createProfiles([{ serverId: 'g1', userId: 'late-joiner' }]); + return result; + }; + await run(fakeClient({ guilds: { g1: guild } }), t0); + assert.deepEqual( + allProfiles() + .map((p) => p.userId) + .sort(), + ['a', 'late-joiner'], + ); + }); + + test('a member who left and rejoined during the fetch keeps their new profile', async () => { + createConfig({ serverId: 'g1' }); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g1', userId: 'rejoiner', birthdayMonth: 2, birthdayDay: 3 }, + ]); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); + const fetchMembers = guild.members.fetch.bind(guild.members); + guild.members.fetch = async (id?: string) => { + const result = await fetchMembers(id); // assembled while they were away + base.db.$client.exec("DELETE FROM `UserProfiles` WHERE userId = 'rejoiner'"); // left + createProfiles([{ serverId: 'g1', userId: 'rejoiner', birthdayMonth: 4, birthdayDay: 5 }]); + return result; + }; + await run(fakeClient({ guilds: { g1: guild } }), t0); + const kept = allProfiles().find((p) => p.userId === 'rejoiner'); + assert.equal(kept?.birthdayMonth, 4); + }); + + test('a guild purged mid-run does not get its config recreated', async () => { + createConfig({ serverId: 'g1' }); + const client = fakeClient({ guilds: { g1: apiError(50001) } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + clearConfigs(); + return fetchGuild(id); + }) as typeof client.guilds.fetch; + await run(client, t0); + assert.equal(findConfig('g1'), null); + }); + + test('a guild purged by guildDelete before its turn is skipped', async () => { + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + const g2 = fakeGuildWithMembers({ id: 'g2', memberIds: [] }); + const client = fakeClient({ guilds: { g1: apiError(500), g2 } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + const result = await fetchGuild(id).catch(() => g2); // first guild done... + clearConfigs(); // ...then guildDelete purges the rest + return result; + }) as typeof client.guilds.fetch; + await run(client, t0); + assert.equal(findConfig('g2'), null, 'g2 was not recreated by the job'); + }); + + test('a concurrent guildDelete purge and a data check purge do not conflict', async () => { + createConfig({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); + createProfiles([{ serverId: 'g1', userId: 'a' }]); + const client = fakeClient({ guilds: { g1: apiError(50001) } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + await guildDelete.run( + base, + stub(client), + stub({ id: 'g1', name: 'G1' }), + ); + return fetchGuild(id); + }) as typeof client.guilds.fetch; + await run(client, at(30)); + assert.equal(findConfig('g1'), null); + assert.equal(countProfiles(), 0); + }); +}); + +describe('guild lifecycle events', () => { + test('guildDelete purges the guild immediately, and is idempotent', async () => { + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g2', userId: 'a' }, + ]); + const guild = stub({ id: 'g1', name: 'G1' }); + await guildDelete.run(base, stub({}), guild); + await guildDelete.run(base, stub({}), guild); + assert.equal(findConfig('g1'), null); + assert.ok(findConfig('g2')); + assert.deepEqual( + allProfiles().map((p) => p.serverId), + ['g2'], + ); + }); + + test('guildCreate creates the config and resets failure counters', async () => { + createConfig({ serverId: 'g1', accessFailureCount: 4, firstAccessFailureAt: t0 }); + const empty = { fetch: async () => new Collection() }; + const guild = stub({ id: 'g1', name: 'G1', members: empty }); + const client = stub({ + guilds: { cache: new Collection([['g1', guild]]) }, + }); + await guildCreate.run(base, client, guild); + assert.equal(findConfig('g1')?.accessFailureCount, 0); + assert.equal(findConfig('g1')?.firstAccessFailureAt, null); + + await guildCreate.run(base, client, stub({ id: 'g3', name: 'G3', members: empty })); + assert.ok(findConfig('g3')); + }); + + test('guildCreate records members already pending, and survives a member fetch failure', async () => { + const pending = { + id: 'p1', + guild: { id: 'g1' }, + user: { id: 'p1', bot: false }, + pending: true, + }; + const guild = stub({ + id: 'g1', + name: 'G1', + members: { fetch: async () => new Collection([['p1', pending]]) }, + }); + const client = stub({ + guilds: { cache: new Collection([['g1', guild]]) }, + }); + await guildCreate.run(base, client, guild); + const recorded = allProfiles().find((p) => p.userId === 'p1'); + assert.equal(recorded?.screeningPendingAt?.getTime(), base.clock().getTime()); + + const broken = stub({ + id: 'g2', + name: 'G2', + members: { + fetch: async () => { + throw new Error('timeout'); + }, + }, + }); + const other = stub({ guilds: { cache: new Collection() } }); + await guildCreate.run(base, other, broken); + assert.ok(findConfig('g2')); + }); +}); From 639645ab6e6133972ffbb306cdb8187df01c0b25 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:29:41 +0100 Subject: [PATCH 71/77] Keep welcome previews within Discord's message limit Add fitMessage to truncate user-configured welcome text with a note, and use it for welcome preview/get and the simulate onboard dry run. Co-Authored-By: Claude Sonnet 5.5 --- src/commands/admin/simulate.ts | 4 ++- src/commands/config/groups/welcome.ts | 17 +++++++---- src/utilities/format.ts | 14 +++++++++ tests/onboarding/commands.test.ts | 43 +++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/commands/admin/simulate.ts b/src/commands/admin/simulate.ts index 175a5536..196703d9 100644 --- a/src/commands/admin/simulate.ts +++ b/src/commands/admin/simulate.ts @@ -3,6 +3,7 @@ import { type CommandContext, defineCommand } from '../../framework/command.ts'; import { UserError } from '../../framework/errors.ts'; import { resolveMember } from '../../services/MemberResolver.ts'; import { auditJoin, complete, formatOnboardingSummary } from '../../services/OnboardingService.ts'; +import { fitMessage } from '../../utilities/format.ts'; const NO_MENTIONS = { parse: [] }; @@ -58,8 +59,9 @@ export default defineCommand({ payload.imageError ? `\nImage failed: ${payload.imageError}` : '' }` : ''; + const head = `Dry run for ${target.toString()} (nothing was changed or sent):\n${formatOnboardingSummary(result)}`; await ctx.reply({ - content: `Dry run for ${target.toString()} (nothing was changed or sent):\n${formatOnboardingSummary(result)}${welcome}`, + content: fitMessage(head, welcome), files: payload?.image ? [payload.image] : undefined, allowedMentions: NO_MENTIONS, }); diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index 871ae30a..27c612f1 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -6,6 +6,7 @@ import { expandWelcomeMessage, MAX_WELCOME_LENGTH, } from '../../../services/OnboardingService.ts'; +import { fitMessage } from '../../../utilities/format.ts'; import { isHttpUrl, render } from '../../../services/WelcomeImage.ts'; export const MAX_WELCOME_MESSAGE_LENGTH = 1500; @@ -64,11 +65,14 @@ const setImage = async (ctx: CommandContext) => { const get = async (ctx: CommandContext) => { const { welcomeMessage, welcomeMessageBackgroundUrl } = ctx.config; + const image = `\nWelcome image: ${welcomeMessageBackgroundUrl ?? 'Not set'}`; await ctx.reply({ - content: [ - `Welcome message: ${welcomeMessage ? `\n${welcomeMessage}` : 'Not set'}`, - `Welcome image: ${welcomeMessageBackgroundUrl ?? 'Not set'}`, - ].join('\n'), + content: + fitMessage( + 'Welcome message: ', + welcomeMessage ? `\n${welcomeMessage}` : 'Not set', + 2000 - image.length, + ) + image, allowedMentions: NO_MENTIONS, }); }; @@ -97,7 +101,10 @@ const preview = async (ctx: CommandContext) => { } const notes = payload.imageError ? `\n\nThe image failed to render: ${payload.imageError}` : ''; await ctx.reply({ - content: `Preview of the welcome message for you:\n\n${payload.content ?? '*(no text)*'}${notes}`, + content: fitMessage( + 'Preview of the welcome message for you:\n\n', + `${payload.content ?? '*(no text)*'}${notes}`, + ), files: payload.image ? [payload.image] : undefined, allowedMentions: NO_MENTIONS, }); diff --git a/src/utilities/format.ts b/src/utilities/format.ts index 35e6366f..df30a982 100644 --- a/src/utilities/format.ts +++ b/src/utilities/format.ts @@ -36,3 +36,17 @@ export function formatUptime(ms: number | null): string { return '0m'; } + +const TRUNCATION_NOTE = '\n*(preview truncated)*'; + +/** + * Joins a prefix and user-supplied body so the result never exceeds Discord's message limit. + * An over-long body is cut with an ellipsis and followed by a truncation note. + */ +export function fitMessage(prefix: string, body: string, max = 2000): string { + if (prefix.length + body.length <= max) { + return prefix + body; + } + const room = Math.max(0, max - prefix.length - TRUNCATION_NOTE.length - 1); + return `${prefix}${body.slice(0, room)}…${TRUNCATION_NOTE}`; +} diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index cfebe54e..e1a9abdd 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -13,6 +13,7 @@ import WelcomeGroup, { validateImageUrl, validateWelcomeMessage, } from '../../src/commands/config/groups/welcome.ts'; +import SimulateCommand from '../../src/commands/admin/simulate.ts'; import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; import { createTestApp } from '../helpers/app.ts'; import { nth, rejectsUserError } from '../helpers/assertions.ts'; @@ -208,3 +209,45 @@ test('newroles set and clear defer before the audit send', async () => { assert.equal(env.auditSends.length, 1, sub); } }); + +const TRUNCATED = /\*\(preview truncated\)\*$/; + +test('welcome preview is truncated to fit Discord limits', async () => { + const env = fakeOnboarding({ + config: { welcomeMessage: 'x'.repeat(2000), systemMessagesEnabled: true }, + }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'preview')(ctx); + const content = nth(replies).content!; + assert.ok(content.length <= 2000); + assert.match(content, TRUNCATED); +}); + +test('welcome get is truncated for long legacy messages', async () => { + const env = fakeOnboarding({ config: { welcomeMessage: 'y'.repeat(2000) } }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'get')(ctx); + const content = nth(replies).content!; + assert.ok(content.length <= 2000); + assert.match(content, /Welcome image: Not set$/); + assert.match(content, /preview truncated/); +}); + +test('simulate onboard truncates a long welcome preview and keeps short ones intact', async () => { + const run = SimulateCommand.resolve(fakeInteraction({ subcommand: 'onboard' }).interaction)!.run; + const long = fakeOnboarding({ + config: { welcomeMessage: 'z'.repeat(2000), systemMessagesEnabled: true }, + }); + const a = fakeCommandContext(app, {}, long); + await run(a.ctx); + assert.ok(nth(a.replies).content!.length <= 2000); + assert.match(nth(a.replies).content!, TRUNCATED); + + const short = fakeOnboarding({ + config: { welcomeMessage: 'hello there', systemMessagesEnabled: true }, + }); + const b = fakeCommandContext(app, {}, short); + await run(b.ctx); + assert.match(nth(b.replies).content!, /hello there/); + assert.doesNotMatch(nth(b.replies).content!, /truncated/); +}); From 95237e472c2903876e7b46c3ec062c4c7de122af Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:30:00 +0100 Subject: [PATCH 72/77] Report member-list gateway rate limits to the admin Wrap the full member fetch in noroles and rolesince so a discord.js GatewayRateLimitError becomes a UserError with the retry time. Co-Authored-By: Claude Sonnet 5.5 --- src/commands/admin/noroles.ts | 3 +- src/commands/admin/rolesince.ts | 3 +- src/services/MemberResolver.ts | 21 ++++++++++++++ tests/moderation/memberListing.test.ts | 38 ++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/commands/admin/noroles.ts b/src/commands/admin/noroles.ts index 8045a3fd..c93d9caf 100644 --- a/src/commands/admin/noroles.ts +++ b/src/commands/admin/noroles.ts @@ -1,4 +1,5 @@ import { defineCommand } from '../../framework/command.ts'; +import { fetchAllMembers } from '../../services/MemberResolver.ts'; import { buildMemberListing } from '../../services/MemberListing.ts'; export default defineCommand({ @@ -7,7 +8,7 @@ export default defineCommand({ adminOnly: true, defer: 'ephemeral', run: async (ctx) => { - const all = await ctx.guild.members.fetch(); + const all = await fetchAllMembers(ctx.guild); const members = [...all.values()].filter( (member) => !member.user.bot && member.roles.cache.size === 1, ); diff --git a/src/commands/admin/rolesince.ts b/src/commands/admin/rolesince.ts index 4f674a09..cbed46bb 100644 --- a/src/commands/admin/rolesince.ts +++ b/src/commands/admin/rolesince.ts @@ -1,4 +1,5 @@ import { defineCommand } from '../../framework/command.ts'; +import { fetchAllMembers } from '../../services/MemberResolver.ts'; import { buildMemberListing, wholeDaysSinceJoin } from '../../services/MemberListing.ts'; export default defineCommand({ @@ -22,7 +23,7 @@ export default defineCommand({ const days = ctx.interaction.options.getInteger('days') ?? 0; const now = ctx.app.clock().getTime(); - await ctx.guild.members.fetch(); + await fetchAllMembers(ctx.guild); const members = [...role.members.values()].filter((member) => { if (member.user.bot) { return false; diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts index 227d1e62..b345da93 100644 --- a/src/services/MemberResolver.ts +++ b/src/services/MemberResolver.ts @@ -1,5 +1,6 @@ import type { Guild, GuildMember, User } from 'discord.js'; import { type GuildTextBasedChannel, type Snowflake } from 'discord.js'; +import { UserError } from '../framework/errors.ts'; const UNKNOWN_MEMBER = 10007; const UNKNOWN_USER = 10013; @@ -38,3 +39,23 @@ export const resolveTextChannel = async ( } return channel; }; + +/** + * Fetches the full member list. A gateway rate limit (discord.js GatewayRateLimitError) becomes a + * UserError with the retry time; any other failure propagates unchanged. + */ +export const fetchAllMembers = async (guild: Guild) => { + try { + return await guild.members.fetch(); + } catch (error) { + if (error instanceof Error && error.name === 'GatewayRateLimitError') { + const retry = Number((error as { data?: { retry_after?: unknown } }).data?.retry_after); + const wait = + Number.isFinite(retry) && retry > 0 ? `${Math.ceil(retry)} seconds` : 'a few seconds'; + throw new UserError(`Discord is rate-limiting member lookups — try again in ${wait}.`, { + cause: error, + }); + } + throw error; + } +}; diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts index 7f433faf..c287d7e0 100644 --- a/tests/moderation/memberListing.test.ts +++ b/tests/moderation/memberListing.test.ts @@ -4,6 +4,8 @@ import { Collection } from 'discord.js'; import noroles from '../../src/commands/admin/noroles.ts'; import rolesince from '../../src/commands/admin/rolesince.ts'; import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing.ts'; +import { fetchAllMembers } from '../../src/services/MemberResolver.ts'; +import { UserError } from '../../src/framework/errors.ts'; import { createTestApp, FIXED_NOW } from '../helpers/app.ts'; import { fakeInteraction } from '../fakes/interaction.ts'; import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages.ts'; @@ -113,3 +115,39 @@ test('noroles lists only non-bot members with just the everyone role', async () assert.doesNotMatch(content, /<@2>/); assert.doesNotMatch(content, /<@3>/); }); + +const rateLimitError = (retryAfter?: number) => + Object.assign(new Error('rate limited'), { + name: 'GatewayRateLimitError', + data: { opcode: 8, retry_after: retryAfter }, + }); +const guildFetching = (error: Error) => ({ members: { fetch: async () => Promise.reject(error) } }); + +test('fetchAllMembers turns a gateway rate limit into a UserError with whole seconds', async () => { + await assert.rejects( + fetchAllMembers(guildFetching(rateLimitError(2.3)) as never), + (error: unknown) => + error instanceof UserError && + /rate-limiting member lookups.*in 3 seconds\./.test(error.message), + ); + await assert.rejects( + fetchAllMembers(guildFetching(rateLimitError()) as never), + (error: unknown) => error instanceof UserError && /a few seconds/.test(error.message), + ); +}); + +test('fetchAllMembers propagates other errors unchanged', async () => { + const boom = new Error('boom'); + await assert.rejects(fetchAllMembers(guildFetching(boom) as never), (error) => error === boom); +}); + +test('noroles replies with the rate-limit message', async () => { + const guild = { + id: 'guild-1', + members: { fetch: async () => Promise.reject(rateLimitError(5)) }, + }; + const sink = fakeAuditClient('a', [noroles]); + const fake = fakeInteraction({ commandName: 'noroles', guild }); + await runSlash(app, sink.client, fake); + assert.match(last(fake.calls).payload.content as string, /try again in 5 seconds/); +}); From 2036557b4a9e9ba29087b2392f2ae58addad1d9f Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:30:22 +0100 Subject: [PATCH 73/77] Cap the welcome image download while reading it Reject on an oversized content-length header, then stream the body and cancel as soon as 10 MB is exceeded, all under the existing 10s timeout. Co-Authored-By: Claude Sonnet 5.5 --- src/services/WelcomeImage.ts | 48 ++++++++++++++++------ tests/onboarding/WelcomeImage.test.ts | 57 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts index ce049f8a..6b5f69dc 100644 --- a/src/services/WelcomeImage.ts +++ b/src/services/WelcomeImage.ts @@ -45,20 +45,50 @@ export const applyText = (canvas: Canvas, text: string, baseSize: number): strin return ctx.font; }; +class DownloadError extends Error {} + const downloadImage = async (url: string): Promise => { if (!isHttpUrl(url)) { throw new Error('Image URL must start with http:// or https://'); } - let response: Response; + const tooLarge = () => new DownloadError('The image is larger than 10 MB'); + // One signal covers connecting and reading the whole body. + const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS); try { - response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + const response = await fetch(url, { signal }); + if (!response.ok) { + await response.body?.cancel(); + throw new DownloadError(`Could not download the image (HTTP ${response.status})`); + } + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) { + await response.body?.cancel(); + throw tooLarge(); + } + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body?.getReader(); + while (reader) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.length; + if (total > MAX_DOWNLOAD_BYTES) { + await reader.cancel(); + throw tooLarge(); + } + chunks.push(value); + } + return Buffer.concat(chunks); } catch (error) { + if (error instanceof DownloadError) { + throw error; + } if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { throw new Error( `Downloading the image timed out after ${DOWNLOAD_TIMEOUT_MS / 1000} seconds`, - { - cause: error, - }, + { cause: error }, ); } throw new Error( @@ -66,14 +96,6 @@ const downloadImage = async (url: string): Promise => { { cause: error }, ); } - if (!response.ok) { - throw new Error(`Could not download the image (HTTP ${response.status})`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - if (buffer.length > MAX_DOWNLOAD_BYTES) { - throw new Error('The image is larger than 10 MB'); - } - return buffer; }; export type WelcomeImageRenderer = ( diff --git a/tests/onboarding/WelcomeImage.test.ts b/tests/onboarding/WelcomeImage.test.ts index 55a3f25f..7fc1dd74 100644 --- a/tests/onboarding/WelcomeImage.test.ts +++ b/tests/onboarding/WelcomeImage.test.ts @@ -114,3 +114,60 @@ test('WelcomeImage.render draws avatar with translucent backdrop and thin ring', app.close(); } }); + +const serve = async (handler: Parameters[1]) => { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/img`, + close: () => { + server.closeAllConnections(); + server.close(); + }, + }; +}; + +const renderFrom = (url: string) => + render( + createTestApp(), + fakeMember('u', { displayName: 'U', joinedTimestamp: Date.now(), guild: { name: 'G' } }), + url, + ); + +test('render rejects a streamed image over 10 MB without content-length', async () => { + let written = 0; + const srv = await serve((_req, res) => { + res.writeHead(200, { 'Content-Type': 'image/png' }); + const chunk = Buffer.alloc(1024 * 1024); + const timer = setInterval(() => { + if (res.destroyed || written > 200 * 1024 * 1024) { + clearInterval(timer); + return; + } + written += chunk.length; + res.write(chunk); + }, 1); + res.on('close', () => clearInterval(timer)); + }); + try { + const started = Date.now(); + await assert.rejects(renderFrom(srv.url), /larger than 10 MB/); + assert.ok(Date.now() - started < 5000); + assert.ok(written < 100 * 1024 * 1024); + } finally { + srv.close(); + } +}); + +test('render rejects a declared content-length over 10 MB', async () => { + const srv = await serve((_req, res) => { + res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': 11 * 1024 * 1024 }); + res.write(Buffer.alloc(1024)); + }); + try { + await assert.rejects(renderFrom(srv.url), /larger than 10 MB/); + } finally { + srv.close(); + } +}); From 59d783400606acb96bd01a69c1b2777cf58edb67 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:30:51 +0100 Subject: [PATCH 74/77] Split large birthday announcements across messages Chunk the mention list so each announcement stays within Discord's 2000 character limit, with allowedMentions limited to each chunk. Co-Authored-By: Claude Sonnet 5.5 --- src/services/BirthdayService.ts | 24 ++++++++++++++++++++---- tests/birthdays/BirthdayService.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts index d1b64f45..052d0b38 100644 --- a/src/services/BirthdayService.ts +++ b/src/services/BirthdayService.ts @@ -259,12 +259,28 @@ const notifyGuild = async ( if (ids.length === 0) { return; } - await channel.send({ - content: `Happy Birthday to ${ids.map((id) => `<@${id}>`).join(', ')}!`, - allowedMentions: { users: ids }, - }); + // Split across messages so a big day never exceeds Discord's 2000 character limit. + const prefix = 'Happy Birthday to '; + let chunk: string[] = []; + const flush = async () => { + await channel.send({ + content: `${prefix}${chunk.map((id) => `<@${id}>`).join(', ')}!`, + allowedMentions: { users: chunk }, + }); + chunk = []; + }; + for (const id of ids) { + const next = [...chunk, id].map((u) => `<@${u}>`).join(', '); + if (chunk.length > 0 && prefix.length + next.length + 1 > MAX_MESSAGE_LENGTH) { + await flush(); + } + chunk.push(id); + } + await flush(); }; +const MAX_MESSAGE_LENGTH = 2000; + export const notifyBirthdays = async (app: App, client: Client): Promise => { app.logger.info('Sending birthday notifications'); const now = nowOf(app); diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts index 6bfe50e0..6b528756 100644 --- a/tests/birthdays/BirthdayService.test.ts +++ b/tests/birthdays/BirthdayService.test.ts @@ -118,3 +118,27 @@ test('notifyBirthdays: no announcements channel sends nothing', async () => { await notifyBirthdays(appAt('2027-06-01T09:00'), fakeClient({ guilds: { g1: guild } })); assert.equal(channel.sent.length, 0); }); + +test('notifyBirthdays: a large day is split into messages within the length limit', async () => { + makeConfig({ announcementsChannelId: 'ann' }); + const ids = Array.from({ length: 120 }, (_, i) => String(100000000000000000n + BigInt(i))); + createProfiles( + ids.map((userId) => ({ serverId: 'g1', userId, birthdayMonth: 6, birthdayDay: 1 })), + ); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ids, channels: [channel] }); + const client = fakeClient({ guilds: { g1: guild } }); + + await notifyBirthdays(appAt('2027-06-01T09:00'), client); + assert.ok(channel.sent.length > 1); + const seen: string[] = []; + for (const sent of channel.sent) { + const content = contentOf(sent); + assert.ok(content.length <= 2000); + assert.ok(content.startsWith('Happy Birthday to ')); + const mentioned = [...content.matchAll(/<@(\d+)>/g)].map((m) => m[1]!); + assert.deepEqual((sent.allowedMentions as { users: string[] }).users, mentioned); + seen.push(...mentioned); + } + assert.deepEqual([...seen].sort(), [...ids].sort()); +}); From cee635ae317c3a4dbe1f3135e4c4831b0fa86e25 Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:33:39 +0100 Subject: [PATCH 75/77] Fix README upgrade notes and examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated native dependencies note to mention both better-sqlite3 and @napi-rs/canvas - Clarified onboarding audit titles: "New member joined" and "Member completed onboarding" - Rewrote v2 → v3 command changes list with accurate details for each command - Fixed example code imports to include .ts extension (Node runs TS with ESM) - Fixed updateConfig signature in example code - Added notes about welcome preview/get truncation, rate-limit handling for /noroles and /rolesince, welcome image download cap, and birthday announcement splitting Co-Authored-By: Claude Haiku 4.5 --- README.md | 53 ++++++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 5e166d1d..b1fe2de8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Discord bot for the Irkallu server, built with discord.js and TypeScript. Prov 1. Clone the repository 2. Install dependencies: `npm ci` - - Install scripts are disabled via `.npmrc` because the only native dependency (`better-sqlite3`) ships prebuilt binaries; if a future dependency needs a postinstall step, run `npm rebuild ` or revisit this setting. + - Install scripts are disabled via `.npmrc` because native dependencies (`better-sqlite3` and `@napi-rs/canvas`) ship prebuilt binaries; if a future dependency needs a postinstall step, run `npm rebuild ` or revisit this setting. 3. Copy `.env.example` to `.env` and fill in `TOKEN` and `CLIENT_ID` from the [Discord Developer Portal](https://discord.com/developers/applications) 4. Start: `npm start` (Node runs the TypeScript source directly; there is no build step) @@ -115,13 +115,13 @@ Commands are organized into categories. Only **admin-only** commands require the ### Admin Commands -| Command | Description | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `/simulate join [member]` | Audit only: send the "member joined" audit for a member (no roles or welcome message). Defaults to you | -| `/simulate onboard [member]` | Dry run: show what onboarding would do (guest roles, welcome) without applying changes. Defaults to you | -| `/rolesince [days]` | List members in a role who joined the server at least N days ago | -| `/noroles` | List all members with no roles assigned (excluding bots) | -| `/post [content] [attachment]` | Post a simple message and/or file to a channel. Requires at least one of content or attachment | +| Command | Description | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/simulate join [member]` | Audit only: send the "member joined" audit for a member (no roles or welcome message). Defaults to you | +| `/simulate onboard [member]` | Dry run: show what onboarding would do (guest roles, welcome) without applying changes. Defaults to you | +| `/rolesince [days]` | List members in a role who joined the server at least N days ago. If Discord rate-limits member lookups, tells the admin to retry after N seconds | +| `/noroles` | List all members with no roles assigned (excluding bots). If Discord rate-limits member lookups, tells the admin to retry after N seconds | +| `/post [content] [attachment]` | Post a simple message and/or file to a channel. Requires at least one of content or attachment | ### Config Commands @@ -168,9 +168,9 @@ Configure a channel that automatically bans members who post in it: | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `set-message ` | Set the welcome message (max 1500 characters). Use `{member}` to mention the new member. Rejected if, after expanding every `{member}` to a mention, the message would exceed Discord's 2000-character limit | | `set-image ` | Set the background image for welcome images (http/https URL) | -| `get` | Show the welcome message and image URL | +| `get` | Show the welcome message and image URL (long messages are truncated to fit Discord's 2000-character limit) | | `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | -| `preview` | Preview the welcome message as it would be sent for you | +| `preview` | Preview the welcome message as it would be sent for you (long messages are truncated to fit Discord's 2000-character limit) | #### `/config newroles` — New Member Roles @@ -205,9 +205,9 @@ Toggle diagnostic audits for onboarding runs: When a member joins without screening enabled, or completes server screening: -1. **Audit**: A "member joined" audit is posted to the configured audit channel (if set) +1. **Audit**: A "New member joined" audit is posted to the configured audit channel (if set). When screening completes, a "Member completed onboarding" audit is posted. Servers without screening enabled only send the join audit. 2. **Guest Roles**: Configured new-member roles are assigned (if any are set) -3. **Welcome**: A welcome message is posted to the server's system channel, optionally with a background image (only if `/config sysmsgs` is enabled) +3. **Welcome**: A welcome message is posted to the server's system channel, optionally with a background image (only if `/config sysmsgs` is enabled; long messages are truncated to fit Discord's 2000-character limit) 4. **Debug**: If debug mode is enabled, a diagnostic summary is posted to the audit channel showing what succeeded, failed, or was skipped Roles are only assigned if: @@ -220,6 +220,8 @@ If a role cannot be assigned, the error is logged and the onboarding continues f **Onboarding Tracking**: The bot records when a member is pending membership screening (when they join pending, on any update showing them pending, and via a startup sweep that fetches each server's members after login). If the bot doesn't have the member's previous state cached, it onboards them only when a pending state was recorded and they haven't been onboarded yet; otherwise it skips (logged). Onboarding clears the pending marker and records `onboardedAt`, so nobody is onboarded twice. Members already in the server before the upgrade are never re-onboarded. Leaving the server deletes the profile, so a rejoin is onboarded again. Known limitation: a member who was already pending at deploy time and completes screening before the startup sweep records them, while not cached, won't be onboarded automatically (an admin can assign roles manually). +**Welcome Image**: The welcome image download is capped at 10 MB while streaming to prevent resource exhaustion. + ### Audit Log Member events are posted to the configured audit channel: @@ -247,7 +249,7 @@ Each message posted by a human (non-bot, non-webhook) in any channel increments Members can set their birthday using `/mybirthday set `. Birthdays are stored per-server. -**Birthday Announcements**: On the configured schedule (default 9:00 AM daily), the bot checks all servers and posts birthday announcements in the announcements channel for members with birthdays today. If a member's birthday is Feb 29 and it's not a leap year, they are celebrated on Feb 28 instead. +**Birthday Announcements**: On the configured schedule (default 9:00 AM daily), the bot checks all servers and posts birthday announcements in the announcements channel for members with birthdays today. If a member's birthday is Feb 29 and it's not a leap year, they are celebrated on Feb 28 instead. Birthday announcements are split across multiple messages if a single day's mentions would exceed Discord's 2000-character limit. **Birthday Calendar**: If configured, a calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job by editing the message. It is also refreshed when a member with a birthday leaves. If the message or channel is missing, or the bot lacks access, the refresh logs a warning and an admin must fix permissions or run `/config birthdays create` again; a temporary Discord error never clears the stored calendar. @@ -332,18 +334,19 @@ docker run --rm -v :/data alpine chown -R 1000:1000 /data Commands are re-registered once on the first start after upgrading. The command set changed in v3: -**Renamed subcommands**: +**Changed commands**: -- `/simulate screen` → `/simulate onboard` +- `/mybirthday` — v2 took `day` and `month` as options; v3 uses subcommands: `set ` / `clear` +- `/config welcome` — v2 had `set|get|clear` with a `content` choice (message/image); v3 has separate subcommands `set-message|set-image|get|clear|preview` +- `/config birthdays` — v2 had `create|sync`; v3 added `remove` +- `/config newroles` — v2 `set` took 1–2 roles; v3 `set` takes 1–3 roles +- `/simulate screen` — renamed to `/simulate onboard` -**New or changed subcommands**: +**Unchanged**: -- `/mybirthday set|clear` (unchanged) -- `/config welcome set-message|set-image|get|clear|preview` (unchanged) -- `/config newroles set` (now takes up to 3 roles) -- `/config birthdays create|sync|remove` (unchanged) -- `/config sysmsgs set |get` (changed from toggle) -- `/config debug set |get` (changed from toggle) +- `/config sysmsgs set |get` — both v2 and v3 use this syntax +- `/config debug set |get` — both v2 and v3 use this syntax +- `/config honeypot`, `/rolesince`, `/noroles`, `/post` — not in v2 ### Releases @@ -383,7 +386,7 @@ Slash commands are defined in `src/commands/`. Here's a minimal example: ```typescript // src/commands/info/hello.ts -import { defineCommand } from '../../framework/command'; +import { defineCommand } from '../../framework/command.ts'; export default defineCommand({ name: 'hello', @@ -418,7 +421,7 @@ channelSetting({ Then ensure the field is defined in `src/db/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. -**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/db/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `updateConfig(ctx.app.db, serverId, { field: value })`. +**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/db/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `updateConfig(ctx.app, serverId, { field: value })`. ### Adding an Event @@ -426,7 +429,7 @@ Events are defined in `src/events/`. Discord.js events are mapped automatically. ```typescript // src/events/myevent.ts -import { defineEvent } from '../framework/event'; +import { defineEvent } from '../framework/event.ts'; export default defineEvent({ name: 'messageCreate', // discord.js event name From 0b04df2bceed11a98558ed01dcc57c8db5ad32df Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:37:26 +0100 Subject: [PATCH 76/77] Fix welcome preview truncation edge cases Co-Authored-By: Claude Sonnet 5.5 --- src/commands/admin/simulate.ts | 9 ++---- src/commands/config/groups/welcome.ts | 17 ++++++++--- src/utilities/format.ts | 6 +++- tests/about.test.ts | 8 ++++- tests/onboarding/commands.test.ts | 42 ++++++++++++++++++++++++++- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/commands/admin/simulate.ts b/src/commands/admin/simulate.ts index 196703d9..b88492f2 100644 --- a/src/commands/admin/simulate.ts +++ b/src/commands/admin/simulate.ts @@ -54,12 +54,9 @@ export default defineCommand({ dryRun: true, }); const payload = result.welcomePayload; - const welcome = payload - ? `\n\nWelcome preview:\n${payload.content ?? '*(no text)*'}${ - payload.imageError ? `\nImage failed: ${payload.imageError}` : '' - }` - : ''; - const head = `Dry run for ${target.toString()} (nothing was changed or sent):\n${formatOnboardingSummary(result)}`; + const imageNote = payload?.imageError ? `\n\nImage failed: ${payload.imageError}` : ''; + const welcome = payload ? `\n\nWelcome preview:\n${payload.content ?? '*(no text)*'}` : ''; + const head = `Dry run for ${target.toString()} (nothing was changed or sent):\n${formatOnboardingSummary(result)}${imageNote}`; await ctx.reply({ content: fitMessage(head, welcome), files: payload?.image ? [payload.image] : undefined, diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts index 27c612f1..68b03405 100644 --- a/src/commands/config/groups/welcome.ts +++ b/src/commands/config/groups/welcome.ts @@ -28,6 +28,10 @@ export const validateImageUrl = (url: string): string | null => const NO_MENTIONS = { parse: [] }; +const MAX_IMAGE_URL_LENGTH = 1000; +/** Legacy stored URLs may be longer than the option allows; keep `get` replies within Discord's limit. */ +const MAX_SHOWN_URL_LENGTH = 500; + const setMessage = async (ctx: CommandContext) => { const text = ctx.interaction.options.getString('text', true); const problem = validateWelcomeMessage(text); @@ -65,7 +69,11 @@ const setImage = async (ctx: CommandContext) => { const get = async (ctx: CommandContext) => { const { welcomeMessage, welcomeMessageBackgroundUrl } = ctx.config; - const image = `\nWelcome image: ${welcomeMessageBackgroundUrl ?? 'Not set'}`; + const shownUrl = + welcomeMessageBackgroundUrl && welcomeMessageBackgroundUrl.length > MAX_SHOWN_URL_LENGTH + ? `${welcomeMessageBackgroundUrl.slice(0, MAX_SHOWN_URL_LENGTH - 1)}…` + : (welcomeMessageBackgroundUrl ?? 'Not set'); + const image = `\nWelcome image: ${shownUrl}`; await ctx.reply({ content: fitMessage( @@ -99,11 +107,11 @@ const preview = async (ctx: CommandContext) => { await ctx.reply(`Nothing would be sent: ${result.welcome.replace(/^(skipped|failed):/, '')}.`); return; } - const notes = payload.imageError ? `\n\nThe image failed to render: ${payload.imageError}` : ''; + const notes = payload.imageError ? `The image failed to render: ${payload.imageError}\n\n` : ''; await ctx.reply({ content: fitMessage( - 'Preview of the welcome message for you:\n\n', - `${payload.content ?? '*(no text)*'}${notes}`, + `Preview of the welcome message for you:\n\n${notes}`, + payload.content ?? '*(no text)*', ), files: payload.image ? [payload.image] : undefined, allowedMentions: NO_MENTIONS, @@ -136,6 +144,7 @@ export const WelcomeGroup = defineSubcommandGroup({ opt .setName('url') .setDescription('http(s) URL of the background image') + .setMaxLength(MAX_IMAGE_URL_LENGTH) .setRequired(true), ), run: setImage, diff --git a/src/utilities/format.ts b/src/utilities/format.ts index df30a982..5b8508b8 100644 --- a/src/utilities/format.ts +++ b/src/utilities/format.ts @@ -47,6 +47,10 @@ export function fitMessage(prefix: string, body: string, max = 2000): string { if (prefix.length + body.length <= max) { return prefix + body; } - const room = Math.max(0, max - prefix.length - TRUNCATION_NOTE.length - 1); + let room = Math.max(0, max - prefix.length - TRUNCATION_NOTE.length - 1); + const last = body.charCodeAt(room - 1); + if (room > 0 && last >= 0xd800 && last <= 0xdbff) { + room -= 1; // don't split a surrogate pair + } return `${prefix}${body.slice(0, room)}…${TRUNCATION_NOTE}`; } diff --git a/tests/about.test.ts b/tests/about.test.ts index 51505bdb..6ec5d794 100644 --- a/tests/about.test.ts +++ b/tests/about.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { toRepoUrl, formatUptime } from '../src/utilities/format.ts'; +import { fitMessage, toRepoUrl, formatUptime } from '../src/utilities/format.ts'; test('toRepoUrl', async (t) => { await t.test('removes git+ prefix', () => { @@ -58,3 +58,9 @@ test('formatUptime', async (t) => { assert.equal(formatUptime(ms), '3d 4h 12m'); }); }); + +test('fitMessage never splits a surrogate pair', () => { + const out = fitMessage('Prefix: ', 'a' + '😀'.repeat(1500)); + assert.ok(out.isWellFormed()); + assert.ok(out.length <= 2000); +}); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts index e1a9abdd..243ad70e 100644 --- a/tests/onboarding/commands.test.ts +++ b/tests/onboarding/commands.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { after, beforeEach, test } from 'node:test'; -import { Collection } from 'discord.js'; +import { Collection, SlashCommandSubcommandBuilder } from 'discord.js'; import type { DingirClient } from '../../src/client/DingirClient.ts'; import ConfigCommand from '../../src/commands/config/config.ts'; import interactionCreate from '../../src/events/interactionCreate.ts'; @@ -251,3 +251,43 @@ test('simulate onboard truncates a long welcome preview and keeps short ones int assert.match(nth(b.replies).content!, /hello there/); assert.doesNotMatch(nth(b.replies).content!, /truncated/); }); + +test('welcome get keeps the reply within the limit for a legacy long image URL', async () => { + const env = fakeOnboarding({ + config: { + welcomeMessage: 'hi', + welcomeMessageBackgroundUrl: `https://e.com/${'a'.repeat(2100)}`, + }, + }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'get')(ctx); + assert.ok(nth(replies).content!.length <= 2000); +}); + +test('welcome set-image option caps the URL length', () => { + const sub = WelcomeGroup.subcommands.find((s) => s.name === 'set-image')!; + const json = ( + sub.options!( + new SlashCommandSubcommandBuilder().setName('x').setDescription('x'), + ) as SlashCommandSubcommandBuilder + ).toJSON(); + assert.equal((json.options![0] as { max_length?: number }).max_length, 1000); +}); + +test('image failure notes survive truncation in preview and simulate', async () => { + const config = { + welcomeMessage: 'q'.repeat(2000), + welcomeMessageBackgroundUrl: 'http://127.0.0.1:1/bg.png', + systemMessagesEnabled: true, + }; + const p = fakeCommandContext(app, {}, fakeOnboarding({ config })); + await handler(WelcomeGroup as never, 'preview')(p.ctx); + assert.ok(nth(p.replies).content!.length <= 2000); + assert.match(nth(p.replies).content!, /The image failed to render/); + + const run = SimulateCommand.resolve(fakeInteraction({ subcommand: 'onboard' }).interaction)!.run; + const s = fakeCommandContext(app, {}, fakeOnboarding({ config })); + await run(s.ctx); + assert.ok(nth(s.replies).content!.length <= 2000); + assert.match(nth(s.replies).content!, /Image failed/); +}); From 5ca9732be46eb292ae9db991822d8a7ee32225dd Mon Sep 17 00:00:00 2001 From: Brent Oates <3065898+BrentOates@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:37:37 +0100 Subject: [PATCH 77/77] Correct unchanged command list in upgrade notes Co-Authored-By: Claude Sonnet 5.5 --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b1fe2de8..aa9f0a6f 100644 --- a/README.md +++ b/README.md @@ -341,12 +341,17 @@ Commands are re-registered once on the first start after upgrading. The command - `/config birthdays` — v2 had `create|sync`; v3 added `remove` - `/config newroles` — v2 `set` took 1–2 roles; v3 `set` takes 1–3 roles - `/simulate screen` — renamed to `/simulate onboard` +- `/rolesince` — `days` is now a whole number of at least 0 (v2 accepted decimals) +- `/post` — `channel` is limited to text and announcement channels, and `content` to 2000 characters +- `/config announcements` and `/config audit` — `set` now only accepts text and announcement channels **Unchanged**: - `/config sysmsgs set |get` — both v2 and v3 use this syntax - `/config debug set |get` — both v2 and v3 use this syntax -- `/config honeypot`, `/rolesince`, `/noroles`, `/post` — not in v2 +- `/config honeypot set|get|clear` — same options as v2 +- `/noroles` — same options as v2 +- `/about`, `/ping`, `/profile ` — same options as v2 ### Releases