From 95143d8c9392a1093a5d84bd9649adc6a7057e56 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sun, 2 Mar 2025 05:47:53 -0500 Subject: [PATCH 01/14] Added Counting Feature --- config.example.json | 3 +- src/commands/fun/counting.ts | 117 ++++++++++++++++++++++++++ src/db/redis.ts | 2 +- src/events/messageEvents.ts | 60 ++++++++++++- src/types/CommandTypes.ts | 6 ++ src/types/ConfigTypes.ts | 1 + src/util/countingManager.ts | 157 +++++++++++++++++++++++++++++++++++ 7 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 src/commands/fun/counting.ts create mode 100644 src/util/countingManager.ts diff --git a/config.example.json b/config.example.json index 28fca15..bae00fa 100644 --- a/config.example.json +++ b/config.example.json @@ -6,7 +6,8 @@ "redisConnectionString": "REDIS_CONNECTION_STRING", "channels": { "welcome": "WELCOME_CHANNEL_ID", - "logs": "LOG_CHAANNEL_ID" + "logs": "LOG_CHANNEL_ID", + "counting": "COUNTING_CHANNEL_ID" }, "roles": { "joinRoles": [ diff --git a/src/commands/fun/counting.ts b/src/commands/fun/counting.ts new file mode 100644 index 0000000..fb8bbce --- /dev/null +++ b/src/commands/fun/counting.ts @@ -0,0 +1,117 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionsBitField, +} from 'discord.js'; + +import { SubcommandCommand } from '../../types/CommandTypes.js'; +import { getCountingData, setCount } from '../../util/countingManager.js'; +import { loadConfig } from '../../util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('counting') + .setDescription('Commands related to the counting channel') + .addSubcommand((subcommand) => + subcommand + .setName('status') + .setDescription('Check the current counting status'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('setcount') + .setDescription( + 'Set the current count to a specific number (Admin only)', + ) + .addIntegerOption((option) => + option + .setName('count') + .setDescription('The number to set as the current count') + .setRequired(true) + .setMinValue(0), + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'status') { + const countingData = await getCountingData(); + const countingChannelId = loadConfig().channels.counting; + + const embed = new EmbedBuilder() + .setTitle('Counting Channel Status') + .setColor(0x0099ff) + .addFields( + { + name: 'Current Count', + value: countingData.currentCount.toString(), + inline: true, + }, + { + name: 'Next Number', + value: (countingData.currentCount + 1).toString(), + inline: true, + }, + { + name: 'Highest Count', + value: countingData.highestCount.toString(), + inline: true, + }, + { + name: 'Total Correct Counts', + value: countingData.totalCorrect.toString(), + inline: true, + }, + { + name: 'Counting Channel', + value: `<#${countingChannelId}>`, + inline: true, + }, + ) + .setFooter({ text: 'Remember: No user can count twice in a row!' }) + .setTimestamp(); + + if (countingData.lastUserId) { + embed.addFields({ + name: 'Last Counter', + value: `<@${countingData.lastUserId}>`, + inline: true, + }); + } + + await interaction.reply({ embeds: [embed] }); + } else if (subcommand === 'setcount') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You need administrator permissions to use this command.', + flags: ['Ephemeral'], + }); + return; + } + + const count = interaction.options.getInteger('count'); + if (count === null) { + await interaction.reply({ + content: 'Invalid count specified.', + flags: ['Ephemeral'], + }); + return; + } + + await setCount(count); + await interaction.reply({ + content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/db/redis.ts b/src/db/redis.ts index 8938d17..e513072 100644 --- a/src/db/redis.ts +++ b/src/db/redis.ts @@ -14,7 +14,7 @@ class RedisError extends Error { } } -redis.on('error', (error) => { +redis.on('error', (error: Error) => { console.error('Redis connection error:', error); throw new RedisError('Failed to connect to Redis instance: ', error); }); diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 8bd0f33..94fe5c9 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -1,6 +1,12 @@ import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js'; import { Event } from '../types/EventTypes.js'; +import { loadConfig } from '../util/configLoader.js'; +import { + addCountingReactions, + processCountingMessage, + resetCounting, +} from '../util/countingManager.js'; import logAction from '../util/logging/logAction.js'; export const messageDelete: Event = { @@ -62,4 +68,56 @@ export const messageUpdate: Event = { }, }; -export default [messageDelete, messageUpdate]; +export const messageCreate: Event = { + name: Events.MessageCreate, + execute: async (message: Message) => { + try { + if (message.author.bot) return; + + const countingChannelId = loadConfig().channels.counting; + const countingChannel = + message.guild?.channels.cache.get(countingChannelId); + + if (!countingChannel || message.channel.id !== countingChannelId) return; + if (!countingChannel.isTextBased()) { + console.error('Counting channel not found or is not a text channel'); + return; + } + + const result = await processCountingMessage(message); + + if (result.isValid) { + await addCountingReactions(message, result.milestoneType || 'normal'); + } else { + let errorMessage: string; + + switch (result.reason) { + case 'not_a_number': + errorMessage = `${message.author}, that's not a valid number! The count has been reset. The next number should be **1**.`; + break; + case 'too_high': + errorMessage = `${message.author}, too high! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`; + break; + case 'too_low': + errorMessage = `${message.author}, too low! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`; + break; + case 'same_user': + errorMessage = `${message.author}, you can't count twice in a row! The count has been reset. The next number should be **1**.`; + break; + default: + errorMessage = `${message.author}, something went wrong with the count. The count has been reset. The next number should be **1**.`; + } + + await resetCounting(); + + await countingChannel.send(errorMessage); + + await message.react('❌'); + } + } catch (error) { + console.error('Error handling message create:', error); + } + }, +}; + +export default [messageCreate, messageDelete, messageUpdate]; diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts index 30d175c..406f3f7 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -2,6 +2,7 @@ import { CommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, + SlashCommandSubcommandsOnlyBuilder, } from 'discord.js'; export interface Command { @@ -13,3 +14,8 @@ export interface OptionsCommand { data: SlashCommandOptionsOnlyBuilder; execute: (interaction: CommandInteraction) => Promise; } + +export interface SubcommandCommand { + data: SlashCommandSubcommandsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index e92ae94..64be57c 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -7,6 +7,7 @@ export interface Config { channels: { welcome: string; logs: string; + counting: string; }; roles: { joinRoles: string[]; diff --git a/src/util/countingManager.ts b/src/util/countingManager.ts new file mode 100644 index 0000000..f910690 --- /dev/null +++ b/src/util/countingManager.ts @@ -0,0 +1,157 @@ +import { Message } from 'discord.js'; + +import { getJson, setJson } from '../db/redis.js'; + +interface CountingData { + currentCount: number; + lastUserId: string | null; + highestCount: number; + totalCorrect: number; +} + +const MILESTONE_REACTIONS = { + normal: '✅', + multiples25: '✨', + multiples50: '⭐', + multiples100: '🎉', +}; + +export async function initializeCountingData(): Promise { + const exists = await getJson('counting'); + if (exists) return exists; + + const initialData: CountingData = { + currentCount: 0, + lastUserId: null, + highestCount: 0, + totalCorrect: 0, + }; + + await setJson('counting', initialData); + return initialData; +} + +export async function getCountingData(): Promise { + const data = await getJson('counting'); + if (!data) { + return initializeCountingData(); + } + return data; +} + +export async function updateCountingData( + data: Partial, +): Promise { + const currentData = await getCountingData(); + const updatedData = { ...currentData, ...data }; + await setJson('counting', updatedData); +} + +export async function resetCounting(): Promise { + await updateCountingData({ + currentCount: 0, + lastUserId: null, + }); + return; +} + +export async function processCountingMessage(message: Message): Promise<{ + isValid: boolean; + expectedCount?: number; + isMilestone?: boolean; + milestoneType?: keyof typeof MILESTONE_REACTIONS; + reason?: string; +}> { + try { + const countingData = await getCountingData(); + + const content = message.content.trim(); + const count = Number(content); + + if (isNaN(count) || !Number.isInteger(count)) { + return { + isValid: false, + expectedCount: countingData.currentCount + 1, + reason: 'not_a_number', + }; + } + + const expectedCount = countingData.currentCount + 1; + if (count !== expectedCount) { + return { + isValid: false, + expectedCount, + reason: count > expectedCount ? 'too_high' : 'too_low', + }; + } + + if (countingData.lastUserId === message.author.id) { + return { isValid: false, expectedCount, reason: 'same_user' }; + } + + const newCount = countingData.currentCount + 1; + const newHighestCount = Math.max(newCount, countingData.highestCount); + + await updateCountingData({ + currentCount: newCount, + lastUserId: message.author.id, + highestCount: newHighestCount, + totalCorrect: countingData.totalCorrect + 1, + }); + + let isMilestone = false; + let milestoneType: keyof typeof MILESTONE_REACTIONS = 'normal'; + + if (newCount % 100 === 0) { + isMilestone = true; + milestoneType = 'multiples100'; + } else if (newCount % 50 === 0) { + isMilestone = true; + milestoneType = 'multiples50'; + } else if (newCount % 25 === 0) { + isMilestone = true; + milestoneType = 'multiples25'; + } + + return { + isValid: true, + expectedCount: newCount + 1, + isMilestone, + milestoneType, + }; + } catch (error) { + console.error('Error processing counting message:', error); + return { isValid: false, reason: 'error' }; + } +} + +export async function addCountingReactions( + message: Message, + milestoneType: keyof typeof MILESTONE_REACTIONS, +): Promise { + try { + await message.react(MILESTONE_REACTIONS[milestoneType]); + + if (milestoneType === 'multiples100') { + await message.react('💯'); + } + } catch (error) { + console.error('Error adding counting reactions:', error); + } +} + +export async function getCountingStatus(): Promise { + const data = await getCountingData(); + return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`; +} + +export async function setCount(count: number): Promise { + if (!Number.isInteger(count) || count < 0) { + throw new Error('Count must be a non-negative integer.'); + } + + await updateCountingData({ + currentCount: count, + lastUserId: null, + }); +} From c47347e07d059a71b745489675761b33dfb59537 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Mon, 3 Mar 2025 19:08:52 -0500 Subject: [PATCH 02/14] Updated Yarn Version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 637b6b2..e682c16 100644 --- a/package.json +++ b/package.json @@ -38,5 +38,5 @@ "tsx": "^4.19.3", "typescript": "^5.8.2" }, - "packageManager": "yarn@4.6.0" + "packageManager": "yarn@4.7.0" } From 40942e253963e9fd9c19e5a1dfa9752f56a12ae3 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sat, 8 Mar 2025 00:29:19 -0500 Subject: [PATCH 03/14] Added Basic Fact of the Day Feature --- config.example.json | 7 +- src/commands/fun/fact.ts | 247 ++++++++++++++++++++++++++++++++ src/db/db.ts | 131 ++++++++++++++++- src/db/schema.ts | 20 +++ src/events/interactionCreate.ts | 84 ++++++++--- src/events/ready.ts | 3 + src/types/ConfigTypes.ts | 3 + src/util/factManager.ts | 69 +++++++++ src/util/logging/logAction.ts | 7 +- 9 files changed, 544 insertions(+), 27 deletions(-) create mode 100644 src/commands/fun/fact.ts create mode 100644 src/util/factManager.ts diff --git a/config.example.json b/config.example.json index bae00fa..bc1ce6f 100644 --- a/config.example.json +++ b/config.example.json @@ -7,11 +7,14 @@ "channels": { "welcome": "WELCOME_CHANNEL_ID", "logs": "LOG_CHANNEL_ID", - "counting": "COUNTING_CHANNEL_ID" + "counting": "COUNTING_CHANNEL_ID", + "factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID", + "factApproval": "FACT_APPROVAL_CHANNEL_ID" }, "roles": { "joinRoles": [ "JOIN_ROLE_IDS" - ] + ], + "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" } } diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts new file mode 100644 index 0000000..4b13186 --- /dev/null +++ b/src/commands/fun/fact.ts @@ -0,0 +1,247 @@ +import { + SlashCommandBuilder, + PermissionsBitField, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from 'discord.js'; + +import { + addFact, + getPendingFacts, + approveFact, + deleteFact, + getLastInsertedFactId, +} from '../../db/db.js'; +import { postFactOfTheDay } from '../../util/factManager.js'; +import { loadConfig } from '../../util/configLoader.js'; +import { SubcommandCommand } from '../../types/CommandTypes.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('fact') + .setDescription('Manage facts of the day') + .addSubcommand((subcommand) => + subcommand + .setName('submit') + .setDescription('Submit a new fact for approval') + .addStringOption((option) => + option + .setName('content') + .setDescription('The fact content') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('source') + .setDescription('Source of the fact (optional)') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('approve') + .setDescription('Approve a pending fact (Mod only)') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('The ID of the fact to approve') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete a fact (Mod only)') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('The ID of the fact to delete') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('pending') + .setDescription('List all pending facts (Mod only)'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('post') + .setDescription('Post a fact of the day manually (Admin only)'), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + const config = loadConfig(); + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'submit') { + const content = interaction.options.getString('content', true); + const source = interaction.options.getString('source') || undefined; + + const isAdmin = interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ); + + await addFact({ + content, + source, + addedBy: interaction.user.id, + approved: isAdmin ? true : false, + }); + + if (!isAdmin) { + const approvalChannel = interaction.guild?.channels.cache.get( + config.channels.factApproval, + ); + + if (approvalChannel?.isTextBased()) { + const embed = new EmbedBuilder() + .setTitle('New Fact Submission') + .setDescription(content) + .setColor(0x0099ff) + .addFields( + { + name: 'Submitted By', + value: `<@${interaction.user.id}>`, + inline: true, + }, + { name: 'Source', value: source || 'Not provided', inline: true }, + ) + .setTimestamp(); + + const approveButton = new ButtonBuilder() + .setCustomId(`approve_fact_${await getLastInsertedFactId()}`) + .setLabel('Approve') + .setStyle(ButtonStyle.Success); + + const rejectButton = new ButtonBuilder() + .setCustomId(`reject_fact_${await getLastInsertedFactId()}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents( + approveButton, + rejectButton, + ); + + await approvalChannel.send({ + embeds: [embed], + components: [row], + }); + } else { + console.error('Approval channel not found or is not a text channel'); + } + } + + await interaction.reply({ + content: isAdmin + ? 'Your fact has been automatically approved and added to the database!' + : 'Your fact has been submitted for approval!', + flags: ['Ephemeral'], + }); + } else if (subcommand === 'approve') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to approve facts.', + flags: ['Ephemeral'], + }); + return; + } + + const id = interaction.options.getInteger('id', true); + await approveFact(id); + + await interaction.reply({ + content: `Fact #${id} has been approved!`, + flags: ['Ephemeral'], + }); + } else if (subcommand === 'delete') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to delete facts.', + flags: ['Ephemeral'], + }); + return; + } + + const id = interaction.options.getInteger('id', true); + await deleteFact(id); + + await interaction.reply({ + content: `Fact #${id} has been deleted!`, + flags: ['Ephemeral'], + }); + } else if (subcommand === 'pending') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to view pending facts.', + flags: ['Ephemeral'], + }); + return; + } + + const pendingFacts = await getPendingFacts(); + + if (pendingFacts.length === 0) { + await interaction.reply({ + content: 'There are no pending facts.', + flags: ['Ephemeral'], + }); + return; + } + + const embed = new EmbedBuilder() + .setTitle('Pending Facts') + .setColor(0x0099ff) + .setDescription( + pendingFacts + .map((fact) => { + return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`; + }) + .join('\n\n'), + ) + .setTimestamp(); + + await interaction.reply({ + embeds: [embed], + flags: ['Ephemeral'], + }); + } else if (subcommand === 'post') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to manually post facts.', + flags: ['Ephemeral'], + }); + return; + } + + await postFactOfTheDay(interaction.client); + + await interaction.reply({ + content: 'Fact of the day has been posted!', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index ebc2db8..6a35c84 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,6 +1,6 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; +import { and, eq, isNull, sql } from 'drizzle-orm'; import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; @@ -230,3 +230,132 @@ export async function getMemberModerationHistory(discordId: string) { ); } } + +export async function addFact({ + content, + source, + addedBy, + approved = false, +}: schema.factTableTypes) { + try { + const result = await db.insert(schema.factTable).values({ + content, + source, + addedBy, + approved, + }); + + await del('unusedFacts'); + return result; + } catch (error) { + console.error('Error adding fact:', error); + throw new DatabaseError('Failed to add fact:', error as Error); + } +} + +export async function getLastInsertedFactId(): Promise { + try { + const result = await db + .select({ id: sql`MAX(${schema.factTable.id})` }) + .from(schema.factTable); + + return result[0]?.id ?? 0; + } catch (error) { + console.error('Error getting last inserted fact ID:', error); + throw new DatabaseError( + 'Failed to get last inserted fact ID:', + error as Error, + ); + } +} + +export async function getRandomUnusedFact() { + try { + if (await exists('unusedFacts')) { + const facts = + await getJson<(typeof schema.factTable.$inferSelect)[]>('unusedFacts'); + if (facts && facts.length > 0) { + return facts[Math.floor(Math.random() * facts.length)]; + } + } + + const facts = await db + .select() + .from(schema.factTable) + .where( + and( + eq(schema.factTable.approved, true), + isNull(schema.factTable.usedOn), + ), + ); + + if (facts.length === 0) { + await db + .update(schema.factTable) + .set({ usedOn: null }) + .where(eq(schema.factTable.approved, true)); + + return await getRandomUnusedFact(); + } + + await setJson<(typeof schema.factTable.$inferSelect)[]>( + 'unusedFacts', + facts, + ); + return facts[Math.floor(Math.random() * facts.length)]; + } catch (error) { + console.error('Error getting random fact:', error); + throw new DatabaseError('Failed to get random fact:', error as Error); + } +} + +export async function markFactAsUsed(id: number) { + try { + await db + .update(schema.factTable) + .set({ usedOn: new Date() }) + .where(eq(schema.factTable.id, id)); + + await del('unusedFacts'); + } catch (error) { + console.error('Error marking fact as used:', error); + throw new DatabaseError('Failed to mark fact as used:', error as Error); + } +} + +export async function getPendingFacts() { + try { + return await db + .select() + .from(schema.factTable) + .where(eq(schema.factTable.approved, false)); + } catch (error) { + console.error('Error getting pending facts:', error); + throw new DatabaseError('Failed to get pending facts:', error as Error); + } +} + +export async function approveFact(id: number) { + try { + await db + .update(schema.factTable) + .set({ approved: true }) + .where(eq(schema.factTable.id, id)); + + await del('unusedFacts'); + } catch (error) { + console.error('Error approving fact:', error); + throw new DatabaseError('Failed to approve fact:', error as Error); + } +} + +export async function deleteFact(id: number) { + try { + await db.delete(schema.factTable).where(eq(schema.factTable.id, id)); + + await del('unusedFacts'); + } catch (error) { + console.error('Error deleting fact:', error); + throw new DatabaseError('Failed to delete fact:', error as Error); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index cbf6782..11fb6b6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -61,3 +61,23 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({ references: [memberTable.discordId], }), })); + +export type factTableTypes = { + id?: number; + content: string; + source?: string; + addedBy: string; + addedAt?: Date; + approved?: boolean; + usedOn?: Date; +}; + +export const factTable = pgTable('facts', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + content: varchar('content').notNull(), + source: varchar('source'), + addedBy: varchar('added_by').notNull(), + addedAt: timestamp('added_at').defaultNow().notNull(), + approved: boolean('approved').default(false).notNull(), + usedOn: timestamp('used_on'), +}); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 328b10f..cd3e796 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -2,39 +2,79 @@ import { Events, Interaction } from 'discord.js'; import { ExtendedClient } from '../structures/ExtendedClient.js'; import { Event } from '../types/EventTypes.js'; +import { approveFact, deleteFact } from '../db/db.js'; export default { name: Events.InteractionCreate, execute: async (interaction: Interaction) => { - if (!interaction.isCommand()) return; + if (interaction.isCommand()) { + const client = interaction.client as ExtendedClient; + const command = client.commands.get(interaction.commandName); - const client = interaction.client as ExtendedClient; - const command = client.commands.get(interaction.commandName); + if (!command) { + console.error( + `No command matching ${interaction.commandName} was found.`, + ); + return; + } - if (!command) { - console.error( - `No command matching ${interaction.commandName} was found.`, - ); - return; - } + try { + await command.execute(interaction); + } catch (error) { + console.error(`Error executing ${interaction.commandName}`); + console.error(error); - try { - await command.execute(interaction); - } catch (error) { - console.error(`Error executing ${interaction.commandName}`); - console.error(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }); + } else { + await interaction.reply({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }); + } + } + } else if (interaction.isButton()) { + const { customId } = interaction; - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], + if (customId.startsWith('approve_fact_')) { + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to approve facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace('approve_fact_', ''), 10); + await approveFact(factId); + + await interaction.update({ + content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`, + components: [], }); - } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], + } else if (customId.startsWith('reject_fact_')) { + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to reject facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace('reject_fact_', ''), 10); + await deleteFact(factId); + + await interaction.update({ + content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`, + components: [], }); } + } else { + console.log('Unhandled interaction type:', interaction); + return; } }, } as Event; diff --git a/src/events/ready.ts b/src/events/ready.ts index 2430295..b7c54ce 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -3,6 +3,7 @@ import { Client, Events } from 'discord.js'; import { setMembers } from '../db/db.js'; import { loadConfig } from '../util/configLoader.js'; import { Event } from '../types/EventTypes.js'; +import { scheduleFactOfTheDay } from '../util/factManager.js'; export default { name: Events.ClientReady, @@ -21,6 +22,8 @@ export default { const members = await guild.members.fetch(); const nonBotMembers = members.filter((m) => !m.user.bot); await setMembers(nonBotMembers); + + await scheduleFactOfTheDay(client); } catch (error) { console.error('Failed to initialize members in database:', error); } diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index 64be57c..6cb16a2 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -8,8 +8,11 @@ export interface Config { welcome: string; logs: string; counting: string; + factOfTheDay: string; + factApproval: string; }; roles: { joinRoles: string[]; + factPingRole: string; }; } diff --git a/src/util/factManager.ts b/src/util/factManager.ts new file mode 100644 index 0000000..4663a18 --- /dev/null +++ b/src/util/factManager.ts @@ -0,0 +1,69 @@ +import { EmbedBuilder, Client } from 'discord.js'; + +import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js'; +import { loadConfig } from './configLoader.js'; + +export async function scheduleFactOfTheDay(client: Client) { + try { + const now = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(now.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const timeUntilMidnight = tomorrow.getTime() - now.getTime(); + + setTimeout(() => { + postFactOfTheDay(client); + scheduleFactOfTheDay(client); + }, timeUntilMidnight); + + console.log( + `Next fact of the day scheduled in ${Math.floor(timeUntilMidnight / 1000 / 60)} minutes`, + ); + } catch (error) { + console.error('Error scheduling fact of the day:', error); + setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000); + } +} + +export async function postFactOfTheDay(client: Client) { + try { + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + + if (!guild) { + console.error('Guild not found'); + return; + } + + const factChannel = guild.channels.cache.get(config.channels.factOfTheDay); + if (!factChannel?.isTextBased()) { + console.error('Fact channel not found or is not a text channel'); + return; + } + + const fact = await getRandomUnusedFact(); + if (!fact) { + console.error('No facts available'); + return; + } + + const embed = new EmbedBuilder() + .setTitle('🌟 Fact of the Day 🌟') + .setDescription(fact.content) + .setColor(0xffaa00) + .setTimestamp(); + + if (fact.source) { + embed.setFooter({ text: `Source: ${fact.source}` }); + } + + await factChannel.send({ + content: `<@&${config.roles.factPingRole}>`, + embeds: [embed], + }); + await markFactAsUsed(fact.id!); + } catch (error) { + console.error('Error posting fact of the day:', error); + } +} diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts index 7becbe5..5a0affe 100644 --- a/src/util/logging/logAction.ts +++ b/src/util/logging/logAction.ts @@ -5,6 +5,7 @@ import { ActionRowBuilder, GuildChannel, } from 'discord.js'; + import { LogActionPayload, ModerationLogAction, @@ -22,10 +23,12 @@ import { getPermissionDifference, getPermissionNames, } from './utils.js'; +import { loadConfig } from '../configLoader.js'; export default async function logAction(payload: LogActionPayload) { - const logChannel = payload.guild.channels.cache.get('1007787977432383611'); - if (!logChannel || !(logChannel instanceof TextChannel)) { + const config = loadConfig(); + const logChannel = payload.guild.channels.cache.get(config.channels.logs); + if (!logChannel?.isTextBased()) { console.error('Log channel not found or is not a Text Channel.'); return; } From 7af6d5914dd050355421335163682b5f2de3bdf2 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:00:00 -0500 Subject: [PATCH 04/14] Updated README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2a8a40e..d784000 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ > [!WARNING] > This Discord bot is not production ready and everything is subject to change +> [!TIP] +> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY). + ## Development Commands Install Dependencies: ``yarn install`` From b5ce514397367b8fad569b42a716441692da590a Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:52:10 -0400 Subject: [PATCH 05/14] Added Basic Leveling System and QoL Updates --- assets/fonts/Manrope-Bold.ttf | Bin 0 -> 96800 bytes assets/fonts/Manrope-Regular.ttf | Bin 0 -> 96832 bytes config.example.json | 31 ++- src/commands/fun/fact.ts | 42 ++--- src/commands/fun/leaderboard.ts | 171 +++++++++++++++++ src/commands/fun/rank.ts | 49 +++++ src/commands/util/recalculatelevels.ts | 36 ++++ src/commands/util/xp.ts | 132 +++++++++++++ src/db/db.ts | 176 ++++++++++++++++- src/db/schema.ts | 31 ++- src/events/interactionCreate.ts | 42 +++-- src/events/messageEvents.ts | 39 +++- src/types/ConfigTypes.ts | 9 + src/util/deployCommand.ts | 2 +- src/util/levelingSystem.ts | 249 +++++++++++++++++++++++++ 15 files changed, 970 insertions(+), 39 deletions(-) create mode 100644 assets/fonts/Manrope-Bold.ttf create mode 100644 assets/fonts/Manrope-Regular.ttf create mode 100644 src/commands/fun/leaderboard.ts create mode 100644 src/commands/fun/rank.ts create mode 100644 src/commands/util/recalculatelevels.ts create mode 100644 src/commands/util/xp.ts create mode 100644 src/util/levelingSystem.ts diff --git a/assets/fonts/Manrope-Bold.ttf b/assets/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..98c1c3d5b6f7b5452964b591fc4a0411ac2b5722 GIT binary patch literal 96800 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 MwwU?%lX!H0184Be#{d8T literal 0 HcmV?d00001 diff --git a/assets/fonts/Manrope-Regular.ttf b/assets/fonts/Manrope-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1a072330a807ba9f247b6b51e234d8ac7c7c9ef8 GIT binary patch literal 96832 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 z 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 PYKF&ZM0WQ-@!I|YI=d1? literal 0 HcmV?d00001 diff --git a/config.example.json b/config.example.json index bc1ce6f..cecb413 100644 --- a/config.example.json +++ b/config.example.json @@ -9,12 +9,41 @@ "logs": "LOG_CHANNEL_ID", "counting": "COUNTING_CHANNEL_ID", "factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID", - "factApproval": "FACT_APPROVAL_CHANNEL_ID" + "factApproval": "FACT_APPROVAL_CHANNEL_ID", + "advancements": "ADVANCEMENTS_CHANNEL_ID" }, "roles": { "joinRoles": [ "JOIN_ROLE_IDS" ], + "levelRoles": [ + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + } + ], + "staffRoles": [ + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + } + ], "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" } } diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts index 4b13186..b0b4c93 100644 --- a/src/commands/fun/fact.ts +++ b/src/commands/fun/fact.ts @@ -74,6 +74,12 @@ const command: SubcommandCommand = { execute: async (interaction) => { if (!interaction.isChatInputCommand()) return; + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + await interaction.editReply('Processing...'); + const config = loadConfig(); const subcommand = interaction.options.getSubcommand(); @@ -112,13 +118,15 @@ const command: SubcommandCommand = { ) .setTimestamp(); + const factId = await getLastInsertedFactId(); + const approveButton = new ButtonBuilder() - .setCustomId(`approve_fact_${await getLastInsertedFactId()}`) + .setCustomId(`approve_fact_${factId}`) .setLabel('Approve') .setStyle(ButtonStyle.Success); const rejectButton = new ButtonBuilder() - .setCustomId(`reject_fact_${await getLastInsertedFactId()}`) + .setCustomId(`reject_fact_${factId}`) .setLabel('Reject') .setStyle(ButtonStyle.Danger); @@ -136,11 +144,10 @@ const command: SubcommandCommand = { } } - await interaction.reply({ + await interaction.editReply({ content: isAdmin ? 'Your fact has been automatically approved and added to the database!' : 'Your fact has been submitted for approval!', - flags: ['Ephemeral'], }); } else if (subcommand === 'approve') { if ( @@ -148,9 +155,8 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to approve facts.', - flags: ['Ephemeral'], }); return; } @@ -158,9 +164,8 @@ const command: SubcommandCommand = { const id = interaction.options.getInteger('id', true); await approveFact(id); - await interaction.reply({ + await interaction.editReply({ content: `Fact #${id} has been approved!`, - flags: ['Ephemeral'], }); } else if (subcommand === 'delete') { if ( @@ -168,9 +173,8 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to delete facts.', - flags: ['Ephemeral'], }); return; } @@ -178,9 +182,8 @@ const command: SubcommandCommand = { const id = interaction.options.getInteger('id', true); await deleteFact(id); - await interaction.reply({ + await interaction.editReply({ content: `Fact #${id} has been deleted!`, - flags: ['Ephemeral'], }); } else if (subcommand === 'pending') { if ( @@ -188,9 +191,8 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to view pending facts.', - flags: ['Ephemeral'], }); return; } @@ -198,9 +200,8 @@ const command: SubcommandCommand = { const pendingFacts = await getPendingFacts(); if (pendingFacts.length === 0) { - await interaction.reply({ + await interaction.editReply({ content: 'There are no pending facts.', - flags: ['Ephemeral'], }); return; } @@ -217,9 +218,8 @@ const command: SubcommandCommand = { ) .setTimestamp(); - await interaction.reply({ + await interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'], }); } else if (subcommand === 'post') { if ( @@ -227,18 +227,16 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to manually post facts.', - flags: ['Ephemeral'], }); return; } await postFactOfTheDay(interaction.client); - await interaction.reply({ + await interaction.editReply({ content: 'Fact of the day has been posted!', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/fun/leaderboard.ts b/src/commands/fun/leaderboard.ts new file mode 100644 index 0000000..0d37fef --- /dev/null +++ b/src/commands/fun/leaderboard.ts @@ -0,0 +1,171 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ButtonBuilder, + ActionRowBuilder, + ButtonStyle, + StringSelectMenuBuilder, + APIEmbed, + JSONEncodable, +} from 'discord.js'; + +import { OptionsCommand } from '../../types/CommandTypes.js'; +import { getLevelLeaderboard } from '../../db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('leaderboard') + .setDescription('Shows the server XP leaderboard') + .addIntegerOption((option) => + option + .setName('limit') + .setDescription('Number of users per page (default: 10)') + .setRequired(false), + ), + execute: async (interaction) => { + if (!interaction.guild) return; + + await interaction.deferReply(); + + try { + const usersPerPage = + (interaction.options.get('limit')?.value as number) || 10; + + const allUsers = await getLevelLeaderboard(100); + + if (allUsers.length === 0) { + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription('No users found on the leaderboard yet.') + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } + + const pages: (APIEmbed | JSONEncodable)[] = []; + + for (let i = 0; i < allUsers.length; i += usersPerPage) { + const pageUsers = allUsers.slice(i, i + usersPerPage); + let leaderboardText = ''; + + for (let j = 0; j < pageUsers.length; j++) { + const user = pageUsers[j]; + const position = i + j + 1; + + try { + const member = await interaction.guild.members.fetch( + user.discordId, + ); + leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`; + } catch (error) { + leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`; + } + } + + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription(leaderboardText) + .setTimestamp() + .setFooter({ + text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`, + }); + + pages.push(embed); + } + + let currentPage = 0; + + const getButtonActionRow = () => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('previous') + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('next') + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === pages.length - 1), + ); + + const getSelectMenuRow = () => { + const options = pages.map((_, index) => ({ + label: `Page ${index + 1}`, + value: index.toString(), + default: index === currentPage, + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('select_page') + .setPlaceholder('Jump to a page') + .addOptions(options); + + return new ActionRowBuilder().addComponents( + select, + ); + }; + + const components = + pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components, + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + if (i.customId === 'previous' && currentPage > 0) { + currentPage--; + } else if (i.customId === 'next' && currentPage < pages.length - 1) { + currentPage++; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [getButtonActionRow(), getSelectMenuRow()], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } + }); + } catch (error) { + console.error('Error getting leaderboard:', error); + await interaction.editReply('Failed to get leaderboard information.'); + } + }, +}; + +export default command; diff --git a/src/commands/fun/rank.ts b/src/commands/fun/rank.ts new file mode 100644 index 0000000..0007d05 --- /dev/null +++ b/src/commands/fun/rank.ts @@ -0,0 +1,49 @@ +import { GuildMember, SlashCommandBuilder } from 'discord.js'; + +import { OptionsCommand } from '../../types/CommandTypes.js'; +import { + generateRankCard, + getXpToNextLevel, +} from '../../util/levelingSystem.js'; +import { getUserLevel } from '../../db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('rank') + .setDescription('Shows your current rank and level') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to check rank for (defaults to yourself)') + .setRequired(false), + ), + execute: async (interaction) => { + const member = await interaction.guild?.members.fetch( + (interaction.options.get('user')?.value as string) || interaction.user.id, + ); + + if (!member) { + await interaction.reply('User not found in this server.'); + return; + } + + await interaction.deferReply(); + + try { + const userData = await getUserLevel(member.id); + const rankCard = await generateRankCard(member, userData); + + const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp); + + await interaction.editReply({ + content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`, + files: [rankCard], + }); + } catch (error) { + console.error('Error getting rank:', error); + await interaction.editReply('Failed to get rank information.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/recalculatelevels.ts b/src/commands/util/recalculatelevels.ts new file mode 100644 index 0000000..0667d1a --- /dev/null +++ b/src/commands/util/recalculatelevels.ts @@ -0,0 +1,36 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { Command } from '../../types/CommandTypes.js'; +import { recalculateUserLevels } from '../../util/levelingSystem.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('recalculatelevels') + .setDescription('(Admin Only) Recalculate all user levels'), + execute: async (interaction) => { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to use this command.', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.deferReply(); + await interaction.editReply('Recalculating levels...'); + + try { + await recalculateUserLevels(); + await interaction.editReply('Levels recalculated successfully!'); + } catch (error) { + console.error('Error recalculating levels:', error); + await interaction.editReply('Failed to recalculate levels.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/xp.ts b/src/commands/util/xp.ts new file mode 100644 index 0000000..cac94e8 --- /dev/null +++ b/src/commands/util/xp.ts @@ -0,0 +1,132 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import { SubcommandCommand } from '../../types/CommandTypes.js'; +import { addXpToUser, getUserLevel } from '../../db/db.js'; +import { loadConfig } from '../../util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('xp') + .setDescription('(Manager only) Manage user XP') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add XP to a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to add XP to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to add') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove XP from a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to remove XP from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to remove') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to set XP for') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to set') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('reset') + .setDescription('Reset XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to reset XP for') + .setRequired(true), + ), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const commandUser = interaction.guild?.members.cache.get( + interaction.user.id, + ); + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + if ( + !commandUser || + !managerRoleId || + commandUser.roles.highest.comparePositionTo(managerRoleId) < 0 + ) { + await interaction.reply({ + content: 'You do not have permission to use this command', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + await interaction.editReply('Processing...'); + + const subcommand = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + const amount = interaction.options.getInteger('amount', false); + + const userData = await getUserLevel(user.id); + + if (subcommand === 'add') { + await addXpToUser(user.id, amount!); + await interaction.editReply({ + content: `Added ${amount} XP to <@${user.id}>`, + }); + } else if (subcommand === 'remove') { + await addXpToUser(user.id, -amount!); + await interaction.editReply({ + content: `Removed ${amount} XP from <@${user.id}>`, + }); + } else if (subcommand === 'set') { + await addXpToUser(user.id, amount! - userData.xp); + await interaction.editReply({ + content: `Set ${amount} XP for <@${user.id}>`, + }); + } else if (subcommand === 'reset') { + await addXpToUser(user.id, userData.xp * -1); + await interaction.editReply({ + content: `Reset XP for <@${user.id}>`, + }); + } + }, +}; + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index 6a35c84..b47d689 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,10 +1,11 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { and, eq, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; +import { calculateLevelFromXp } from '../util/levelingSystem.js'; const { Pool } = pkg; const config = loadConfig(); @@ -162,6 +163,179 @@ export async function updateMember({ } } +export async function getUserLevel( + discordId: string, +): Promise { + try { + if (await exists(`level-${discordId}`)) { + const cachedLevel = await getJson( + `level-${discordId}`, + ); + if (cachedLevel !== null) { + return cachedLevel; + } + await del(`level-${discordId}`); + } + + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); + + if (level) { + const typedLevel: schema.levelTableTypes = { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; + await setJson(`level-${discordId}`, typedLevel); + return typedLevel; + } + + const newLevel = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + }; + + await db.insert(schema.levelTable).values(newLevel); + await setJson(`level-${discordId}`, newLevel); + return newLevel; + } catch (error) { + console.error('Error getting user level:', error); + throw error; + } +} + +export async function addXpToUser(discordId: string, amount: number) { + try { + const userData = await getUserLevel(discordId); + const currentLevel = userData.level; + + userData.xp += amount; + userData.lastMessageTimestamp = new Date(); + + const newLevel = calculateLevelFromXp(userData.xp); + userData.level = newLevel; + + await db + .update(schema.levelTable) + .set({ + xp: userData.xp, + level: newLevel, + lastMessageTimestamp: userData.lastMessageTimestamp, + }) + .where(eq(schema.levelTable.discordId, discordId)); + + await setJson(`level-${discordId}`, userData); + + await invalidateLeaderboardCache(); + + return { + leveledUp: newLevel > currentLevel, + newLevel, + oldLevel: currentLevel, + }; + } catch (error) { + console.error('Error adding XP to user:', error); + throw error; + } +} + +export async function getUserRank(discordId: string): Promise { + try { + if (await exists('xp-leaderboard-cache')) { + const leaderboardCache = await getJson< + Array<{ discordId: string; xp: number }> + >('xp-leaderboard-cache'); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; + } + } + } + + const allMembers = await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + + await setJson('xp-leaderboard-cache', allMembers, 300); + + const userIndex = allMembers.findIndex( + (member) => member.discordId === discordId, + ); + + return userIndex !== -1 ? userIndex + 1 : 1; + } catch (error) { + console.error('Error getting user rank:', error); + return 1; + } +} + +export async function invalidateLeaderboardCache() { + try { + if (await exists('xp-leaderboard-cache')) { + await del('xp-leaderboard-cache'); + } + } catch (error) { + console.error('Error invalidating leaderboard cache:', error); + } +} + +export async function getLevelLeaderboard(limit = 10) { + try { + if (await exists('xp-leaderboard-cache')) { + const leaderboardCache = await getJson< + Array<{ discordId: string; xp: number }> + >('xp-leaderboard-cache'); + + if (leaderboardCache) { + const limitedCache = leaderboardCache.slice(0, limit); + + const fullLeaderboard = await Promise.all( + limitedCache.map(async (entry) => { + const userData = await getUserLevel(entry.discordId); + return userData; + }), + ); + + return fullLeaderboard; + } + } + + const leaderboard = await db + .select() + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)) + .limit(limit); + + const allMembers = await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + + await setJson('xp-leaderboard-cache', allMembers, 300); + + return leaderboard; + } catch (error) { + console.error('Error getting leaderboard:', error); + throw error; + } +} + export async function updateMemberModerationHistory({ discordId, moderatorDiscordId, diff --git a/src/db/schema.ts b/src/db/schema.ts index 11fb6b6..d168cef 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -25,6 +25,24 @@ export const memberTable = pgTable('members', { currentlyMuted: boolean('currently_muted').notNull().default(false), }); +export interface levelTableTypes { + id?: number; + discordId: string; + xp: number; + level: number; + lastMessageTimestamp?: Date; +} + +export const levelTable = pgTable('levels', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('discord_id') + .notNull() + .references(() => memberTable.discordId, { onDelete: 'cascade' }), + xp: integer('xp').notNull().default(0), + level: integer('level').notNull().default(1), + lastMessageTimestamp: timestamp('last_message_timestamp'), +}); + export interface moderationTableTypes { id?: number; discordId: string; @@ -51,8 +69,19 @@ export const moderationTable = pgTable('moderations', { active: boolean('active').notNull().default(true), }); -export const memberRelations = relations(memberTable, ({ many }) => ({ +export const memberRelations = relations(memberTable, ({ many, one }) => ({ moderations: many(moderationTable), + levels: one(levelTable, { + fields: [memberTable.discordId], + references: [levelTable.discordId], + }), +})); + +export const levelRelations = relations(levelTable, ({ one }) => ({ + member: one(memberTable, { + fields: [levelTable.discordId], + references: [memberTable.discordId], + }), })); export const moderationRelations = relations(moderationTable, ({ one }) => ({ diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index cd3e796..10155dc 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -20,20 +20,40 @@ export default { try { await command.execute(interaction); - } catch (error) { + } catch (error: any) { console.error(`Error executing ${interaction.commandName}`); console.error(error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + const isUnknownInteractionError = + error.code === 10062 || + (error.message && error.message.includes('Unknown interaction')); + + if (!isUnknownInteractionError) { + try { + if (interaction.replied || interaction.deferred) { + await interaction + .followUp({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }) + .catch((e) => + console.error('Failed to send error followup:', e), + ); + } else { + await interaction + .reply({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }) + .catch((e) => console.error('Failed to send error reply:', e)); + } + } catch (replyError) { + console.error('Failed to respond with error message:', replyError); + } } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + console.warn( + 'Interaction expired before response could be sent (code 10062)', + ); } } } else if (interaction.isButton()) { @@ -73,7 +93,7 @@ export default { }); } } else { - console.log('Unhandled interaction type:', interaction); + console.warn('Unhandled interaction type:', interaction); return; } }, diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 94fe5c9..24b7a66 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -8,6 +8,10 @@ import { resetCounting, } from '../util/countingManager.js'; import logAction from '../util/logging/logAction.js'; +import { + checkAndAssignLevelRoles, + processMessage, +} from '../util/levelingSystem.js'; export const messageDelete: Event = { name: Events.MessageDelete, @@ -72,7 +76,38 @@ export const messageCreate: Event = { name: Events.MessageCreate, execute: async (message: Message) => { try { - if (message.author.bot) return; + if (message.author.bot || !message.guild) return; + + const levelResult = await processMessage(message); + const advancementsChannelId = loadConfig().channels.advancements; + const advancementsChannel = message.guild?.channels.cache.get( + advancementsChannelId, + ); + + if (!advancementsChannel || !advancementsChannel.isTextBased()) { + console.error( + 'Advancements channel not found or is not a text channel', + ); + return; + } + + if (levelResult?.leveledUp) { + await advancementsChannel.send( + `🎉 Congratulations <@${message.author.id}>! You've leveled up to **Level ${levelResult.newLevel}**!`, + ); + + const assignedRole = await checkAndAssignLevelRoles( + message.guild, + message.author.id, + levelResult.newLevel, + ); + + if (assignedRole) { + await advancementsChannel.send( + `<@${message.author.id}> You've earned the <@&${assignedRole}> role!`, + ); + } + } const countingChannelId = loadConfig().channels.counting; const countingChannel = @@ -115,7 +150,7 @@ export const messageCreate: Event = { await message.react('❌'); } } catch (error) { - console.error('Error handling message create:', error); + console.error('Error handling message create: ', error); } }, }; diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index 6cb16a2..96ed2dd 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -10,9 +10,18 @@ export interface Config { counting: string; factOfTheDay: string; factApproval: string; + advancements: string; }; roles: { joinRoles: string[]; + levelRoles: { + level: number; + roleId: string; + }[]; + staffRoles: { + name: string; + roleId: string; + }[]; factPingRole: string; }; } diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 9ce4580..98b6934 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,6 +1,6 @@ -import { REST, Routes } from 'discord.js'; import fs from 'fs'; import path from 'path'; +import { REST, Routes } from 'discord.js'; import { loadConfig } from './configLoader.js'; const config = loadConfig(); diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts new file mode 100644 index 0000000..d546250 --- /dev/null +++ b/src/util/levelingSystem.ts @@ -0,0 +1,249 @@ +import path from 'path'; +import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js'; +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; + +import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js'; +import * as schema from '../db/schema.js'; +import { loadConfig } from './configLoader.js'; + +const config = loadConfig(); + +const XP_COOLDOWN = 60 * 1000; +const MIN_XP = 15; +const MAX_XP = 25; + +const __dirname = path.resolve(); + +export const calculateXpForLevel = (level: number): number => { + if (level === 0) return 0; + return (5 / 6) * level * (2 * level * level + 27 * level + 91); +}; + +export const calculateLevelFromXp = (xp: number): number => { + if (xp < calculateXpForLevel(1)) return 0; + + let level = 0; + while (calculateXpForLevel(level + 1) <= xp) { + level++; + } + + return level; +}; + +export const getXpToNextLevel = (level: number, currentXp: number): number => { + if (level === 0) return calculateXpForLevel(1) - currentXp; + + const nextLevelXp = calculateXpForLevel(level + 1); + return nextLevelXp - currentXp; +}; + +export async function recalculateUserLevels() { + const users = await db.select().from(schema.levelTable); + + for (const user of users) { + await addXpToUser(user.discordId, 0); + } +} + +export async function processMessage(message: Message) { + if (message.author.bot || !message.guild) return; + + try { + const userId = message.author.id; + const userData = await getUserLevel(userId); + + if (userData.lastMessageTimestamp) { + const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime(); + const currentTime = Date.now(); + + if (currentTime - lastMessageTime < XP_COOLDOWN) { + return null; + } + } + + const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + const result = await addXpToUser(userId, xpToAdd); + + return result; + } catch (error) { + console.error('Error processing message for XP:', error); + return null; + } +} + +function roundRect( + ctx: Canvas.SKRSContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, + fill: boolean, +) { + if (typeof radius === 'undefined') { + radius = 5; + } + + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + + if (fill) { + ctx.fill(); + } else { + ctx.stroke(); + } +} + +export async function generateRankCard( + member: GuildMember, + userData: schema.levelTableTypes, +) { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const userRank = await getUserRank(userData.discordId); + + const canvas = Canvas.createCanvas(934, 282); + const context = canvas.getContext('2d'); + + context.fillStyle = '#23272A'; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.fillStyle = '#2C2F33'; + roundRect(context, 22, 22, 890, 238, 20, true); + + try { + const avatar = await Canvas.loadImage( + member.user.displayAvatarURL({ extension: 'png', size: 256 }), + ); + context.save(); + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.closePath(); + context.clip(); + context.drawImage(avatar, 40, 61, 160, 160); + context.restore(); + } catch (error) { + console.error('Error loading avatar image:', error); + context.fillStyle = '#5865F2'; + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.fill(); + } + + context.font = '38px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(member.user.username, 242, 142); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.textAlign = 'end'; + context.fillText(`LEVEL ${userData.level}`, 890, 82); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(`RANK #${userRank}`, 890, 122); + + const barWidth = 615; + const barHeight = 38; + const barX = 242; + const barY = 182; + + const currentLevel = userData.level; + const currentLevelXp = calculateXpForLevel(currentLevel); + const nextLevelXp = calculateXpForLevel(currentLevel + 1); + + const xpNeededForNextLevel = nextLevelXp - currentLevelXp; + + let xpIntoCurrentLevel; + if (currentLevel === 0) { + xpIntoCurrentLevel = userData.xp; + } else { + xpIntoCurrentLevel = userData.xp - currentLevelXp; + } + + const progress = Math.max( + 0, + Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1), + ); + + context.fillStyle = '#484b4E'; + roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true); + + if (progress > 0) { + context.fillStyle = '#5865F2'; + roundRect( + context, + barX, + barY, + barWidth * progress, + barHeight, + barHeight / 2, + true, + ); + } + + context.textAlign = 'center'; + context.font = '20px "Manrope"'; + context.fillStyle = '#A0A0A0'; + context.fillText( + `${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`, + barX + barWidth / 2, + barY + barHeight / 2 + 7, + ); + + return new AttachmentBuilder(canvas.toBuffer('image/png'), { + name: 'rank-card.png', + }); +} + +export async function checkAndAssignLevelRoles( + guild: Guild, + userId: string, + newLevel: number, +) { + try { + if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) { + return; + } + + const member = await guild.members.fetch(userId); + if (!member) return; + + const rolesToAdd = config.roles.levelRoles + .filter((role) => role.level <= newLevel) + .map((role) => role.roleId); + + if (rolesToAdd.length === 0) return; + + const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId); + const rolesToRemove = member.roles.cache.filter((role) => + existingLevelRoles.includes(role.id), + ); + if (rolesToRemove.size > 0) { + await member.roles.remove(rolesToRemove); + } + + const highestRole = rolesToAdd[rolesToAdd.length - 1]; + await member.roles.add(highestRole); + + return highestRole; + } catch (error) { + console.error('Error assigning level roles:', error); + } +} From 84bf5b272cbd94bf6f80c541101da462cdc70ee6 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:55:28 -0400 Subject: [PATCH 06/14] Update src/db/schema.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com> --- src/db/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/schema.ts b/src/db/schema.ts index d168cef..fd6ddaa 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -39,7 +39,7 @@ export const levelTable = pgTable('levels', { .notNull() .references(() => memberTable.discordId, { onDelete: 'cascade' }), xp: integer('xp').notNull().default(0), - level: integer('level').notNull().default(1), + level: integer('level').notNull().default(0), lastMessageTimestamp: timestamp('last_message_timestamp'), }); From b3fbd2358b32e9132c9f6000a3006d58d4ef5ed6 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sat, 15 Mar 2025 20:52:19 -0400 Subject: [PATCH 07/14] Updated README.md --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d784000..24e74a7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Poixpixel's Discord Bot > [!WARNING] -> This Discord bot is not production ready and everything is subject to change +> This Discord bot is not production ready. > [!TIP] > Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY). @@ -10,8 +10,14 @@ Install Dependencies: ``yarn install`` +Lint: ``yarn lint`` + +Check Formatting: ``yarn format`` + +Fix Formatting: ``yarn format:fix`` + Compile: ``yarn compile`` Start: ``yarn target`` -Build & Start: ``yarn start`` +Build & Start: ``yarn start`` \ No newline at end of file From 890ca26c78b7350ee6dbe37f196952385da7d8ed Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:31:43 -0400 Subject: [PATCH 08/14] Added code coments, refactored db.ts and redis.ts, and added two new commands --- .github/workflows/npm-build-and-compile.yml | 2 +- README.md | 6 +- config.example.json | 17 +- package.json | 4 +- src/commands/util/members.ts | 2 +- src/commands/util/reconnect.ts | 203 ++++ src/commands/util/restart.ts | 93 ++ src/db/db.ts | 1010 +++++++++++++------ src/db/redis.ts | 307 +++++- src/db/schema.ts | 1 + src/events/interactionCreate.ts | 4 +- src/events/memberEvents.ts | 17 +- src/events/messageEvents.ts | 2 +- src/events/ready.ts | 16 +- src/structures/ExtendedClient.ts | 3 + src/types/CommandTypes.ts | 9 + src/types/ConfigTypes.ts | 20 +- src/types/EventTypes.ts | 3 + src/util/configLoader.ts | 4 + src/util/countingManager.ts | 34 + src/util/deployCommand.ts | 29 +- src/util/eventLoader.ts | 4 + src/util/factManager.ts | 24 +- src/util/helpers.ts | 137 ++- src/util/levelingSystem.ts | 134 ++- src/util/logging/constants.ts | 9 + src/util/logging/logAction.ts | 9 +- src/util/logging/types.ts | 51 + src/util/logging/utils.ts | 56 + src/util/notificationHandler.ts | 151 +++ 30 files changed, 1899 insertions(+), 462 deletions(-) create mode 100644 src/commands/util/reconnect.ts create mode 100644 src/commands/util/restart.ts create mode 100644 src/util/notificationHandler.ts diff --git a/.github/workflows/npm-build-and-compile.yml b/.github/workflows/npm-build-and-compile.yml index d22aa36..51c4c1a 100644 --- a/.github/workflows/npm-build-and-compile.yml +++ b/.github/workflows/npm-build-and-compile.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [21.x] + node-version: [23.x] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 24e74a7..b556733 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,8 @@ Compile: ``yarn compile`` Start: ``yarn target`` -Build & Start: ``yarn start`` \ No newline at end of file +Build & Start (dev): ``yarn start:dev`` + +Build & Start (prod): ``yarn start:prod`` + +Restart (works only when the bot is started with ``yarn start:prod``): ``yarn restart`` diff --git a/config.example.json b/config.example.json index cecb413..32d9014 100644 --- a/config.example.json +++ b/config.example.json @@ -2,8 +2,16 @@ "token": "DISCORD_BOT_API_KEY", "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", - "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", - "redisConnectionString": "REDIS_CONNECTION_STRING", + "database": { + "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "maxRetryAttempts": "MAX_RETRY_ATTEMPTS", + "retryDelay": "RETRY_DELAY_IN_MS" + }, + "redis": { + "redisConnectionString": "REDIS_CONNECTION_STRING", + "retryAttempts": "RETRY_ATTEMPTS", + "initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS" + }, "channels": { "welcome": "WELCOME_CHANNEL_ID", "logs": "LOG_CHANNEL_ID", @@ -45,5 +53,10 @@ } ], "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" + }, + "leveling": { + "xpCooldown": "XP_COOLDOWN_IN_SECONDS", + "minXpAwarded": "MINIMUM_XP_AWARDED", + "maxXpAwarded": "MAXIMUM_XP_AWARDED" } } diff --git a/package.json b/package.json index 0ef760c..e7a152a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "scripts": { "compile": "npx tsc", "target": "node ./target/discord-bot.js", - "start": "yarn run compile && yarn run target", + "start:dev": "yarn run compile && yarn run target", + "start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot", + "restart": "pm2 restart poixpixel-discord-bot", "lint": "npx eslint ./src && npx tsc --noEmit", "format": "prettier --check --ignore-path .prettierignore .", "format:fix": "prettier --write --ignore-path .prettierignore ." diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index 083ee64..e1bc945 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -19,7 +19,7 @@ const command: Command = { execute: async (interaction) => { let members = await getAllMembers(); members = members.sort((a, b) => - a.discordUsername.localeCompare(b.discordUsername), + (a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''), ); const ITEMS_PER_PAGE = 15; diff --git a/src/commands/util/reconnect.ts b/src/commands/util/reconnect.ts new file mode 100644 index 0000000..a4ed8f0 --- /dev/null +++ b/src/commands/util/reconnect.ts @@ -0,0 +1,203 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, +} from 'discord.js'; + +import { SubcommandCommand } from '../../types/CommandTypes.js'; +import { loadConfig } from '../../util/configLoader.js'; +import { + initializeDatabaseConnection, + ensureDbInitialized, +} from '../../db/db.js'; +import { isRedisConnected } from '../../db/redis.js'; +import { + NotificationType, + notifyManagers, +} from '../../util/notificationHandler.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('reconnect') + .setDescription('(Manager Only) Force reconnection to database or Redis') + .addSubcommand((subcommand) => + subcommand + .setName('database') + .setDescription('(Manager Only) Force reconnection to the database'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('redis') + .setDescription('(Manager Only) Force reconnection to Redis cache'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('status') + .setDescription( + '(Manager Only) Check connection status of database and Redis', + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + const member = await interaction.guild?.members.fetch(interaction.user.id); + const hasManagerRole = member?.roles.cache.has(managerRoleId || ''); + + if ( + !hasManagerRole && + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: + 'You do not have permission to use this command. This command is restricted to users with the Manager role.', + flags: ['Ephemeral'], + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + if (subcommand === 'database') { + await handleDatabaseReconnect(interaction); + } else if (subcommand === 'redis') { + await handleRedisReconnect(interaction); + } else if (subcommand === 'status') { + await handleStatusCheck(interaction); + } + } catch (error) { + console.error(`Error in reconnect command (${subcommand}):`, error); + await interaction.editReply({ + content: `An error occurred while processing the reconnect command: \`${error}\``, + }); + } + }, +}; + +/** + * Handle database reconnection + */ +async function handleDatabaseReconnect(interaction: CommandInteraction) { + await interaction.editReply('Attempting to reconnect to the database...'); + + try { + const success = await initializeDatabaseConnection(); + + if (success) { + await interaction.editReply( + '✅ **Database reconnection successful!** All database functions should now be operational.', + ); + + notifyManagers( + interaction.client, + NotificationType.DATABASE_CONNECTION_RESTORED, + `Database connection manually restored by ${interaction.user.tag}`, + ); + } else { + await interaction.editReply( + '❌ **Database reconnection failed.** Check the logs for more details.', + ); + } + } catch (error) { + console.error('Error reconnecting to database:', error); + await interaction.editReply( + `❌ **Database reconnection failed with error:** \`${error}\``, + ); + } +} + +/** + * Handle Redis reconnection + */ +async function handleRedisReconnect(interaction: CommandInteraction) { + await interaction.editReply('Attempting to reconnect to Redis...'); + + try { + const redisModule = await import('../../db/redis.js'); + + await redisModule.ensureRedisConnection(); + + const isConnected = redisModule.isRedisConnected(); + + if (isConnected) { + await interaction.editReply( + '✅ **Redis reconnection successful!** Cache functionality is now available.', + ); + + notifyManagers( + interaction.client, + NotificationType.REDIS_CONNECTION_RESTORED, + `Redis connection manually restored by ${interaction.user.tag}`, + ); + } else { + await interaction.editReply( + '❌ **Redis reconnection failed.** The bot will continue to function without caching capabilities.', + ); + } + } catch (error) { + console.error('Error reconnecting to Redis:', error); + await interaction.editReply( + `❌ **Redis reconnection failed with error:** \`${error}\``, + ); + } +} + +/** + * Handle status check for both services + */ +async function handleStatusCheck(interaction: any) { + await interaction.editReply('Checking connection status...'); + + try { + const dbStatus = await (async () => { + try { + await ensureDbInitialized(); + return true; + } catch { + return false; + } + })(); + + const redisStatus = isRedisConnected(); + + const statusEmbed = { + title: '🔌 Service Connection Status', + fields: [ + { + name: 'Database', + value: dbStatus ? '✅ Connected' : '❌ Disconnected', + inline: true, + }, + { + name: 'Redis Cache', + value: redisStatus + ? '✅ Connected' + : '⚠️ Disconnected (caching disabled)', + inline: true, + }, + ], + color: + dbStatus && redisStatus ? 0x00ff00 : dbStatus ? 0xffaa00 : 0xff0000, + timestamp: new Date().toISOString(), + }; + + await interaction.editReply({ content: '', embeds: [statusEmbed] }); + } catch (error) { + console.error('Error checking connection status:', error); + await interaction.editReply( + `❌ **Error checking connection status:** \`${error}\``, + ); + } +} + +export default command; diff --git a/src/commands/util/restart.ts b/src/commands/util/restart.ts new file mode 100644 index 0000000..bf7afb4 --- /dev/null +++ b/src/commands/util/restart.ts @@ -0,0 +1,93 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +import { Command } from '../../types/CommandTypes.js'; +import { loadConfig } from '../../util/configLoader.js'; +import { + NotificationType, + notifyManagers, +} from '../../util/notificationHandler.js'; +import { isRedisConnected } from '../../db/redis.js'; +import { ensureDatabaseConnection } from '../../db/db.js'; + +const execAsync = promisify(exec); + +const command: Command = { + data: new SlashCommandBuilder() + .setName('restart') + .setDescription('(Manager Only) Restart the bot'), + execute: async (interaction) => { + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + const member = await interaction.guild?.members.fetch(interaction.user.id); + const hasManagerRole = member?.roles.cache.has(managerRoleId || ''); + + if ( + !hasManagerRole && + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: + 'You do not have permission to restart the bot. This command is restricted to users with the Manager role.', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.reply({ + content: 'Restarting the bot... This may take a few moments.', + flags: ['Ephemeral'], + }); + + const dbConnected = await ensureDatabaseConnection(); + const redisConnected = isRedisConnected(); + let statusInfo = ''; + + if (!dbConnected) { + statusInfo += '⚠️ Database is currently disconnected\n'; + } + + if (!redisConnected) { + statusInfo += '⚠️ Redis caching is currently unavailable\n'; + } + + if (dbConnected && redisConnected) { + statusInfo = '✅ All services are operational\n'; + } + + await notifyManagers( + interaction.client, + NotificationType.BOT_RESTARTING, + `Restart initiated by ${interaction.user.tag}\n\nCurrent service status:\n${statusInfo}`, + ); + + setTimeout(async () => { + try { + console.log( + `Bot restart initiated by ${interaction.user.tag} (${interaction.user.id})`, + ); + + await execAsync('yarn restart'); + } catch (error) { + console.error('Failed to restart the bot:', error); + try { + await interaction.followUp({ + content: + 'Failed to restart the bot. Check the console for details.', + flags: ['Ephemeral'], + }); + } catch { + // If this fails too, we can't do much + } + } + }, 1000); + }, +}; + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index b47d689..23e57f2 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,21 +1,34 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; +import { Client, Collection, GuildMember } from 'discord.js'; import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; import { calculateLevelFromXp } from '../util/levelingSystem.js'; +import { + logManagerNotification, + NotificationType, + notifyManagers, +} from '../util/notificationHandler.js'; const { Pool } = pkg; const config = loadConfig(); -const dbPool = new Pool({ - connectionString: config.dbConnectionString, - ssl: true, -}); -export const db = drizzle({ client: dbPool, schema }); +// Database connection state +let isDbConnected = false; +let connectionAttempts = 0; +const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts; +const INITIAL_DB_RETRY_DELAY = config.database.retryDelay; +let hasNotifiedDbDisconnect = false; +let discordClient: Client | null = null; +let dbPool: pkg.Pool; +export let db: ReturnType; +/** + * Custom error class for database errors + */ class DatabaseError extends Error { constructor( message: string, @@ -26,121 +39,358 @@ class DatabaseError extends Error { } } +/** + * Sets the Discord client for sending notifications + * @param client - The Discord client + */ +export function setDiscordClient(client: Client): void { + discordClient = client; +} + +/** + * Initializes the database connection with retry logic + */ +export async function initializeDatabaseConnection(): Promise { + try { + if (dbPool) { + try { + await dbPool.query('SELECT 1'); + isDbConnected = true; + return true; + } catch (error) { + console.warn( + 'Existing database connection is not responsive, creating a new one', + ); + try { + await dbPool.end(); + } catch (endError) { + console.error('Error ending pool:', endError); + } + } + } + + // Log the database connection string (without sensitive info) + console.log( + `Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`, + ); + + dbPool = new Pool({ + connectionString: config.database.dbConnectionString, + ssl: true, + connectionTimeoutMillis: 10000, + }); + + await dbPool.query('SELECT 1'); + + db = drizzle({ client: dbPool, schema }); + + console.info('Successfully connected to database'); + isDbConnected = true; + connectionAttempts = 0; + + if (hasNotifiedDbDisconnect && discordClient) { + logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED); + notifyManagers( + discordClient, + NotificationType.DATABASE_CONNECTION_RESTORED, + ); + hasNotifiedDbDisconnect = false; + } + + return true; + } catch (error) { + console.error('Failed to connect to database:', error); + isDbConnected = false; + connectionAttempts++; + + if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) { + if (!hasNotifiedDbDisconnect && discordClient) { + const message = `Failed to connect to database after ${connectionAttempts} attempts.`; + console.error(message); + logManagerNotification( + NotificationType.DATABASE_CONNECTION_LOST, + `Error: ${error}`, + ); + notifyManagers( + discordClient, + NotificationType.DATABASE_CONNECTION_LOST, + `Connection attempts exhausted after ${connectionAttempts} tries. The bot cannot function without database access and will now terminate.`, + ); + hasNotifiedDbDisconnect = true; + } + + setTimeout(() => { + console.error('Database connection failed, shutting down bot'); + process.exit(1); + }, 3000); + + return false; + } + + // Try to reconnect after delay with exponential backoff + const delay = Math.min( + INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1), + 30000, + ); + console.log( + `Retrying database connection in ${delay}ms... (Attempt ${connectionAttempts}/${MAX_DB_RETRY_ATTEMPTS})`, + ); + + setTimeout(initializeDatabaseConnection, delay); + + return false; + } +} + +// Replace existing initialization with a properly awaited one +let dbInitPromise = initializeDatabaseConnection().catch((error) => { + console.error('Failed to initialize database connection:', error); + process.exit(1); +}); + +/** + * Ensures the database is initialized and returns a promise + * @returns Promise for database initialization + */ +export async function ensureDbInitialized(): Promise { + await dbInitPromise; + + if (!isDbConnected) { + dbInitPromise = initializeDatabaseConnection(); + await dbInitPromise; + } +} + +/** + * Checks if the database connection is active and working + * @returns Promise resolving to true if connected, false otherwise + */ +export async function ensureDatabaseConnection(): Promise { + await ensureDbInitialized(); + + if (!isDbConnected) { + return await initializeDatabaseConnection(); + } + + try { + await dbPool.query('SELECT 1'); + return true; + } catch (error) { + console.error('Database connection test failed:', error); + isDbConnected = false; + return await initializeDatabaseConnection(); + } +} + +// ======================== +// Helper functions +// ======================== + +/** + * Generic error handler for database operations + * @param errorMessage - Error message to log + * @param error - Original error object + */ +export const handleDbError = (errorMessage: string, error: Error): never => { + console.error(`${errorMessage}: `, error); + + if ( + error.message.includes('connection') || + error.message.includes('connect') + ) { + isDbConnected = false; + ensureDatabaseConnection().catch((err) => { + console.error('Failed to reconnect to database:', err); + }); + } + + throw new DatabaseError(errorMessage, error); +}; + +/** + * Checks and retrieves cached data or fetches from database + * @param cacheKey - Key to check in cache + * @param dbFetch - Function to fetch data from database + * @param ttl - Time to live for cache + * @returns Cached or fetched data + */ +async function withCache( + cacheKey: string, + dbFetch: () => Promise, + ttl?: number, +): Promise { + try { + const cachedData = await getJson(cacheKey); + if (cachedData !== null) { + return cachedData; + } + } catch (error) { + console.warn( + `Cache retrieval failed for ${cacheKey}, falling back to database:`, + error, + ); + } + + const data = await dbFetch(); + + try { + await setJson(cacheKey, data, ttl); + } catch (error) { + console.warn(`Failed to cache data for ${cacheKey}:`, error); + } + + return data; +} + +/** + * Invalidates a cache key if it exists + * @param cacheKey - Key to invalidate + */ +async function invalidateCache(cacheKey: string): Promise { + try { + if (await exists(cacheKey)) { + await del(cacheKey); + } + } catch (error) { + console.warn(`Error invalidating cache for key ${cacheKey}:`, error); + } +} + +// ======================== +// Member Functions +// ======================== + +/** + * Get all non-bot members currently in the server + * @returns Array of member objects + */ export async function getAllMembers() { try { - if (await exists('nonBotMembers')) { - const memberData = - await getJson<(typeof schema.memberTable.$inferSelect)[]>( - 'nonBotMembers', - ); - if (memberData && memberData.length > 0) { - return memberData; - } else { - await del('nonBotMembers'); - return await getAllMembers(); - } - } else { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get members'); + } + + const cacheKey = 'nonBotMembers'; + return await withCache(cacheKey, async () => { const nonBotMembers = await db .select() .from(schema.memberTable) .where(eq(schema.memberTable.currentlyInServer, true)); - await setJson<(typeof schema.memberTable.$inferSelect)[]>( - 'nonBotMembers', - nonBotMembers, - ); return nonBotMembers; - } - } catch (error) { - console.error('Error getting all members: ', error); - throw new DatabaseError('Failed to get all members: ', error as Error); - } -} - -export async function setMembers(nonBotMembers: any) { - try { - nonBotMembers.forEach(async (member: any) => { - const memberInfo = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.discordId, member.user.id)); - if (memberInfo.length > 0) { - await updateMember({ - discordId: member.user.id, - discordUsername: member.user.username, - currentlyInServer: true, - }); - } else { - const members: typeof schema.memberTable.$inferInsert = { - discordId: member.user.id, - discordUsername: member.user.username, - }; - await db.insert(schema.memberTable).values(members); - } }); } catch (error) { - console.error('Error setting members: ', error); - throw new DatabaseError('Failed to set members: ', error as Error); + return handleDbError('Failed to get all members', error as Error); } } -export async function getMember(discordId: string) { +/** + * Set or update multiple members at once + * @param nonBotMembers - Array of member objects + */ +export async function setMembers( + nonBotMembers: Collection, +): Promise { try { - if (await exists(`${discordId}-memberInfo`)) { - const cachedMember = await getJson< - typeof schema.memberTable.$inferSelect - >(`${discordId}-memberInfo`); - const cachedModerationHistory = await getJson< - (typeof schema.moderationTable.$inferSelect)[] - >(`${discordId}-moderationHistory`); + await ensureDbInitialized(); - if ( - cachedMember && - 'discordId' in cachedMember && - cachedModerationHistory && - cachedModerationHistory.length > 0 - ) { - return { - ...cachedMember, - moderations: cachedModerationHistory, - }; - } else { - await del(`${discordId}-memberInfo`); - await del(`${discordId}-moderationHistory`); - return await getMember(discordId); - } - } else { - const member = await db.query.memberTable.findFirst({ - where: eq(schema.memberTable.discordId, discordId), - with: { - moderations: true, - }, - }); - - await setJson( - `${discordId}-memberInfo`, - member!, - ); - await setJson<(typeof schema.moderationTable.$inferSelect)[]>( - `${discordId}-moderationHistory`, - member!.moderations, - ); - - return member; + if (!db) { + console.error('Database not initialized, cannot set members'); } + + await Promise.all( + nonBotMembers.map(async (member) => { + const memberInfo = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, member.user.id)); + + if (memberInfo.length > 0) { + await updateMember({ + discordId: member.user.id, + discordUsername: member.user.username, + currentlyInServer: true, + }); + } else { + const members: typeof schema.memberTable.$inferInsert = { + discordId: member.user.id, + discordUsername: member.user.username, + }; + await db.insert(schema.memberTable).values(members); + } + }), + ); } catch (error) { - console.error('Error getting member: ', error); - throw new DatabaseError('Failed to get member: ', error as Error); + handleDbError('Failed to set members', error as Error); } } +/** + * Get detailed information about a specific member including moderation history + * @param discordId - Discord ID of the user + * @returns Member object with moderation history + */ +export async function getMember( + discordId: string, +): Promise< + | (schema.memberTableTypes & { moderations: schema.moderationTableTypes[] }) + | undefined +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get member'); + } + + const cacheKey = `${discordId}-memberInfo`; + + const member = await withCache( + cacheKey, + async () => { + const memberData = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, discordId)) + .then((rows) => rows[0]); + + return memberData as schema.memberTableTypes; + }, + ); + + const moderations = await getMemberModerationHistory(discordId); + + return { + ...member, + moderations, + }; + } catch (error) { + return handleDbError('Failed to get member', error as Error); + } +} + +/** + * Update a member's information in the database + * @param discordId - Discord ID of the user + * @param discordUsername - New username of the member + * @param currentlyInServer - Whether the member is currently in the server + * @param currentlyBanned - Whether the member is currently banned + */ export async function updateMember({ discordId, discordUsername, currentlyInServer, currentlyBanned, -}: schema.memberTableTypes) { +}: schema.memberTableTypes): Promise { try { - const result = await db + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot update member'); + } + + await db .update(schema.memberTable) .set({ discordUsername, @@ -149,193 +399,252 @@ export async function updateMember({ }) .where(eq(schema.memberTable.discordId, discordId)); - if (await exists(`${discordId}-memberInfo`)) { - await del(`${discordId}-memberInfo`); - } - if (await exists('nonBotMembers')) { - await del('nonBotMembers'); - } - - return result; + await Promise.all([ + invalidateCache(`${discordId}-memberInfo`), + invalidateCache('nonBotMembers'), + ]); } catch (error) { - console.error('Error updating member: ', error); - throw new DatabaseError('Failed to update member: ', error as Error); + handleDbError('Failed to update member', error as Error); } } +// ======================== +// Level & XP Functions +// ======================== + +/** + * Get user level information or create a new entry if not found + * @param discordId - Discord ID of the user + * @returns User level object + */ export async function getUserLevel( discordId: string, ): Promise { try { - if (await exists(`level-${discordId}`)) { - const cachedLevel = await getJson( - `level-${discordId}`, - ); - if (cachedLevel !== null) { - return cachedLevel; + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user level'); + } + + const cacheKey = `level-${discordId}`; + + return await withCache(cacheKey, async () => { + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); + + if (level) { + return { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; } - await del(`level-${discordId}`); - } - const level = await db - .select() - .from(schema.levelTable) - .where(eq(schema.levelTable.discordId, discordId)) - .then((rows) => rows[0]); - - if (level) { - const typedLevel: schema.levelTableTypes = { - ...level, - lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + const newLevel: schema.levelTableTypes = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), }; - await setJson(`level-${discordId}`, typedLevel); - return typedLevel; - } - const newLevel = { - discordId, - xp: 0, - level: 0, - lastMessageTimestamp: new Date(), - }; - - await db.insert(schema.levelTable).values(newLevel); - await setJson(`level-${discordId}`, newLevel); - return newLevel; + await db.insert(schema.levelTable).values(newLevel); + return newLevel; + }); } catch (error) { - console.error('Error getting user level:', error); - throw error; + return handleDbError('Error getting user level', error as Error); } } -export async function addXpToUser(discordId: string, amount: number) { +/** + * Add XP to a user, updating their level if necessary + * @param discordId - Discord ID of the user + * @param amount - Amount of XP to add + */ +export async function addXpToUser( + discordId: string, + amount: number, +): Promise<{ + leveledUp: boolean; + newLevel: number; + oldLevel: number; +}> { try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add xp to user'); + } + + const cacheKey = `level-${discordId}`; const userData = await getUserLevel(discordId); const currentLevel = userData.level; userData.xp += amount; userData.lastMessageTimestamp = new Date(); - - const newLevel = calculateLevelFromXp(userData.xp); - userData.level = newLevel; - - await db - .update(schema.levelTable) - .set({ - xp: userData.xp, - level: newLevel, - lastMessageTimestamp: userData.lastMessageTimestamp, - }) - .where(eq(schema.levelTable.discordId, discordId)); - - await setJson(`level-${discordId}`, userData); + userData.level = calculateLevelFromXp(userData.xp); await invalidateLeaderboardCache(); + await invalidateCache(cacheKey); + await withCache( + cacheKey, + async () => { + const result = await db + .update(schema.levelTable) + .set({ + xp: userData.xp, + level: userData.level, + lastMessageTimestamp: userData.lastMessageTimestamp, + }) + .where(eq(schema.levelTable.discordId, discordId)) + .returning(); + + return result[0] as schema.levelTableTypes; + }, + 300, + ); return { - leveledUp: newLevel > currentLevel, - newLevel, + leveledUp: userData.level > currentLevel, + newLevel: userData.level, oldLevel: currentLevel, }; } catch (error) { - console.error('Error adding XP to user:', error); - throw error; + return handleDbError('Error adding XP to user', error as Error); } } +/** + * Get a user's rank on the XP leaderboard + * @param discordId - Discord ID of the user + * @returns User's rank on the leaderboard + */ export async function getUserRank(discordId: string): Promise { try { - if (await exists('xp-leaderboard-cache')) { - const leaderboardCache = await getJson< - Array<{ discordId: string; xp: number }> - >('xp-leaderboard-cache'); + await ensureDbInitialized(); - if (leaderboardCache) { - const userIndex = leaderboardCache.findIndex( - (member) => member.discordId === discordId, - ); + if (!db) { + console.error('Database not initialized, cannot get user rank'); + } - if (userIndex !== -1) { - return userIndex + 1; - } + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; } } - const allMembers = await db - .select({ - discordId: schema.levelTable.discordId, - xp: schema.levelTable.xp, - }) - .from(schema.levelTable) - .orderBy(desc(schema.levelTable.xp)); - - await setJson('xp-leaderboard-cache', allMembers, 300); - - const userIndex = allMembers.findIndex( - (member) => member.discordId === discordId, - ); - - return userIndex !== -1 ? userIndex + 1 : 1; - } catch (error) { - console.error('Error getting user rank:', error); return 1; - } -} - -export async function invalidateLeaderboardCache() { - try { - if (await exists('xp-leaderboard-cache')) { - await del('xp-leaderboard-cache'); - } } catch (error) { - console.error('Error invalidating leaderboard cache:', error); + return handleDbError('Failed to get user rank', error as Error); } } -export async function getLevelLeaderboard(limit = 10) { +/** + * Clear leaderboard cache + */ +export async function invalidateLeaderboardCache(): Promise { + await invalidateCache('xp-leaderboard-cache'); +} + +/** + * Helper function to get or create leaderboard data + * @returns Array of leaderboard data + */ +async function getLeaderboardData(): Promise< + Array<{ + discordId: string; + xp: number; + }> +> { try { - if (await exists('xp-leaderboard-cache')) { - const leaderboardCache = await getJson< - Array<{ discordId: string; xp: number }> - >('xp-leaderboard-cache'); + await ensureDbInitialized(); - if (leaderboardCache) { - const limitedCache = leaderboardCache.slice(0, limit); - - const fullLeaderboard = await Promise.all( - limitedCache.map(async (entry) => { - const userData = await getUserLevel(entry.discordId); - return userData; - }), - ); - - return fullLeaderboard; - } + if (!db) { + console.error('Database not initialized, cannot get leaderboard data'); } - const leaderboard = await db + const cacheKey = 'xp-leaderboard-cache'; + return withCache>( + cacheKey, + async () => { + return await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + }, + 300, + ); + } catch (error) { + return handleDbError('Failed to get leaderboard data', error as Error); + } +} + +/** + * Get the XP leaderboard + * @param limit - Number of entries to return + * @returns Array of leaderboard entries + */ +export async function getLevelLeaderboard( + limit = 10, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get level leaderboard'); + } + + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const limitedCache = leaderboardCache.slice(0, limit); + + const fullLeaderboard = await Promise.all( + limitedCache.map(async (entry) => { + const userData = await getUserLevel(entry.discordId); + return userData; + }), + ); + + return fullLeaderboard; + } + + return (await db .select() .from(schema.levelTable) .orderBy(desc(schema.levelTable.xp)) - .limit(limit); - - const allMembers = await db - .select({ - discordId: schema.levelTable.discordId, - xp: schema.levelTable.xp, - }) - .from(schema.levelTable) - .orderBy(desc(schema.levelTable.xp)); - - await setJson('xp-leaderboard-cache', allMembers, 300); - - return leaderboard; + .limit(limit)) as schema.levelTableTypes[]; } catch (error) { - console.error('Error getting leaderboard:', error); - throw error; + return handleDbError('Failed to get leaderboard', error as Error); } } +// ======================== +// Moderation Functions +// ======================== + +/** + * Add a new moderation action to a member's history + * @param discordId - Discord ID of the user + * @param moderatorDiscordId - Discord ID of the moderator + * @param action - Type of action taken + * @param reason - Reason for the action + * @param duration - Duration of the action + * @param createdAt - Timestamp of when the action was taken + * @param expiresAt - Timestamp of when the action expires + * @param active - Wether the action is active or not + */ export async function updateMemberModerationHistory({ discordId, moderatorDiscordId, @@ -345,8 +654,16 @@ export async function updateMemberModerationHistory({ createdAt, expiresAt, active, -}: schema.moderationTableTypes) { +}: schema.moderationTableTypes): Promise { try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, update member moderation history', + ); + } + const moderationEntry = { discordId, moderatorDiscordId, @@ -357,111 +674,138 @@ export async function updateMemberModerationHistory({ expiresAt, active, }; - const result = await db - .insert(schema.moderationTable) - .values(moderationEntry); - if (await exists(`${discordId}-moderationHistory`)) { - await del(`${discordId}-moderationHistory`); - } - if (await exists(`${discordId}-memberInfo`)) { - await del(`${discordId}-memberInfo`); - } + await db.insert(schema.moderationTable).values(moderationEntry); - return result; + await Promise.all([ + invalidateCache(`${discordId}-moderationHistory`), + invalidateCache(`${discordId}-memberInfo`), + ]); } catch (error) { - console.error('Error updating moderation history: ', error); - throw new DatabaseError( - 'Failed to update moderation history: ', - error as Error, - ); + handleDbError('Failed to update moderation history', error as Error); } } -export async function getMemberModerationHistory(discordId: string) { +/** + * Get a member's moderation history + * @param discordId - Discord ID of the user + * @returns Array of moderation actions + */ +export async function getMemberModerationHistory( + discordId: string, +): Promise { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot get member moderation history', + ); + } + + const cacheKey = `${discordId}-moderationHistory`; + try { - if (await exists(`${discordId}-moderationHistory`)) { - return await getJson<(typeof schema.moderationTable.$inferSelect)[]>( - `${discordId}-moderationHistory`, - ); - } else { - const moderationHistory = await db - .select() - .from(schema.moderationTable) - .where(eq(schema.moderationTable.discordId, discordId)); - - await setJson<(typeof schema.moderationTable.$inferSelect)[]>( - `${discordId}-moderationHistory`, - moderationHistory, - ); - return moderationHistory; - } - } catch (error) { - console.error('Error getting moderation history: ', error); - throw new DatabaseError( - 'Failed to get moderation history: ', - error as Error, + return await withCache( + cacheKey, + async () => { + const history = await db + .select() + .from(schema.moderationTable) + .where(eq(schema.moderationTable.discordId, discordId)); + return history as schema.moderationTableTypes[]; + }, ); + } catch (error) { + return handleDbError('Failed to get moderation history', error as Error); } } +// ======================== +// Fact Functions +// ======================== + +/** + * Add a new fact to the database + * @param content - Content of the fact + * @param source - Source of the fact + * @param addedBy - Discord ID of the user who added the fact + * @param approved - Whether the fact is approved or not + */ export async function addFact({ content, source, addedBy, approved = false, -}: schema.factTableTypes) { +}: schema.factTableTypes): Promise { try { - const result = await db.insert(schema.factTable).values({ + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add fact'); + } + + await db.insert(schema.factTable).values({ content, source, addedBy, approved, }); - await del('unusedFacts'); - return result; + await invalidateCache('unused-facts'); } catch (error) { - console.error('Error adding fact:', error); - throw new DatabaseError('Failed to add fact:', error as Error); + handleDbError('Failed to add fact', error as Error); } } +/** + * Get the ID of the most recently added fact + * @returns ID of the last inserted fact + */ export async function getLastInsertedFactId(): Promise { try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get last inserted fact'); + } + const result = await db .select({ id: sql`MAX(${schema.factTable.id})` }) .from(schema.factTable); return result[0]?.id ?? 0; } catch (error) { - console.error('Error getting last inserted fact ID:', error); - throw new DatabaseError( - 'Failed to get last inserted fact ID:', - error as Error, - ); + return handleDbError('Failed to get last inserted fact ID', error as Error); } } -export async function getRandomUnusedFact() { +/** + * Get a random fact that hasn't been used yet + * @returns Random fact object + */ +export async function getRandomUnusedFact(): Promise { try { - if (await exists('unusedFacts')) { - const facts = - await getJson<(typeof schema.factTable.$inferSelect)[]>('unusedFacts'); - if (facts && facts.length > 0) { - return facts[Math.floor(Math.random() * facts.length)]; - } + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get random unused fact'); } - const facts = await db - .select() - .from(schema.factTable) - .where( - and( - eq(schema.factTable.approved, true), - isNull(schema.factTable.usedOn), - ), - ); + const cacheKey = 'unused-facts'; + const facts = await withCache( + cacheKey, + async () => { + return (await db + .select() + .from(schema.factTable) + .where( + and( + eq(schema.factTable.approved, true), + isNull(schema.factTable.usedOn), + ), + )) as schema.factTableTypes[]; + }, + ); if (facts.length === 0) { await db @@ -469,67 +813,101 @@ export async function getRandomUnusedFact() { .set({ usedOn: null }) .where(eq(schema.factTable.approved, true)); + await invalidateCache(cacheKey); return await getRandomUnusedFact(); } - await setJson<(typeof schema.factTable.$inferSelect)[]>( - 'unusedFacts', - facts, - ); - return facts[Math.floor(Math.random() * facts.length)]; + return facts[ + Math.floor(Math.random() * facts.length) + ] as schema.factTableTypes; } catch (error) { - console.error('Error getting random fact:', error); - throw new DatabaseError('Failed to get random fact:', error as Error); + return handleDbError('Failed to get random fact', error as Error); } } -export async function markFactAsUsed(id: number) { +/** + * Mark a fact as used + * @param id - ID of the fact to mark as used + */ +export async function markFactAsUsed(id: number): Promise { try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot mark fact as used'); + } + await db .update(schema.factTable) .set({ usedOn: new Date() }) .where(eq(schema.factTable.id, id)); - await del('unusedFacts'); + await invalidateCache('unused-facts'); } catch (error) { - console.error('Error marking fact as used:', error); - throw new DatabaseError('Failed to mark fact as used:', error as Error); + handleDbError('Failed to mark fact as used', error as Error); } } -export async function getPendingFacts() { +/** + * Get all pending facts that need approval + * @returns Array of pending fact objects + */ +export async function getPendingFacts(): Promise { try { - return await db + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get pending facts'); + } + + return (await db .select() .from(schema.factTable) - .where(eq(schema.factTable.approved, false)); + .where(eq(schema.factTable.approved, false))) as schema.factTableTypes[]; } catch (error) { - console.error('Error getting pending facts:', error); - throw new DatabaseError('Failed to get pending facts:', error as Error); + return handleDbError('Failed to get pending facts', error as Error); } } -export async function approveFact(id: number) { +/** + * Approve a fact + * @param id - ID of the fact to approve + */ +export async function approveFact(id: number): Promise { try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot approve fact'); + } + await db .update(schema.factTable) .set({ approved: true }) .where(eq(schema.factTable.id, id)); - await del('unusedFacts'); + await invalidateCache('unused-facts'); } catch (error) { - console.error('Error approving fact:', error); - throw new DatabaseError('Failed to approve fact:', error as Error); + handleDbError('Failed to approve fact', error as Error); } } -export async function deleteFact(id: number) { +/** + * Delete a fact + * @param id - ID of the fact to delete + */ +export async function deleteFact(id: number): Promise { try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete fact'); + } + await db.delete(schema.factTable).where(eq(schema.factTable.id, id)); - await del('unusedFacts'); + await invalidateCache('unused-facts'); } catch (error) { - console.error('Error deleting fact:', error); - throw new DatabaseError('Failed to delete fact:', error as Error); + return handleDbError('Failed to delete fact', error as Error); } } diff --git a/src/db/redis.ts b/src/db/redis.ts index e513072..6b58f6b 100644 --- a/src/db/redis.ts +++ b/src/db/redis.ts @@ -1,9 +1,31 @@ import Redis from 'ioredis'; +import { Client } from 'discord.js'; + import { loadConfig } from '../util/configLoader.js'; +import { + logManagerNotification, + NotificationType, + notifyManagers, +} from '../util/notificationHandler.js'; const config = loadConfig(); -const redis = new Redis(config.redisConnectionString); +// Redis connection state +let isRedisAvailable = false; +let redis: Redis; +let connectionAttempts = 0; +const MAX_RETRY_ATTEMPTS = config.redis.retryAttempts; +const INITIAL_RETRY_DELAY = config.redis.initialRetryDelay; +let hasNotifiedDisconnect = false; +let discordClient: Client | null = null; + +// ======================== +// Redis Utility Classes and Helper Functions +// ======================== + +/** + * Custom error class for Redis errors + */ class RedisError extends Error { constructor( message: string, @@ -14,77 +36,271 @@ class RedisError extends Error { } } -redis.on('error', (error: Error) => { - console.error('Redis connection error:', error); - throw new RedisError('Failed to connect to Redis instance: ', error); -}); +/** + * Redis error handler + * @param errorMessage - The error message to log + * @param error - The error object + */ +const handleRedisError = (errorMessage: string, error: Error): null => { + console.error(`${errorMessage}:`, error); + throw new RedisError(errorMessage, error); +}; -redis.on('connect', () => { - console.log('Successfully connected to Redis'); -}); +/** + * Sets the Discord client for sending notifications + * @param client - The Discord client + */ +export function setDiscordClient(client: Client): void { + discordClient = client; +} +/** + * Initializes the Redis connection with retry logic + */ +async function initializeRedisConnection() { + try { + if (redis && redis.status !== 'end' && redis.status !== 'close') { + return; + } + + redis = new Redis(config.redis.redisConnectionString, { + retryStrategy(times) { + connectionAttempts = times; + if (times >= MAX_RETRY_ATTEMPTS) { + const message = `Failed to connect to Redis after ${times} attempts. Caching will be disabled.`; + console.warn(message); + + if (!hasNotifiedDisconnect && discordClient) { + logManagerNotification(NotificationType.REDIS_CONNECTION_LOST); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_LOST, + `Connection attempts exhausted after ${times} tries. Caching is now disabled.`, + ); + hasNotifiedDisconnect = true; + } + + return null; + } + + const delay = Math.min(INITIAL_RETRY_DELAY * Math.pow(2, times), 30000); + console.log( + `Retrying Redis connection in ${delay}ms... (Attempt ${times + 1}/${MAX_RETRY_ATTEMPTS})`, + ); + return delay; + }, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, + }); + + // ======================== + // Redis Events + // ======================== + redis.on('error', (error: Error) => { + console.error('Redis Connection Error:', error); + isRedisAvailable = false; + }); + + redis.on('connect', () => { + console.info('Successfully connected to Redis'); + isRedisAvailable = true; + connectionAttempts = 0; + + if (hasNotifiedDisconnect && discordClient) { + logManagerNotification(NotificationType.REDIS_CONNECTION_RESTORED); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_RESTORED, + ); + hasNotifiedDisconnect = false; + } + }); + + redis.on('close', () => { + console.warn('Redis connection closed'); + isRedisAvailable = false; + + // Try to reconnect after some time if we've not exceeded max attempts + if (connectionAttempts < MAX_RETRY_ATTEMPTS) { + const delay = Math.min( + INITIAL_RETRY_DELAY * Math.pow(2, connectionAttempts), + 30000, + ); + setTimeout(initializeRedisConnection, delay); + } else if (!hasNotifiedDisconnect && discordClient) { + logManagerNotification(NotificationType.REDIS_CONNECTION_LOST); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_LOST, + 'Connection closed and max retry attempts reached.', + ); + hasNotifiedDisconnect = true; + } + }); + + redis.on('reconnecting', () => { + console.info('Attempting to reconnect to Redis...'); + }); + } catch (error) { + console.error('Failed to initialize Redis:', error); + isRedisAvailable = false; + + if (!hasNotifiedDisconnect && discordClient) { + logManagerNotification( + NotificationType.REDIS_CONNECTION_LOST, + `Error: ${error}`, + ); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_LOST, + `Initialization error: ${error}`, + ); + hasNotifiedDisconnect = true; + } + } +} + +// Initialize Redis connection +initializeRedisConnection(); + +/** + * Check if Redis is currently available, and attempt to reconnect if not + * @returns - True if Redis is connected and available + */ +export async function ensureRedisConnection(): Promise { + if (!isRedisAvailable) { + await initializeRedisConnection(); + } + return isRedisAvailable; +} + +// ======================== +// Redis Functions +// ======================== + +/** + * Function to set a key in Redis + * @param key - The key to set + * @param value - The value to set + * @param ttl - The time to live for the key + * @returns - 'OK' if successful + */ export async function set( key: string, value: string, ttl?: number, -): Promise<'OK'> { - try { - await redis.set(key, value); - if (ttl) await redis.expire(key, ttl); - } catch (error) { - console.error('Redis set error: ', error); - throw new RedisError(`Failed to set key: ${key}, `, error as Error); +): Promise<'OK' | null> { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping set operation'); + return null; + } + + try { + await redis.set(`bot:${key}`, value); + if (ttl) await redis.expire(`bot:${key}`, ttl); + return 'OK'; + } catch (error) { + return handleRedisError(`Failed to set key: ${key}`, error as Error); } - return Promise.resolve('OK'); } +/** + * Function to set a key in Redis with a JSON value + * @param key - The key to set + * @param value - The value to set + * @param ttl - The time to live for the key + * @returns - 'OK' if successful + */ export async function setJson( key: string, value: T, ttl?: number, -): Promise<'OK'> { +): Promise<'OK' | null> { return await set(key, JSON.stringify(value), ttl); } -export async function incr(key: string): Promise { +/** + * Increments a key in Redis + * @param key - The key to increment + * @returns - The new value of the key, or null if Redis is unavailable + */ +export async function incr(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping increment operation'); + return null; + } + try { - return await redis.incr(key); + return await redis.incr(`bot:${key}`); } catch (error) { - console.error('Redis increment error: ', error); - throw new RedisError(`Failed to increment key: ${key}, `, error as Error); + return handleRedisError(`Failed to increment key: ${key}`, error as Error); } } -export async function exists(key: string): Promise { +/** + * Checks if a key exists in Redis + * @param key - The key to check + * @returns - True if the key exists, false otherwise, or null if Redis is unavailable + */ +export async function exists(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping exists operation'); + return null; + } + try { - return (await redis.exists(key)) === 1; + return (await redis.exists(`bot:${key}`)) === 1; } catch (error) { - console.error('Redis exists error: ', error); - throw new RedisError( - `Failed to check if key exists: ${key}, `, + return handleRedisError( + `Failed to check if key exists: ${key}`, error as Error, ); } } +/** + * Gets the value of a key in Redis + * @param key - The key to get + * @returns - The value of the key, or null if the key does not exist or Redis is unavailable + */ export async function get(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping get operation'); + return null; + } + try { - return await redis.get(key); + return await redis.get(`bot:${key}`); } catch (error) { - console.error('Redis get error: ', error); - throw new RedisError(`Failed to get key: ${key}, `, error as Error); + return handleRedisError(`Failed to get key: ${key}`, error as Error); } } -export async function mget(...keys: string[]): Promise<(string | null)[]> { +/** + * Gets the values of multiple keys in Redis + * @param keys - The keys to get + * @returns - The values of the keys, or null if Redis is unavailable + */ +export async function mget( + ...keys: string[] +): Promise<(string | null)[] | null> { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping mget operation'); + return null; + } + try { - return await redis.mget(keys); + return await redis.mget(...keys.map((key) => `bot:${key}`)); } catch (error) { - console.error('Redis mget error: ', error); - throw new RedisError(`Failed to get keys: ${keys}, `, error as Error); + return handleRedisError('Failed to get keys', error as Error); } } +/** + * Gets the value of a key in Redis and parses it as a JSON object + * @param key - The key to get + * @returns - The parsed JSON value of the key, or null if the key does not exist or Redis is unavailable + */ export async function getJson(key: string): Promise { const value = await get(key); if (!value) return null; @@ -95,11 +311,28 @@ export async function getJson(key: string): Promise { } } -export async function del(key: string): Promise { +/** + * Deletes a key in Redis + * @param key - The key to delete + * @returns - The number of keys that were deleted, or null if Redis is unavailable + */ +export async function del(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping delete operation'); + return null; + } + try { - return await redis.del(key); + return await redis.del(`bot:${key}`); } catch (error) { - console.error('Redis del error: ', error); - throw new RedisError(`Failed to delete key: ${key}, `, error as Error); + return handleRedisError(`Failed to delete key: ${key}`, error as Error); } } + +/** + * Check if Redis is currently available + * @returns - True if Redis is connected and available + */ +export function isRedisConnected(): boolean { + return isRedisAvailable; +} diff --git a/src/db/schema.ts b/src/db/schema.ts index fd6ddaa..61bdc94 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -75,6 +75,7 @@ export const memberRelations = relations(memberTable, ({ many, one }) => ({ fields: [memberTable.discordId], references: [levelTable.discordId], }), + facts: many(factTable), })); export const levelRelations = relations(levelTable, ({ one }) => ({ diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 10155dc..f25d2a3 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -63,7 +63,7 @@ export default { if (!interaction.memberPermissions?.has('ModerateMembers')) { await interaction.reply({ content: 'You do not have permission to approve facts.', - ephemeral: true, + flags: ['Ephemeral'], }); return; } @@ -79,7 +79,7 @@ export default { if (!interaction.memberPermissions?.has('ModerateMembers')) { await interaction.reply({ content: 'You do not have permission to reject facts.', - ephemeral: true, + flags: ['Ephemeral'], }); return; } diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index a6ddc80..671f99a 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -1,4 +1,10 @@ -import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js'; +import { + Collection, + Events, + Guild, + GuildMember, + PartialGuildMember, +} from 'discord.js'; import { updateMember, setMembers } from '../db/db.js'; import { generateMemberBanner } from '../util/helpers.js'; @@ -19,12 +25,9 @@ export const memberJoin: Event = { } try { - await setMembers([ - { - discordId: member.user.id, - discordUsername: member.user.username, - }, - ]); + const memberCollection = new Collection(); + memberCollection.set(member.user.id, member); + await setMembers(memberCollection); if (!member.user.bot) { const attachment = await generateMemberBanner({ diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 24b7a66..07d0595 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -84,7 +84,7 @@ export const messageCreate: Event = { advancementsChannelId, ); - if (!advancementsChannel || !advancementsChannel.isTextBased()) { + if (!advancementsChannel?.isTextBased()) { console.error( 'Advancements channel not found or is not a text channel', ); diff --git a/src/events/ready.ts b/src/events/ready.ts index b7c54ce..24c0ff7 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,16 +1,28 @@ import { Client, Events } from 'discord.js'; -import { setMembers } from '../db/db.js'; +import { ensureDbInitialized, setMembers } from '../db/db.js'; import { loadConfig } from '../util/configLoader.js'; import { Event } from '../types/EventTypes.js'; import { scheduleFactOfTheDay } from '../util/factManager.js'; +import { + ensureRedisConnection, + setDiscordClient as setRedisDiscordClient, +} from '../db/redis.js'; +import { setDiscordClient as setDbDiscordClient } from '../db/db.js'; + export default { name: Events.ClientReady, once: true, execute: async (client: Client) => { const config = loadConfig(); try { + setRedisDiscordClient(client); + setDbDiscordClient(client); + + await ensureDbInitialized(); + await ensureRedisConnection(); + const guild = client.guilds.cache.find( (guilds) => guilds.id === config.guildId, ); @@ -25,7 +37,7 @@ export default { await scheduleFactOfTheDay(client); } catch (error) { - console.error('Failed to initialize members in database:', error); + console.error('Failed to initialize the bot:', error); } console.log(`Ready! Logged in as ${client.user?.tag}`); diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts index 4dfba26..bf0d153 100644 --- a/src/structures/ExtendedClient.ts +++ b/src/structures/ExtendedClient.ts @@ -4,6 +4,9 @@ import { Config } from '../types/ConfigTypes.js'; import { deployCommands } from '../util/deployCommand.js'; import { registerEvents } from '../util/eventLoader.js'; +/** + * Extended client class that extends the default Client class + */ export class ExtendedClient extends Client { public commands: Collection; private config: Config; diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts index 406f3f7..002cd17 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -5,16 +5,25 @@ import { SlashCommandSubcommandsOnlyBuilder, } from 'discord.js'; +/** + * Command interface for normal commands + */ export interface Command { data: Omit; execute: (interaction: CommandInteraction) => Promise; } +/** + * Command interface for options commands + */ export interface OptionsCommand { data: SlashCommandOptionsOnlyBuilder; execute: (interaction: CommandInteraction) => Promise; } +/** + * Command interface for subcommand commands + */ export interface SubcommandCommand { data: SlashCommandSubcommandsOnlyBuilder; execute: (interaction: CommandInteraction) => Promise; diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index 96ed2dd..b38b126 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -1,9 +1,20 @@ +/** + * Config interface for the bot + */ export interface Config { token: string; clientId: string; guildId: string; - dbConnectionString: string; - redisConnectionString: string; + database: { + dbConnectionString: string; + maxRetryAttempts: number; + retryDelay: number; + }; + redis: { + redisConnectionString: string; + retryAttempts: number; + initialRetryDelay: number; + }; channels: { welcome: string; logs: string; @@ -24,4 +35,9 @@ export interface Config { }[]; factPingRole: string; }; + leveling: { + xpCooldown: number; + minXpAwarded: number; + maxXpAwarded: number; + }; } diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts index f07556d..df6cf7c 100644 --- a/src/types/EventTypes.ts +++ b/src/types/EventTypes.ts @@ -1,5 +1,8 @@ import { ClientEvents } from 'discord.js'; +/** + * Event interface for events + */ export interface Event { name: K; once?: boolean; diff --git a/src/util/configLoader.ts b/src/util/configLoader.ts index 497e5a0..4a847dc 100644 --- a/src/util/configLoader.ts +++ b/src/util/configLoader.ts @@ -2,6 +2,10 @@ import { Config } from '../types/ConfigTypes.js'; import fs from 'node:fs'; import path from 'node:path'; +/** + * Loads the config file from the root directory + * @returns - The loaded config object + */ export function loadConfig(): Config { try { const configPath = path.join(process.cwd(), './config.json'); diff --git a/src/util/countingManager.ts b/src/util/countingManager.ts index f910690..a3beb67 100644 --- a/src/util/countingManager.ts +++ b/src/util/countingManager.ts @@ -16,6 +16,10 @@ const MILESTONE_REACTIONS = { multiples100: '🎉', }; +/** + * Initializes the counting data if it doesn't exist + * @returns - The initialized counting data + */ export async function initializeCountingData(): Promise { const exists = await getJson('counting'); if (exists) return exists; @@ -31,6 +35,10 @@ export async function initializeCountingData(): Promise { return initialData; } +/** + * Gets the current counting data + * @returns - The current counting data + */ export async function getCountingData(): Promise { const data = await getJson('counting'); if (!data) { @@ -39,6 +47,10 @@ export async function getCountingData(): Promise { return data; } +/** + * Updates the counting data with new data + * @param data - The data to update the counting data with + */ export async function updateCountingData( data: Partial, ): Promise { @@ -47,6 +59,10 @@ export async function updateCountingData( await setJson('counting', updatedData); } +/** + * Resets the counting data to the initial state + * @returns - The current count + */ export async function resetCounting(): Promise { await updateCountingData({ currentCount: 0, @@ -55,6 +71,11 @@ export async function resetCounting(): Promise { return; } +/** + * Processes a counting message to determine if it is valid + * @param message - The message to process + * @returns - An object with information about the message + */ export async function processCountingMessage(message: Message): Promise<{ isValid: boolean; expectedCount?: number; @@ -125,6 +146,11 @@ export async function processCountingMessage(message: Message): Promise<{ } } +/** + * Adds counting reactions to a message based on the milestone type + * @param message - The message to add counting reactions to + * @param milestoneType - The type of milestone to add reactions for + */ export async function addCountingReactions( message: Message, milestoneType: keyof typeof MILESTONE_REACTIONS, @@ -140,11 +166,19 @@ export async function addCountingReactions( } } +/** + * Gets the current counting status + * @returns - A string with the current counting status + */ export async function getCountingStatus(): Promise { const data = await getCountingData(); return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`; } +/** + * Sets the current count to a specific number + * @param count - The number to set as the current count + */ export async function setCount(count: number): Promise { if (!Number.isInteger(count) || count < 0) { throw new Error('Count must be a non-negative integer.'); diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 98b6934..891734b 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -11,6 +11,11 @@ const commandsPath = path.join(__dirname, 'target', 'commands'); const rest = new REST({ version: '10' }).setToken(token); +/** + * Gets all files in the command directory and its subdirectories + * @param directory - The directory to get files from + * @returns - An array of file paths + */ const getFilesRecursively = (directory: string): string[] => { const files: string[] = []; const filesInDirectory = fs.readdirSync(directory); @@ -30,15 +35,21 @@ const getFilesRecursively = (directory: string): string[] => { const commandFiles = getFilesRecursively(commandsPath); +/** + * Registers all commands in the command directory with the Discord API + * @returns - An array of valid command objects + */ export const deployCommands = async () => { try { console.log( `Started refreshing ${commandFiles.length} application (/) commands...`, ); - const existingCommands = (await rest.get( - Routes.applicationGuildCommands(clientId, guildId), - )) as any[]; + console.log('Undeploying all existing commands...'); + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: [], + }); + console.log('Successfully undeployed all commands'); const commands = commandFiles.map(async (file) => { const commandModule = await import(`file://${file}`); @@ -64,18 +75,6 @@ export const deployCommands = async () => { const apiCommands = validCommands.map((command) => command.data.toJSON()); - const commandsToRemove = existingCommands.filter( - (existingCmd) => - !apiCommands.some((newCmd) => newCmd.name === existingCmd.name), - ); - - for (const cmdToRemove of commandsToRemove) { - await rest.delete( - Routes.applicationGuildCommand(clientId, guildId, cmdToRemove.id), - ); - console.log(`Removed command: ${cmdToRemove.name}`); - } - const data: any = await rest.put( Routes.applicationGuildCommands(clientId, guildId), { body: apiCommands }, diff --git a/src/util/eventLoader.ts b/src/util/eventLoader.ts index 855f296..d212380 100644 --- a/src/util/eventLoader.ts +++ b/src/util/eventLoader.ts @@ -7,6 +7,10 @@ import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +/** + * Registers all event handlers in the events directory + * @param client - The Discord client + */ export async function registerEvents(client: Client): Promise { try { const eventsPath = join(__dirname, '..', 'events'); diff --git a/src/util/factManager.ts b/src/util/factManager.ts index 4663a18..d146f67 100644 --- a/src/util/factManager.ts +++ b/src/util/factManager.ts @@ -3,8 +3,22 @@ import { EmbedBuilder, Client } from 'discord.js'; import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js'; import { loadConfig } from './configLoader.js'; -export async function scheduleFactOfTheDay(client: Client) { +let isFactScheduled = false; + +/** + * Schedule the fact of the day to be posted daily + * @param client - The Discord client + */ +export async function scheduleFactOfTheDay(client: Client): Promise { + if (isFactScheduled) { + console.log( + 'Fact of the day already scheduled, skipping duplicate schedule', + ); + return; + } + try { + isFactScheduled = true; const now = new Date(); const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1); @@ -14,6 +28,7 @@ export async function scheduleFactOfTheDay(client: Client) { setTimeout(() => { postFactOfTheDay(client); + isFactScheduled = false; scheduleFactOfTheDay(client); }, timeUntilMidnight); @@ -22,11 +37,16 @@ export async function scheduleFactOfTheDay(client: Client) { ); } catch (error) { console.error('Error scheduling fact of the day:', error); + isFactScheduled = false; setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000); } } -export async function postFactOfTheDay(client: Client) { +/** + * Post the fact of the day to the configured channel + * @param client - The Discord client + */ +export async function postFactOfTheDay(client: Client): Promise { try { const config = loadConfig(); const guild = client.guilds.cache.get(config.guildId); diff --git a/src/util/helpers.ts b/src/util/helpers.ts index dcc6fca..edfe5aa 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -5,11 +5,16 @@ import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js'; import { and, eq } from 'drizzle-orm'; import { moderationTable } from '../db/schema.js'; -import { db, updateMember } from '../db/db.js'; +import { db, handleDbError, updateMember } from '../db/db.js'; import logAction from './logging/logAction.js'; const __dirname = path.resolve(); +/** + * Turns a duration string into milliseconds + * @param duration - The duration to parse + * @returns - The parsed duration in milliseconds + */ export function parseDuration(duration: string): number { const regex = /^(\d+)(s|m|h|d)$/; const match = duration.match(regex); @@ -30,17 +35,27 @@ export function parseDuration(duration: string): number { } } +/** + * Member banner types + */ interface generateMemberBannerTypes { member: GuildMember; width: number; height: number; } +/** + * Generates a welcome banner for a member + * @param member - The member to generate a banner for + * @param width - The width of the banner + * @param height - The height of the banner + * @returns - The generated banner + */ export async function generateMemberBanner({ member, width, height, -}: generateMemberBannerTypes) { +}: generateMemberBannerTypes): Promise { const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png'); const canvas = Canvas.createCanvas(width, height); const context = canvas.getContext('2d'); @@ -92,12 +107,19 @@ export async function generateMemberBanner({ return attachment; } +/** + * Schedules an unban for a user + * @param client - The client to use + * @param guildId - The guild ID to unban the user from + * @param userId - The user ID to unban + * @param expiresAt - The date to unban the user at + */ export async function scheduleUnban( client: Client, guildId: string, userId: string, expiresAt: Date, -) { +): Promise { const timeUntilUnban = expiresAt.getTime() - Date.now(); if (timeUntilUnban > 0) { setTimeout(async () => { @@ -106,12 +128,19 @@ export async function scheduleUnban( } } +/** + * Executes an unban for a user + * @param client - The client to use + * @param guildId - The guild ID to unban the user from + * @param userId - The user ID to unban + * @param reason - The reason for the unban + */ export async function executeUnban( client: Client, guildId: string, userId: string, reason?: string, -) { +): Promise { try { const guild = await client.guilds.fetch(guildId); await guild.members.unban(userId, reason ?? 'Temporary ban expired'); @@ -140,26 +169,96 @@ export async function executeUnban( reason: reason ?? 'Temporary ban expired', }); } catch (error) { - console.error(`Failed to unban user ${userId}:`, error); + handleDbError(`Failed to unban user ${userId}`, error as Error); } } -export async function loadActiveBans(client: Client, guild: Guild) { - const activeBans = await db - .select() - .from(moderationTable) - .where( - and(eq(moderationTable.action, 'ban'), eq(moderationTable.active, true)), - ); +/** + * Loads all active bans and schedules unban events + * @param client - The client to use + * @param guild - The guild to load bans for + */ +export async function loadActiveBans( + client: Client, + guild: Guild, +): Promise { + try { + const activeBans = await db + .select() + .from(moderationTable) + .where( + and( + eq(moderationTable.action, 'ban'), + eq(moderationTable.active, true), + ), + ); - for (const ban of activeBans) { - if (!ban.expiresAt) continue; + for (const ban of activeBans) { + if (!ban.expiresAt) continue; - const timeUntilUnban = ban.expiresAt.getTime() - Date.now(); - if (timeUntilUnban <= 0) { - await executeUnban(client, guild.id, ban.discordId); - } else { - await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt); + const timeUntilUnban = ban.expiresAt.getTime() - Date.now(); + if (timeUntilUnban <= 0) { + await executeUnban(client, guild.id, ban.discordId); + } else { + await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt); + } } + } catch (error) { + handleDbError('Failed to load active bans', error as Error); + } +} + +/** + * Types for the roundRect function + */ +interface roundRectTypes { + ctx: Canvas.SKRSContext2D; + x: number; + y: number; + width: number; + height: number; + fill: boolean; + radius?: number; +} + +/** + * Creates a rounded rectangle + * @param ctx - The canvas context to use + * @param x - The x position of the rectangle + * @param y - The y position of the rectangle + * @param width - The width of the rectangle + * @param height - The height of the rectangle + * @param radius - The radius of the corners + * @param fill - Whether to fill the rectangle + */ +export function roundRect({ + ctx, + x, + y, + width, + height, + radius, + fill, +}: roundRectTypes): void { + if (typeof radius === 'undefined') { + radius = 5; + } + + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + + if (fill) { + ctx.fill(); + } else { + ctx.stroke(); } } diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts index d546250..ba42ea6 100644 --- a/src/util/levelingSystem.ts +++ b/src/util/levelingSystem.ts @@ -1,24 +1,41 @@ import path from 'path'; -import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js'; import Canvas, { GlobalFonts } from '@napi-rs/canvas'; +import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js'; -import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js'; +import { + addXpToUser, + db, + getUserLevel, + getUserRank, + handleDbError, +} from '../db/db.js'; import * as schema from '../db/schema.js'; import { loadConfig } from './configLoader.js'; +import { roundRect } from './helpers.js'; const config = loadConfig(); -const XP_COOLDOWN = 60 * 1000; -const MIN_XP = 15; -const MAX_XP = 25; +const XP_COOLDOWN = config.leveling.xpCooldown * 1000; +const MIN_XP = config.leveling.minXpAwarded; +const MAX_XP = config.leveling.maxXpAwarded; const __dirname = path.resolve(); +/** + * Calculates the amount of XP required to reach the given level + * @param level - The level to calculate the XP for + * @returns - The amount of XP required to reach the given level + */ export const calculateXpForLevel = (level: number): number => { if (level === 0) return 0; return (5 / 6) * level * (2 * level * level + 27 * level + 91); }; +/** + * Calculates the level that corresponds to the given amount of XP + * @param xp - The amount of XP to calculate the level for + * @returns - The level that corresponds to the given amount of XP + */ export const calculateLevelFromXp = (xp: number): number => { if (xp < calculateXpForLevel(1)) return 0; @@ -30,6 +47,12 @@ export const calculateLevelFromXp = (xp: number): number => { return level; }; +/** + * Gets the amount of XP required to reach the next level + * @param level - The level to calculate the XP for + * @param currentXp - The current amount of XP + * @returns - The amount of XP required to reach the next level + */ export const getXpToNextLevel = (level: number, currentXp: number): number => { if (level === 0) return calculateXpForLevel(1) - currentXp; @@ -37,14 +60,26 @@ export const getXpToNextLevel = (level: number, currentXp: number): number => { return nextLevelXp - currentXp; }; +/** + * Recalculates the levels for all users in the database + */ export async function recalculateUserLevels() { - const users = await db.select().from(schema.levelTable); + try { + const users = await db.select().from(schema.levelTable); - for (const user of users) { - await addXpToUser(user.discordId, 0); + for (const user of users) { + await addXpToUser(user.discordId, 0); + } + } catch (error) { + handleDbError('Failed to recalculate user levels', error as Error); } } +/** + * Processes a message for XP + * @param message - The message to process for XP + * @returns - The result of processing the message + */ export async function processMessage(message: Message) { if (message.author.bot || !message.guild) return; @@ -71,38 +106,12 @@ export async function processMessage(message: Message) { } } -function roundRect( - ctx: Canvas.SKRSContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, - fill: boolean, -) { - if (typeof radius === 'undefined') { - radius = 5; - } - - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); - - if (fill) { - ctx.fill(); - } else { - ctx.stroke(); - } -} - +/** + * Generates a rank card for the given member + * @param member - The member to generate a rank card for + * @param userData - The user's level data + * @returns - The rank card as an attachment + */ export async function generateRankCard( member: GuildMember, userData: schema.levelTableTypes, @@ -125,7 +134,15 @@ export async function generateRankCard( context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = '#2C2F33'; - roundRect(context, 22, 22, 890, 238, 20, true); + roundRect({ + ctx: context, + x: 22, + y: 22, + width: 890, + height: 238, + radius: 20, + fill: true, + }); try { const avatar = await Canvas.loadImage( @@ -183,19 +200,27 @@ export async function generateRankCard( ); context.fillStyle = '#484b4E'; - roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true); + roundRect({ + ctx: context, + x: barX, + y: barY, + width: barWidth, + height: barHeight, + radius: barHeight / 2, + fill: true, + }); if (progress > 0) { context.fillStyle = '#5865F2'; - roundRect( - context, - barX, - barY, - barWidth * progress, - barHeight, - barHeight / 2, - true, - ); + roundRect({ + ctx: context, + x: barX, + y: barY, + width: barWidth * progress, + height: barHeight, + radius: barHeight / 2, + fill: true, + }); } context.textAlign = 'center'; @@ -212,6 +237,13 @@ export async function generateRankCard( }); } +/** + * Assigns level roles to a user based on their new level + * @param guild - The guild to assign roles in + * @param userId - The userId of the user to assign roles to + * @param newLevel - The new level of the user + * @returns - The highest role that was assigned + */ export async function checkAndAssignLevelRoles( guild: Guild, userId: string, diff --git a/src/util/logging/constants.ts b/src/util/logging/constants.ts index 85ed0bf..d9c3b60 100644 --- a/src/util/logging/constants.ts +++ b/src/util/logging/constants.ts @@ -1,6 +1,9 @@ import { ChannelType } from 'discord.js'; import { LogActionType } from './types'; +/** + * Colors for different actions + */ export const ACTION_COLORS: Record = { // Danger actions - Red ban: 0xff0000, @@ -31,6 +34,9 @@ export const ACTION_COLORS: Record = { default: 0x0099ff, }; +/** + * Emojis for different actions + */ export const ACTION_EMOJIS: Record = { roleCreate: '⭐', roleDelete: '🗑️', @@ -54,6 +60,9 @@ export const ACTION_EMOJIS: Record = { roleRemove: '➖', }; +/** + * Types of channels + */ export const CHANNEL_TYPES: Record = { [ChannelType.GuildText]: 'Text Channel', [ChannelType.GuildVoice]: 'Voice Channel', diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts index 5a0affe..a78ea48 100644 --- a/src/util/logging/logAction.ts +++ b/src/util/logging/logAction.ts @@ -1,5 +1,4 @@ import { - TextChannel, ButtonStyle, ButtonBuilder, ActionRowBuilder, @@ -25,7 +24,13 @@ import { } from './utils.js'; import { loadConfig } from '../configLoader.js'; -export default async function logAction(payload: LogActionPayload) { +/** + * Logs an action to the log channel + * @param payload - The payload to log + */ +export default async function logAction( + payload: LogActionPayload, +): Promise { const config = loadConfig(); const logChannel = payload.guild.channels.cache.get(config.channels.logs); if (!logChannel?.isTextBased()) { diff --git a/src/util/logging/types.ts b/src/util/logging/types.ts index 36eed72..8319a3a 100644 --- a/src/util/logging/types.ts +++ b/src/util/logging/types.ts @@ -7,6 +7,9 @@ import { PermissionsBitField, } from 'discord.js'; +/** + * Moderation log action types + */ export type ModerationActionType = | 'ban' | 'kick' @@ -14,23 +17,38 @@ export type ModerationActionType = | 'unban' | 'unmute' | 'warn'; +/** + * Message log action types + */ export type MessageActionType = 'messageDelete' | 'messageEdit'; +/** + * Member log action types + */ export type MemberActionType = | 'memberJoin' | 'memberLeave' | 'memberUsernameUpdate' | 'memberNicknameUpdate'; +/** + * Role log action types + */ export type RoleActionType = | 'roleAdd' | 'roleRemove' | 'roleCreate' | 'roleDelete' | 'roleUpdate'; +/** + * Channel log action types + */ export type ChannelActionType = | 'channelCreate' | 'channelDelete' | 'channelUpdate'; +/** + * All log action types + */ export type LogActionType = | ModerationActionType | MessageActionType @@ -38,6 +56,9 @@ export type LogActionType = | RoleActionType | ChannelActionType; +/** + * Properties of a role + */ export type RoleProperties = { name: string; color: string; @@ -45,6 +66,9 @@ export type RoleProperties = { mentionable: boolean; }; +/** + * Base log action properties + */ export interface BaseLogAction { guild: Guild; action: LogActionType; @@ -53,6 +77,9 @@ export interface BaseLogAction { duration?: string; } +/** + * Log action properties for moderation actions + */ export interface ModerationLogAction extends BaseLogAction { action: ModerationActionType; target: GuildMember; @@ -61,6 +88,9 @@ export interface ModerationLogAction extends BaseLogAction { duration?: string; } +/** + * Log action properties for message actions + */ export interface MessageLogAction extends BaseLogAction { action: MessageActionType; message: Message; @@ -68,11 +98,17 @@ export interface MessageLogAction extends BaseLogAction { newContent?: string; } +/** + * Log action properties for member actions + */ export interface MemberLogAction extends BaseLogAction { action: 'memberJoin' | 'memberLeave'; member: GuildMember; } +/** + * Log action properties for member username or nickname updates + */ export interface MemberUpdateAction extends BaseLogAction { action: 'memberUsernameUpdate' | 'memberNicknameUpdate'; member: GuildMember; @@ -80,6 +116,9 @@ export interface MemberUpdateAction extends BaseLogAction { newValue: string; } +/** + * Log action properties for role actions + */ export interface RoleLogAction extends BaseLogAction { action: 'roleAdd' | 'roleRemove'; member: GuildMember; @@ -87,6 +126,9 @@ export interface RoleLogAction extends BaseLogAction { moderator?: GuildMember; } +/** + * Log action properties for role updates + */ export interface RoleUpdateAction extends BaseLogAction { action: 'roleUpdate'; role: Role; @@ -97,12 +139,18 @@ export interface RoleUpdateAction extends BaseLogAction { moderator?: GuildMember; } +/** + * Log action properties for role creation or deletion + */ export interface RoleCreateDeleteAction extends BaseLogAction { action: 'roleCreate' | 'roleDelete'; role: Role; moderator?: GuildMember; } +/** + * Log action properties for channel actions + */ export interface ChannelLogAction extends BaseLogAction { action: ChannelActionType; channel: GuildChannel; @@ -123,6 +171,9 @@ export interface ChannelLogAction extends BaseLogAction { moderator?: GuildMember; } +/** + * Payload for a log action + */ export type LogActionPayload = | ModerationLogAction | MessageLogAction diff --git a/src/util/logging/utils.ts b/src/util/logging/utils.ts index d47d0ce..c6c2977 100644 --- a/src/util/logging/utils.ts +++ b/src/util/logging/utils.ts @@ -5,9 +5,15 @@ import { EmbedField, PermissionsBitField, } from 'discord.js'; + import { LogActionPayload, LogActionType, RoleProperties } from './types.js'; import { ACTION_EMOJIS } from './constants.js'; +/** + * Formats a permission name to be more readable + * @param perm - The permission to format + * @returns - The formatted permission name + */ export const formatPermissionName = (perm: string): string => { return perm .split('_') @@ -15,6 +21,12 @@ export const formatPermissionName = (perm: string): string => { .join(' '); }; +/** + * Creates a field for a user + * @param user - The user to create a field for + * @param label - The label for the field + * @returns - The created field + */ export const createUserField = ( user: User | GuildMember, label = 'User', @@ -24,6 +36,12 @@ export const createUserField = ( inline: true, }); +/** + * Creates a field for a moderator + * @param moderator - The moderator to create a field for + * @param label - The label for the field + * @returns - The created field + */ export const createModeratorField = ( moderator?: GuildMember, label = 'Moderator', @@ -36,12 +54,23 @@ export const createModeratorField = ( } : null; +/** + * Creates a field for a channel + * @param channel - The channel to create a field for + * @returns - The created field + */ export const createChannelField = (channel: GuildChannel): EmbedField => ({ name: 'Channel', value: `<#${channel.id}>`, inline: true, }); +/** + * Creates a field for changed permissions + * @param oldPerms - The old permissions + * @param newPerms - The new permissions + * @returns - The created fields + */ export const createPermissionChangeFields = ( oldPerms: Readonly, newPerms: Readonly, @@ -84,6 +113,11 @@ export const createPermissionChangeFields = ( return fields; }; +/** + * Gets the names of the permissions in a bitfield + * @param permissions - The permissions to get the names of + * @returns - The names of the permissions + */ export const getPermissionNames = ( permissions: Readonly, ): string[] => { @@ -98,6 +132,12 @@ export const getPermissionNames = ( return names; }; +/** + * Compares two bitfields and returns the names of the permissions that are in the first bitfield but not the second + * @param a - The first bitfield + * @param b - The second bitfield + * @returns - The names of the permissions that are in the first bitfield but not the second + */ export const getPermissionDifference = ( a: Readonly, b: Readonly, @@ -114,6 +154,12 @@ export const getPermissionDifference = ( return names; }; +/** + * Creates a field for a role + * @param oldRole - The old role + * @param newRole - The new role + * @returns - The fields for the role changes + */ export const createRoleChangeFields = ( oldRole: Partial, newRole: Partial, @@ -153,6 +199,11 @@ export const createRoleChangeFields = ( return fields; }; +/** + * Gets the ID of the item that was logged + * @param payload - The payload to get the log item ID from + * @returns - The ID of the log item + */ export const getLogItemId = (payload: LogActionPayload): string => { switch (payload.action) { case 'roleCreate': @@ -188,6 +239,11 @@ export const getLogItemId = (payload: LogActionPayload): string => { } }; +/** + * Gets the emoji for an action + * @param action - The action to get an emoji for + * @returns - The emoji for the action + */ export const getEmojiForAction = (action: LogActionType): string => { return ACTION_EMOJIS[action] || '📝'; }; diff --git a/src/util/notificationHandler.ts b/src/util/notificationHandler.ts new file mode 100644 index 0000000..a2f8a58 --- /dev/null +++ b/src/util/notificationHandler.ts @@ -0,0 +1,151 @@ +import { Client, Guild, GuildMember } from 'discord.js'; +import { loadConfig } from './configLoader.js'; + +/** + * Types of notifications that can be sent + */ +export enum NotificationType { + // Redis notifications + REDIS_CONNECTION_LOST = 'REDIS_CONNECTION_LOST', + REDIS_CONNECTION_RESTORED = 'REDIS_CONNECTION_RESTORED', + + // Database notifications + DATABASE_CONNECTION_LOST = 'DATABASE_CONNECTION_LOST', + DATABASE_CONNECTION_RESTORED = 'DATABASE_CONNECTION_RESTORED', + + // Bot notifications + BOT_RESTARTING = 'BOT_RESTARTING', + BOT_ERROR = 'BOT_ERROR', +} + +/** + * Maps notification types to their messages + */ +const NOTIFICATION_MESSAGES = { + [NotificationType.REDIS_CONNECTION_LOST]: + '⚠️ **Redis Connection Lost**\n\nThe bot has lost connection to Redis after multiple retry attempts. Caching functionality is disabled until the connection is restored.', + [NotificationType.REDIS_CONNECTION_RESTORED]: + '✅ **Redis Connection Restored**\n\nThe bot has successfully reconnected to Redis. All caching functionality has been restored.', + + [NotificationType.DATABASE_CONNECTION_LOST]: + '🚨 **Database Connection Lost**\n\nThe bot has lost connection to the database after multiple retry attempts. The bot cannot function properly without database access and will shut down.', + [NotificationType.DATABASE_CONNECTION_RESTORED]: + '✅ **Database Connection Restored**\n\nThe bot has successfully reconnected to the database.', + + [NotificationType.BOT_RESTARTING]: + '🔄 **Bot Restarting**\n\nThe bot is being restarted. Services will be temporarily unavailable.', + [NotificationType.BOT_ERROR]: + '🚨 **Critical Bot Error**\n\nThe bot has encountered a critical error and may not function correctly.', +}; + +/** + * Creates a Discord-friendly timestamp string + * @returns Formatted Discord timestamp string + */ +function createDiscordTimestamp(): string { + const timestamp = Math.floor(Date.now() / 1000); + return ` ()`; +} + +/** + * Gets all managers with the Manager role + * @param guild - The guild to search in + * @returns Array of members with the Manager role + */ +async function getManagers(guild: Guild): Promise { + const config = loadConfig(); + const managerRoleId = config.roles?.staffRoles?.find( + (role) => role.name === 'Manager', + )?.roleId; + + if (!managerRoleId) { + console.warn('Manager role not found in config'); + return []; + } + + try { + await guild.members.fetch(); + + return Array.from( + guild.members.cache + .filter( + (member) => member.roles.cache.has(managerRoleId) && !member.user.bot, + ) + .values(), + ); + } catch (error) { + console.error('Error fetching managers:', error); + return []; + } +} + +/** + * Sends a notification to users with the Manager role + * @param client - Discord client instance + * @param type - Type of notification to send + * @param customMessage - Optional custom message to append + */ +export async function notifyManagers( + client: Client, + type: NotificationType, + customMessage?: string, +): Promise { + try { + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + + if (!guild) { + console.error(`Guild with ID ${config.guildId} not found`); + return; + } + + const managers = await getManagers(guild); + + if (managers.length === 0) { + console.warn('No managers found to notify'); + return; + } + + const baseMessage = NOTIFICATION_MESSAGES[type]; + const timestamp = createDiscordTimestamp(); + const fullMessage = customMessage + ? `${baseMessage}\n\n${customMessage}` + : baseMessage; + + let successCount = 0; + for (const manager of managers) { + try { + await manager.send({ + content: `${fullMessage}\n\nTimestamp: ${timestamp}`, + }); + successCount++; + } catch (error) { + console.error( + `Failed to send DM to manager ${manager.user.tag}:`, + error, + ); + } + } + + console.log( + `Sent ${type} notification to ${successCount}/${managers.length} managers`, + ); + } catch (error) { + console.error('Error sending manager notifications:', error); + } +} + +/** + * Log a manager-level notification to the console + * @param type - Type of notification + * @param details - Additional details + */ +export function logManagerNotification( + type: NotificationType, + details?: string, +): void { + const baseMessage = NOTIFICATION_MESSAGES[type].split('\n')[0]; + console.warn( + `MANAGER NOTIFICATION: ${baseMessage}${details ? ` | ${details}` : ''}`, + ); +} From f91ac5f04d5f1077eb584b57b342ea3907541d2e Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:05:32 -0400 Subject: [PATCH 09/14] build: added basic husky + commitlint + lint-staged setup --- .commitlintrc | 5 + .husky/commit-msg | 1 + .husky/pre-commit | 1 + .husky/pre-push | 1 + .lintstagedrc.mjs | 12 + package.json | 7 +- yarn.lock | 1079 ++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 1095 insertions(+), 11 deletions(-) create mode 100644 .commitlintrc create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit create mode 100644 .husky/pre-push create mode 100644 .lintstagedrc.mjs diff --git a/.commitlintrc b/.commitlintrc new file mode 100644 index 0000000..0df1d25 --- /dev/null +++ b/.commitlintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "@commitlint/config-conventional" + ] +} diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..34414dd --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +yarn dlx commitlint --edit \ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3723623 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..c69bd51 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +yarn compile diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs new file mode 100644 index 0000000..27118f0 --- /dev/null +++ b/.lintstagedrc.mjs @@ -0,0 +1,12 @@ +import path from 'path'; +import process from 'process'; + +const buildEslintCommand = (filenames) => + `eslint ${filenames.map((f) => path.relative(process.cwd(), f))}`; + +const prettierCommand = 'prettier --write'; + +export default { + '*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand], + '*.{json}': [prettierCommand], +}; diff --git a/package.json b/package.json index 073f136..216b935 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "restart": "pm2 restart poixpixel-discord-bot", "lint": "npx eslint ./src && npx tsc --noEmit", "format": "prettier --check --ignore-path .prettierignore .", - "format:fix": "prettier --write --ignore-path .prettierignore ." + "format:fix": "prettier --write --ignore-path .prettierignore .", + "prepare": "husky" }, "dependencies": { "@napi-rs/canvas": "^0.1.68", @@ -24,6 +25,8 @@ "pg": "^8.14.1" }, "devDependencies": { + "@commitlint/cli": "^19.8.0", + "@commitlint/config-conventional": "^19.8.0", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.22.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", @@ -35,6 +38,8 @@ "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "globals": "^16.0.0", + "husky": "^9.1.7", + "lint-staged": "^15.5.0", "prettier": "3.5.3", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/yarn.lock b/yarn.lock index fba9b51..e1cd4c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,215 @@ __metadata: version: 8 cacheKey: 10c0 +"@babel/code-frame@npm:^7.0.0": + version: 7.26.2 + resolution: "@babel/code-frame@npm:7.26.2" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.25.9" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.0.0" + checksum: 10c0/7d79621a6849183c415486af99b1a20b84737e8c11cd55b6544f688c51ce1fd710e6d869c3dd21232023da272a79b91efb3e83b5bc2dc65c1187c5fcd1b72ea8 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-validator-identifier@npm:7.25.9" + checksum: 10c0/4fc6f830177b7b7e887ad3277ddb3b91d81e6c4a24151540d9d1023e8dc6b1c0505f0f0628ae653601eb4388a8db45c1c14b2c07a9173837aef7e4116456259d + languageName: node + linkType: hard + +"@commitlint/cli@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/cli@npm:19.8.0" + dependencies: + "@commitlint/format": "npm:^19.8.0" + "@commitlint/lint": "npm:^19.8.0" + "@commitlint/load": "npm:^19.8.0" + "@commitlint/read": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + tinyexec: "npm:^0.3.0" + yargs: "npm:^17.0.0" + bin: + commitlint: ./cli.js + checksum: 10c0/6931c62c18b848b2c7266ec0b2d3a690a9ec9f83151a67a89ef20a49c84d5e6ee8dbaee4aaec14b2bd1229fdd91c7a0b41b7fd68c52fff8632a0037d52bd6eb2 + languageName: node + linkType: hard + +"@commitlint/config-conventional@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/config-conventional@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + conventional-changelog-conventionalcommits: "npm:^7.0.2" + checksum: 10c0/c0e2ad4ee8b793ad08ce8f0fd242d8111c71c81eba53b652431b7852e02d3eef0a383e234b7574429f5d1876b712a915921f6ff61fdaccdf708cbbaf3fa1f2f0 + languageName: node + linkType: hard + +"@commitlint/config-validator@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/config-validator@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + ajv: "npm:^8.11.0" + checksum: 10c0/968b3041dbf1683f9da443c2998a53ced52e86b98a48862f39f303af69638c72b7409840c16b3ded27eaa1636bdbf6b2464f8a2628c40d8f14a66a5474359ed5 + languageName: node + linkType: hard + +"@commitlint/ensure@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/ensure@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + lodash.camelcase: "npm:^4.3.0" + lodash.kebabcase: "npm:^4.1.1" + lodash.snakecase: "npm:^4.1.1" + lodash.startcase: "npm:^4.4.0" + lodash.upperfirst: "npm:^4.3.1" + checksum: 10c0/5160dcf41c595496894cf1d075b4ee15c14b3689967d8693d4121689475d36853eceeb09fc4e07b6f002e7b8869e75418b0c1cd95d4ee32d062811301337875c + languageName: node + linkType: hard + +"@commitlint/execute-rule@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/execute-rule@npm:19.8.0" + checksum: 10c0/fee5848e41680935510c6eebe2afcfe3511e2ccc39686c555f2e2db0205345479c7dbd84e7a8a2b22c7700ce75e6442b24685fbc3a419b0ea91f83a0850c6489 + languageName: node + linkType: hard + +"@commitlint/format@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/format@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + chalk: "npm:^5.3.0" + checksum: 10c0/25de71d5b19c126e7e9f471dcf8015bc362ee94fec7ca0da866181832548cb4a04c18f732c8d7cc64641e896a33d0e199bd445edd9e0ef164b0e7bd7259b86b1 + languageName: node + linkType: hard + +"@commitlint/is-ignored@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/is-ignored@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + semver: "npm:^7.6.0" + checksum: 10c0/6f882266cca84fdc2a435cc01388b070c60cdda56dff6cb1bd98a443982d8bb90b186972450c733ee1190122882f53e715a7204d9fc9787b5303ca545985958c + languageName: node + linkType: hard + +"@commitlint/lint@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/lint@npm:19.8.0" + dependencies: + "@commitlint/is-ignored": "npm:^19.8.0" + "@commitlint/parse": "npm:^19.8.0" + "@commitlint/rules": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + checksum: 10c0/5ce1074e5ad1ed12158fb722d4d643be71c3ae35113c6b13faa71dd85a07eeafec50ef2fee3f3e6fccdbd8bf8684613aa097e287b54a7cbcae1f9f28e2b95e8d + languageName: node + linkType: hard + +"@commitlint/load@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/load@npm:19.8.0" + dependencies: + "@commitlint/config-validator": "npm:^19.8.0" + "@commitlint/execute-rule": "npm:^19.8.0" + "@commitlint/resolve-extends": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + chalk: "npm:^5.3.0" + cosmiconfig: "npm:^9.0.0" + cosmiconfig-typescript-loader: "npm:^6.1.0" + lodash.isplainobject: "npm:^4.0.6" + lodash.merge: "npm:^4.6.2" + lodash.uniq: "npm:^4.5.0" + checksum: 10c0/6826a015ce40ae6043ff45bf29c7d515822ea416ab2a2a6eec6a69e5ba81b71419cadd609070aa3695d59f5442c34e3c264889df343eb66595c130185db58bad + languageName: node + linkType: hard + +"@commitlint/message@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/message@npm:19.8.0" + checksum: 10c0/a7390fade33e381a17d53ec16081bd6915d61cf4eb326739ee4b4c1f3a4016f84e953dd273126fcf23deaf5ca2ed49d75c0e667bc159dcfb26cb37ce840d97a9 + languageName: node + linkType: hard + +"@commitlint/parse@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/parse@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + conventional-changelog-angular: "npm:^7.0.0" + conventional-commits-parser: "npm:^5.0.0" + checksum: 10c0/ece54b76d2bf6eb620d972810a8db276a104cbd29db6a3c7eb661fc6eaf8212fda04a42920eac56831f65af77bc4a8e15260c2c0881f351289d93e4cf5371cde + languageName: node + linkType: hard + +"@commitlint/read@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/read@npm:19.8.0" + dependencies: + "@commitlint/top-level": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + git-raw-commits: "npm:^4.0.0" + minimist: "npm:^1.2.8" + tinyexec: "npm:^0.3.0" + checksum: 10c0/94b9156f67b95d0ca7dd9653e399b7129d0b84c4940dc79a5264148688ca01c70780ef235b67d344059e575938c9e0988af9fa7233a793dcd74f49f9278e0e68 + languageName: node + linkType: hard + +"@commitlint/resolve-extends@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/resolve-extends@npm:19.8.0" + dependencies: + "@commitlint/config-validator": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + global-directory: "npm:^4.0.1" + import-meta-resolve: "npm:^4.0.0" + lodash.mergewith: "npm:^4.6.2" + resolve-from: "npm:^5.0.0" + checksum: 10c0/7b05d0c9bc2171e1475baeef13d30d6d985e1dd9cb4652355484a8d4841797dffd3e80edd5c61182cbfab1a28f4180ccbdef87bfa8f4586e057e05e238f5b19b + languageName: node + linkType: hard + +"@commitlint/rules@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/rules@npm:19.8.0" + dependencies: + "@commitlint/ensure": "npm:^19.8.0" + "@commitlint/message": "npm:^19.8.0" + "@commitlint/to-lines": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + checksum: 10c0/3d6e932dfbd4c6384d3b3ded66a9f886667988cae4b1ae091350198ae8ca5c703142f13ccd8b632a0d260fd48072f5bc67836c15e6d637033b97dac2c81c95dd + languageName: node + linkType: hard + +"@commitlint/to-lines@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/to-lines@npm:19.8.0" + checksum: 10c0/1a0f34805615f244f34471138cfd5c8a45531ec3d1a0254370835db817dd06ec14181a8b281cd508632cf217d6cf5148473984bf4736d74b275fe69b8cd40863 + languageName: node + linkType: hard + +"@commitlint/top-level@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/top-level@npm:19.8.0" + dependencies: + find-up: "npm:^7.0.0" + checksum: 10c0/04d39835bfb8d9f86b693d8d13bfe7e6566d48ac57e382e5139277bb0e5fa286645fe220c323fcb8e6569eea48ab26253c0eb4f6a142855a3a7b7565891ead7c + languageName: node + linkType: hard + +"@commitlint/types@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/types@npm:19.8.0" + dependencies: + "@types/conventional-commits-parser": "npm:^5.0.0" + chalk: "npm:^5.3.0" + checksum: 10c0/634a5db20110675da8ddf226f200c33f262c6e99d06853fd4a2f6d543e6cc7dfe48b045f7ae76bcce2e39595099bfebe6a5dd6da37ff2968733c1263b8d46644 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -1068,6 +1277,15 @@ __metadata: languageName: node linkType: hard +"@types/conventional-commits-parser@npm:^5.0.0": + version: 5.0.1 + resolution: "@types/conventional-commits-parser@npm:5.0.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/4b7b561f195f779d07f973801a9f15d77cd58ceb67e817459688b11cc735288d30de050f445c91f4cd2c007fa86824e59a6e3cde602d150b828c4474f6e67be5 + languageName: node + linkType: hard + "@types/estree@npm:^1.0.6": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" @@ -1246,6 +1464,18 @@ __metadata: languageName: node linkType: hard +"JSONStream@npm:^1.3.5": + version: 1.3.5 + resolution: "JSONStream@npm:1.3.5" + dependencies: + jsonparse: "npm:^1.2.0" + through: "npm:>=2.2.7 <3" + bin: + JSONStream: ./bin.js + checksum: 10c0/0f54694da32224d57b715385d4a6b668d2117379d1f3223dc758459246cca58fdc4c628b83e8a8883334e454a0a30aa198ede77c788b55537c1844f686a751f2 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -1320,6 +1550,27 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.11.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + languageName: node + linkType: hard + +"ansi-escapes@npm:^7.0.0": + version: 7.0.0 + resolution: "ansi-escapes@npm:7.0.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/86e51e36fabef18c9c004af0a280573e828900641cea35134a124d2715e0c5a473494ab4ce396614505da77638ae290ff72dd8002d9747d2ee53f5d6bbe336be + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -1343,7 +1594,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.1.0": +"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c @@ -1364,6 +1615,13 @@ __metadata: languageName: node linkType: hard +"array-ify@npm:^1.0.0": + version: 1.0.0 + resolution: "array-ify@npm:1.0.0" + checksum: 10c0/75c9c072faac47bd61779c0c595e912fe660d338504ac70d10e39e1b8a4a0c9c87658703d619b9d1b70d324177ae29dc8d07dda0d0a15d005597bc4c5a59c70c + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1443,6 +1701,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.3.0, chalk@npm:^5.4.1": + version: 5.4.1 + resolution: "chalk@npm:5.4.1" + checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -1457,6 +1722,36 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10c0/7ec62f69b79f6734ab209a3e4dbdc8af7422d44d360a7cb1efa8a0887bbe466a6e625650c466fe4359aee44dbe2dc0b6994b583d40a05d0808a5cb193641d220 + languageName: node + linkType: hard + +"cli-truncate@npm:^4.0.0": + version: 4.0.0 + resolution: "cli-truncate@npm:4.0.0" + dependencies: + slice-ansi: "npm:^5.0.0" + string-width: "npm:^7.0.0" + checksum: 10c0/d7f0b73e3d9b88cb496e6c086df7410b541b56a43d18ade6a573c9c18bd001b1c3fba1ad578f741a4218fdc794d042385f8ac02c25e1c295a2d8b9f3cb86eb4c + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 + languageName: node + linkType: hard + "cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" @@ -1480,6 +1775,30 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.20": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 + languageName: node + linkType: hard + +"commander@npm:^13.1.0": + version: 13.1.0 + resolution: "commander@npm:13.1.0" + checksum: 10c0/7b8c5544bba704fbe84b7cab2e043df8586d5c114a4c5b607f83ae5060708940ed0b5bd5838cf8ce27539cde265c1cbd59ce3c8c6b017ed3eec8943e3a415164 + languageName: node + linkType: hard + +"compare-func@npm:^2.0.0": + version: 2.0.0 + resolution: "compare-func@npm:2.0.0" + dependencies: + array-ify: "npm:^1.0.0" + dot-prop: "npm:^5.1.0" + checksum: 10c0/78bd4dd4ed311a79bd264c9e13c36ed564cde657f1390e699e0f04b8eee1fc06ffb8698ce2dfb5fbe7342d509579c82d4e248f08915b708f77f7b72234086cc3 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -1487,6 +1806,68 @@ __metadata: languageName: node linkType: hard +"conventional-changelog-angular@npm:^7.0.0": + version: 7.0.0 + resolution: "conventional-changelog-angular@npm:7.0.0" + dependencies: + compare-func: "npm:^2.0.0" + checksum: 10c0/90e73e25e224059b02951b6703b5f8742dc2a82c1fea62163978e6735fd3ab04350897a8fc6f443ec6b672d6b66e28a0820e833e544a0101f38879e5e6289b7e + languageName: node + linkType: hard + +"conventional-changelog-conventionalcommits@npm:^7.0.2": + version: 7.0.2 + resolution: "conventional-changelog-conventionalcommits@npm:7.0.2" + dependencies: + compare-func: "npm:^2.0.0" + checksum: 10c0/3cb1eab35e37fc973cfb3aed0e159f54414e49b222988da1c2aa86cc8a87fe7531491bbb7657fe5fc4dc0e25f5b50e2065ba8ac71cc4c08eed9189102a2b81bd + languageName: node + linkType: hard + +"conventional-commits-parser@npm:^5.0.0": + version: 5.0.0 + resolution: "conventional-commits-parser@npm:5.0.0" + dependencies: + JSONStream: "npm:^1.3.5" + is-text-path: "npm:^2.0.0" + meow: "npm:^12.0.1" + split2: "npm:^4.0.0" + bin: + conventional-commits-parser: cli.mjs + checksum: 10c0/c9e542f4884119a96a6bf3311ff62cdee55762d8547f4c745ae3ebdc50afe4ba7691e165e34827d5cf63283cbd93ab69917afd7922423075b123d5d9a7a82ed2 + languageName: node + linkType: hard + +"cosmiconfig-typescript-loader@npm:^6.1.0": + version: 6.1.0 + resolution: "cosmiconfig-typescript-loader@npm:6.1.0" + dependencies: + jiti: "npm:^2.4.1" + peerDependencies: + "@types/node": "*" + cosmiconfig: ">=9" + typescript: ">=5" + checksum: 10c0/5e3baf85a9da7dcdd7ef53a54d1293400eed76baf0abb3a41bf9fcc789f1a2653319443471f9a1dc32951f1de4467a6696ccd0f88640e7827f1af6ff94ceaf1a + languageName: node + linkType: hard + +"cosmiconfig@npm:^9.0.0": + version: 9.0.0 + resolution: "cosmiconfig@npm:9.0.0" + dependencies: + env-paths: "npm:^2.2.1" + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/1c1703be4f02a250b1d6ca3267e408ce16abfe8364193891afc94c2d5c060b69611fdc8d97af74b7e6d5d1aac0ab2fb94d6b079573146bc2d756c2484ce5f0ee + languageName: node + linkType: hard + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" @@ -1494,7 +1875,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -1505,6 +1886,13 @@ __metadata: languageName: node linkType: hard +"dargs@npm:^8.0.0": + version: 8.1.0 + resolution: "dargs@npm:8.1.0" + checksum: 10c0/08cbd1ee4ac1a16fb7700e761af2e3e22d1bdc04ac4f851926f552dde8f9e57714c0d04013c2cca1cda0cba8fb637e0f93ad15d5285547a939dd1989ee06a82d + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.4": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -1529,6 +1917,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -1593,6 +1993,15 @@ __metadata: languageName: node linkType: hard +"dot-prop@npm:^5.1.0": + version: 5.3.0 + resolution: "dot-prop@npm:5.3.0" + dependencies: + is-obj: "npm:^2.0.0" + checksum: 10c0/93f0d343ef87fe8869320e62f2459f7e70f49c6098d948cc47e060f4a3f827d0ad61e83cb82f2bd90cd5b9571b8d334289978a43c0f98fea4f0e99ee8faa0599 + languageName: node + linkType: hard + "drizzle-kit@npm:^0.30.5": version: 0.30.5 resolution: "drizzle-kit@npm:0.30.5" @@ -1707,6 +2116,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.4.0 + resolution: "emoji-regex@npm:10.4.0" + checksum: 10c0/a3fcedfc58bfcce21a05a5f36a529d81e88d602100145fcca3dc6f795e3c8acc4fc18fe773fbf9b6d6e9371205edb3afa2668ec3473fa2aa7fd47d2a9d46482d + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -1730,7 +2146,7 @@ __metadata: languageName: node linkType: hard -"env-paths@npm:^2.2.0": +"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 @@ -1744,6 +2160,13 @@ __metadata: languageName: node linkType: hard +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d + languageName: node + linkType: hard + "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -1751,6 +2174,15 @@ __metadata: languageName: node linkType: hard +"error-ex@npm:^1.3.1": + version: 1.3.2 + resolution: "error-ex@npm:1.3.2" + dependencies: + is-arrayish: "npm:^0.2.1" + checksum: 10c0/ba827f89369b4c93382cfca5a264d059dfefdaa56ecc5e338ffa58a6471f5ed93b71a20add1d52290a4873d92381174382658c885ac1a2305f7baca363ce9cce + languageName: node + linkType: hard + "esbuild-register@npm:^3.5.0": version: 3.6.0 resolution: "esbuild-register@npm:3.6.0" @@ -2005,6 +2437,13 @@ __metadata: languageName: node linkType: hard +"escalade@npm:^3.1.1": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -2227,6 +2666,30 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^5.0.1": + version: 5.0.1 + resolution: "eventemitter3@npm:5.0.1" + checksum: 10c0/4ba5c00c506e6c786b4d6262cfbce90ddc14c10d4667e5c83ae993c9de88aa856033994dd2b35b83e8dc1170e224e66a319fa80adc4c32adcd2379bbc75da814 + languageName: node + linkType: hard + +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10c0/2c52d8775f5bf103ce8eec9c7ab3059909ba350a5164744e9947ed14a53f51687c040a250bda833f906d1283aa8803975b84e6c8f7a7c42f99dc8ef80250d1af + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -2268,6 +2731,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.0.6 + resolution: "fast-uri@npm:3.0.6" + checksum: 10c0/74a513c2af0584448aee71ce56005185f81239eab7a2343110e5bad50c39ad4fb19c5a6f99783ead1cac7ccaf3461a6034fda89fffa2b30b6d99b9f21c2f9d29 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.17.1 resolution: "fastq@npm:1.17.1" @@ -2314,6 +2784,17 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^7.0.0": + version: 7.0.0 + resolution: "find-up@npm:7.0.0" + dependencies: + locate-path: "npm:^7.2.0" + path-exists: "npm:^5.0.0" + unicorn-magic: "npm:^0.1.0" + checksum: 10c0/e6ee3e6154560bc0ab3bc3b7d1348b31513f9bdf49a5dd2e952495427d559fa48cdf33953e85a309a323898b43fa1bfbc8b80c880dfc16068384783034030008 + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.2.0 resolution: "flat-cache@npm:3.2.0" @@ -2412,6 +2893,27 @@ __metadata: languageName: node linkType: hard +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0": + version: 1.3.0 + resolution: "get-east-asian-width@npm:1.3.0" + checksum: 10c0/1a049ba697e0f9a4d5514c4623781c5246982bdb61082da6b5ae6c33d838e52ce6726407df285cdbb27ec1908b333cf2820989bd3e986e37bb20979437fdf34b + languageName: node + linkType: hard + +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10c0/5c2181e98202b9dae0bb4a849979291043e5892eb40312b47f0c22b9414fc9b28a3b6063d2375705eb24abc41ecf97894d9a51f64ff021511b504477b27b4290 + languageName: node + linkType: hard + "get-tsconfig@npm:^4.7.0, get-tsconfig@npm:^4.7.5": version: 4.8.1 resolution: "get-tsconfig@npm:4.8.1" @@ -2421,6 +2923,19 @@ __metadata: languageName: node linkType: hard +"git-raw-commits@npm:^4.0.0": + version: 4.0.0 + resolution: "git-raw-commits@npm:4.0.0" + dependencies: + dargs: "npm:^8.0.0" + meow: "npm:^12.0.1" + split2: "npm:^4.0.0" + bin: + git-raw-commits: cli.mjs + checksum: 10c0/ab51335d9e55692fce8e42788013dba7a7e7bf9f5bf0622c8cd7ddc9206489e66bb939563fca4edb3aa87477e2118f052702aad1933b13c6fa738af7f29884f0 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -2469,6 +2984,15 @@ __metadata: languageName: node linkType: hard +"global-directory@npm:^4.0.1": + version: 4.0.1 + resolution: "global-directory@npm:4.0.1" + dependencies: + ini: "npm:4.1.1" + checksum: 10c0/f9cbeef41db4876f94dd0bac1c1b4282a7de9c16350ecaaf83e7b2dd777b32704cc25beeb1170b5a63c42a2c9abfade74d46357fe0133e933218bc89e613d4b2 + languageName: node + linkType: hard + "globals@npm:^13.19.0": version: 13.24.0 resolution: "globals@npm:13.24.0" @@ -2540,6 +3064,22 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10c0/5a9359073fe17a8b58e5a085e9a39a950366d9f00217c4ff5878bd312e09d80f460536ea6a3f260b5943a01fe55c158d1cea3fc7bee3d0520aeef04f6d915c82 + languageName: node + linkType: hard + +"husky@npm:^9.1.7": + version: 9.1.7 + resolution: "husky@npm:9.1.7" + bin: + husky: bin.js + checksum: 10c0/35bb110a71086c48906aa7cd3ed4913fb913823715359d65e32e0b964cb1e255593b0ae8014a5005c66a68e6fa66c38dcfa8056dbbdfb8b0187c0ffe7ee3a58f + languageName: node + linkType: hard + "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -2566,6 +3106,23 @@ __metadata: languageName: node linkType: hard +"import-fresh@npm:^3.3.0": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"import-meta-resolve@npm:^4.0.0": + version: 4.1.0 + resolution: "import-meta-resolve@npm:4.1.0" + checksum: 10c0/42f3284b0460635ddf105c4ad99c6716099c3ce76702602290ad5cbbcd295700cbc04e4bdf47bacf9e3f1a4cec2e1ff887dabc20458bef398f9de22ddff45ef5 + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -2597,6 +3154,13 @@ __metadata: languageName: node linkType: hard +"ini@npm:4.1.1": + version: 4.1.1 + resolution: "ini@npm:4.1.1" + checksum: 10c0/7fddc8dfd3e63567d4fdd5d999d1bf8a8487f1479d0b34a1d01f28d391a9228d261e19abc38e1a6a1ceb3400c727204fce05725d5eb598dfcf2077a1e3afe211 + languageName: node + linkType: hard + "ioredis@npm:^5.6.0": version: 5.6.0 resolution: "ioredis@npm:5.6.0" @@ -2624,6 +3188,13 @@ __metadata: languageName: node linkType: hard +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -2638,6 +3209,22 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^4.0.0": + version: 4.0.0 + resolution: "is-fullwidth-code-point@npm:4.0.0" + checksum: 10c0/df2a717e813567db0f659c306d61f2f804d480752526886954a2a3e2246c7745fd07a52b5fecf2b68caf0a6c79dcdace6166fdf29cc76ed9975cc334f0a018b8 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^5.0.0": + version: 5.0.0 + resolution: "is-fullwidth-code-point@npm:5.0.0" + dependencies: + get-east-asian-width: "npm:^1.0.0" + checksum: 10c0/cd591b27d43d76b05fa65ed03eddce57a16e1eca0b7797ff7255de97019bcaf0219acfc0c4f7af13319e13541f2a53c0ace476f442b13267b9a6a7568f2b65c8 + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -2661,6 +3248,13 @@ __metadata: languageName: node linkType: hard +"is-obj@npm:^2.0.0": + version: 2.0.0 + resolution: "is-obj@npm:2.0.0" + checksum: 10c0/85044ed7ba8bd169e2c2af3a178cacb92a97aa75de9569d02efef7f443a824b5e153eba72b9ae3aca6f8ce81955271aa2dc7da67a8b720575d3e38104208cb4e + languageName: node + linkType: hard + "is-path-inside@npm:^3.0.3": version: 3.0.3 resolution: "is-path-inside@npm:3.0.3" @@ -2668,6 +3262,22 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10c0/eb2f7127af02ee9aa2a0237b730e47ac2de0d4e76a4a905a50a11557f2339df5765eaea4ceb8029f1efa978586abe776908720bfcb1900c20c6ec5145f6f29d8 + languageName: node + linkType: hard + +"is-text-path@npm:^2.0.0": + version: 2.0.0 + resolution: "is-text-path@npm:2.0.0" + dependencies: + text-extensions: "npm:^2.0.0" + checksum: 10c0/e3c470e1262a3a54aa0fca1c0300b2659a7aed155714be6b643f88822c03bcfa6659b491f7a05c5acd3c1a3d6d42bab47e1bdd35bcc3a25973c4f26b2928bc1a + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -2695,6 +3305,22 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.4.1": + version: 2.4.2 + resolution: "jiti@npm:2.4.2" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/4ceac133a08c8faff7eac84aabb917e85e8257f5ad659e843004ce76e981c457c390a220881748ac67ba1b940b9b729b30fb85cbaf6e7989f04b6002c94da331 + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + "js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -2727,6 +3353,13 @@ __metadata: languageName: node linkType: hard +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -2734,6 +3367,13 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -2741,6 +3381,13 @@ __metadata: languageName: node linkType: hard +"jsonparse@npm:^1.2.0": + version: 1.3.1 + resolution: "jsonparse@npm:1.3.1" + checksum: 10c0/89bc68080cd0a0e276d4b5ab1b79cacd68f562467008d176dc23e16e97d4efec9e21741d92ba5087a8433526a45a7e6a9d5ef25408696c402ca1cfbc01a90bf0 + languageName: node + linkType: hard + "keyv@npm:^4.5.3, keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -2760,6 +3407,54 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.3": + version: 3.1.3 + resolution: "lilconfig@npm:3.1.3" + checksum: 10c0/f5604e7240c5c275743561442fbc5abf2a84ad94da0f5adc71d25e31fa8483048de3dcedcb7a44112a942fed305fd75841cdf6c9681c7f640c63f1049e9a5dcc + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 10c0/3da6ee62d4cd9f03f5dc90b4df2540fb85b352081bee77fe4bbcd12c9000ead7f35e0a38b8d09a9bb99b13223446dd8689ff3c4959807620726d788701a83d2d + languageName: node + linkType: hard + +"lint-staged@npm:^15.5.0": + version: 15.5.0 + resolution: "lint-staged@npm:15.5.0" + dependencies: + chalk: "npm:^5.4.1" + commander: "npm:^13.1.0" + debug: "npm:^4.4.0" + execa: "npm:^8.0.1" + lilconfig: "npm:^3.1.3" + listr2: "npm:^8.2.5" + micromatch: "npm:^4.0.8" + pidtree: "npm:^0.6.0" + string-argv: "npm:^0.3.2" + yaml: "npm:^2.7.0" + bin: + lint-staged: bin/lint-staged.js + checksum: 10c0/393b24d85d705a36e6556dc9d9b710594163be60f7789a2ca71bbf8f31debc10f7fde9cd0e868466ac2b7c154661983602decd7abbb6c685b21007bc70dbbdd6 + languageName: node + linkType: hard + +"listr2@npm:^8.2.5": + version: 8.2.5 + resolution: "listr2@npm:8.2.5" + dependencies: + cli-truncate: "npm:^4.0.0" + colorette: "npm:^2.0.20" + eventemitter3: "npm:^5.0.1" + log-update: "npm:^6.1.0" + rfdc: "npm:^1.4.1" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/f5a9599514b00c27d7eb32d1117c83c61394b2a985ec20e542c798bf91cf42b19340215701522736f5b7b42f557e544afeadec47866e35e5d4f268f552729671 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -2769,6 +3464,22 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^7.2.0": + version: 7.2.0 + resolution: "locate-path@npm:7.2.0" + dependencies: + p-locate: "npm:^6.0.0" + checksum: 10c0/139e8a7fe11cfbd7f20db03923cacfa5db9e14fa14887ea121345597472b4a63c1a42a8a5187defeeff6acf98fd568da7382aa39682d38f0af27433953a97751 + languageName: node + linkType: hard + +"lodash.camelcase@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.camelcase@npm:4.3.0" + checksum: 10c0/fcba15d21a458076dd309fce6b1b4bf611d84a0ec252cb92447c948c533ac250b95d2e00955801ebc367e5af5ed288b996d75d37d2035260a937008e14eaf432 + languageName: node + linkType: hard + "lodash.defaults@npm:^4.2.0": version: 4.2.0 resolution: "lodash.defaults@npm:4.2.0" @@ -2783,6 +3494,20 @@ __metadata: languageName: node linkType: hard +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lodash.kebabcase@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.kebabcase@npm:4.1.1" + checksum: 10c0/da5d8f41dbb5bc723d4bf9203d5096ca8da804d6aec3d2b56457156ba6c8d999ff448d347ebd97490da853cb36696ea4da09a431499f1ee8deb17b094ecf4e33 + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -2790,13 +3515,41 @@ __metadata: languageName: node linkType: hard -"lodash.snakecase@npm:4.1.1": +"lodash.mergewith@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.mergewith@npm:4.6.2" + checksum: 10c0/4adbed65ff96fd65b0b3861f6899f98304f90fd71e7f1eb36c1270e05d500ee7f5ec44c02ef979b5ddbf75c0a0b9b99c35f0ad58f4011934c4d4e99e5200b3b5 + languageName: node + linkType: hard + +"lodash.snakecase@npm:4.1.1, lodash.snakecase@npm:^4.1.1": version: 4.1.1 resolution: "lodash.snakecase@npm:4.1.1" checksum: 10c0/f0b3f2497eb20eea1a1cfc22d645ecaeb78ac14593eb0a40057977606d2f35f7aaff0913a06553c783b535aafc55b718f523f9eb78f8d5293f492af41002eaf9 languageName: node linkType: hard +"lodash.startcase@npm:^4.4.0": + version: 4.4.0 + resolution: "lodash.startcase@npm:4.4.0" + checksum: 10c0/bd82aa87a45de8080e1c5ee61128c7aee77bf7f1d86f4ff94f4a6d7438fc9e15e5f03374b947be577a93804c8ad6241f0251beaf1452bf716064eeb657b3a9f0 + languageName: node + linkType: hard + +"lodash.uniq@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.uniq@npm:4.5.0" + checksum: 10c0/262d400bb0952f112162a320cc4a75dea4f66078b9e7e3075ffbc9c6aa30b3e9df3cf20e7da7d566105e1ccf7804e4fbd7d804eee0b53de05d83f16ffbf41c5e + languageName: node + linkType: hard + +"lodash.upperfirst@npm:^4.3.1": + version: 4.3.1 + resolution: "lodash.upperfirst@npm:4.3.1" + checksum: 10c0/435625da4b3ee74e7a1367a780d9107ab0b13ef4359fc074b2a1a40458eb8d91b655af62f6795b7138d493303a98c0285340160341561d6896e4947e077fa975 + languageName: node + linkType: hard + "lodash@npm:^4.17.14, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -2804,6 +3557,19 @@ __metadata: languageName: node linkType: hard +"log-update@npm:^6.1.0": + version: 6.1.0 + resolution: "log-update@npm:6.1.0" + dependencies: + ansi-escapes: "npm:^7.0.0" + cli-cursor: "npm:^5.0.0" + slice-ansi: "npm:^7.1.0" + strip-ansi: "npm:^7.1.0" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/4b350c0a83d7753fea34dcac6cd797d1dc9603291565de009baa4aa91c0447eab0d3815a05c8ec9ac04fdfffb43c82adcdb03ec1fceafd8518e1a8c1cff4ff89 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -2845,6 +3611,20 @@ __metadata: languageName: node linkType: hard +"meow@npm:^12.0.1": + version: 12.1.1 + resolution: "meow@npm:12.1.1" + checksum: 10c0/a125ca99a32e2306e2f4cbe651a0d27f6eb67918d43a075f6e80b35e9bf372ebf0fc3a9fbc201cbbc9516444b6265fb3c9f80c5b7ebd32f548aa93eb7c28e088 + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + "merge2@npm:^1.3.0": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -2852,7 +3632,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4": +"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -2862,6 +3642,20 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10c0/de9cc32be9996fd941e512248338e43407f63f6d497abe8441fa33447d922e927de54d4cc3c1a3c6d652857acd770389d5a3823f311a744132760ce2be15ccbf + languageName: node + linkType: hard + +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + "minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -2880,6 +3674,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.8": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -3032,6 +3833,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10c0/124df74820c40c2eb9a8612a254ea1d557ddfab1581c3e751f825e3e366d9f00b0d76a3c94ecd8398e7f3eee193018622677e95816e8491f0797b21e30b2deba + languageName: node + linkType: hard + "obuf@npm:~1.1.2": version: 1.1.2 resolution: "obuf@npm:1.1.2" @@ -3048,6 +3858,24 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10c0/4eef7c6abfef697dd4479345a4100c382d73c149d2d56170a54a07418c50816937ad09500e1ed1e79d235989d073a9bade8557122aee24f0576ecde0f392bb6c + languageName: node + linkType: hard + +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: 10c0/5cb9179d74b63f52a196a2e7037ba2b9a893245a5532d3f44360012005c9cadb60851d56716ebff18a6f47129dab7168022445df47c2aff3b276d92585ed1221 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -3071,6 +3899,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^4.0.0": + version: 4.0.0 + resolution: "p-limit@npm:4.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/a56af34a77f8df2ff61ddfb29431044557fcbcb7642d5a3233143ebba805fc7306ac1d448de724352861cb99de934bc9ab74f0d16fe6a5460bdbdf938de875ad + languageName: node + linkType: hard + "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -3080,6 +3917,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^6.0.0": + version: 6.0.0 + resolution: "p-locate@npm:6.0.0" + dependencies: + p-limit: "npm:^4.0.0" + checksum: 10c0/d72fa2f41adce59c198270aa4d3c832536c87a1806e0f69dffb7c1a7ca998fb053915ca833d90f166a8c082d3859eabfed95f01698a3214c20df6bb8de046312 + languageName: node + linkType: hard + "p-map@npm:^4.0.0": version: 4.0.0 resolution: "p-map@npm:4.0.0" @@ -3105,6 +3951,18 @@ __metadata: languageName: node linkType: hard +"parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" + checksum: 10c0/77947f2253005be7a12d858aedbafa09c9ae39eb4863adf330f7b416ca4f4a08132e453e08de2db46459256fb66afaac5ee758b44fe6541b7cdaf9d252e59585 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -3112,6 +3970,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^5.0.0": + version: 5.0.0 + resolution: "path-exists@npm:5.0.0" + checksum: 10c0/b170f3060b31604cde93eefdb7392b89d832dfbc1bed717c9718cbe0f230c1669b7e75f87e19901da2250b84d092989a0f9e44d2ef41deb09aa3ad28e691a40a + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -3126,6 +3991,13 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 10c0/794efeef32863a65ac312f3c0b0a99f921f3e827ff63afa5cb09a377e202c262b671f7b3832a4e64731003fa94af0263713962d317b9887bd1e0c48a342efba3 + languageName: node + linkType: hard + "path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" @@ -3246,6 +4118,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:^1.0.0": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + "picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -3253,10 +4132,21 @@ __metadata: languageName: node linkType: hard +"pidtree@npm:^0.6.0": + version: 0.6.0 + resolution: "pidtree@npm:0.6.0" + bin: + pidtree: bin/pidtree.js + checksum: 10c0/0829ec4e9209e230f74ebf4265f5ccc9ebfb488334b525cb13f86ff801dca44b362c41252cd43ae4d7653a10a5c6ab3be39d2c79064d6895e0d78dc50a5ed6e9 + languageName: node + linkType: hard + "poixpixel-discord-bot@workspace:.": version: 0.0.0-use.local resolution: "poixpixel-discord-bot@workspace:." dependencies: + "@commitlint/cli": "npm:^19.8.0" + "@commitlint/config-conventional": "npm:^19.8.0" "@eslint/eslintrc": "npm:^3.3.0" "@eslint/js": "npm:^9.22.0" "@microsoft/eslint-formatter-sarif": "npm:^3.1.0" @@ -3271,7 +4161,9 @@ __metadata: eslint: "npm:^9.22.0" eslint-config-prettier: "npm:^10.1.1" globals: "npm:^16.0.0" + husky: "npm:^9.1.7" ioredis: "npm:^5.6.0" + lint-staged: "npm:^15.5.0" pg: "npm:^8.14.1" prettier: "npm:3.5.3" ts-node: "npm:^10.9.2" @@ -3410,6 +4302,20 @@ __metadata: languageName: node linkType: hard +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -3417,6 +4323,13 @@ __metadata: languageName: node linkType: hard +"resolve-from@npm:^5.0.0": + version: 5.0.0 + resolution: "resolve-from@npm:5.0.0" + checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 + languageName: node + linkType: hard + "resolve-pkg-maps@npm:^1.0.0": version: 1.0.0 resolution: "resolve-pkg-maps@npm:1.0.0" @@ -3424,6 +4337,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 10c0/c2ba89131eea791d1b25205bdfdc86699767e2b88dee2a590b1a6caa51737deac8bad0260a5ded2f7c074b7db2f3a626bcf1fcf3cdf35974cbeea5e2e6764f60 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -3438,6 +4361,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.4.1": + version: 1.4.1 + resolution: "rfdc@npm:1.4.1" + checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7 + languageName: node + linkType: hard + "rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" @@ -3506,13 +4436,33 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 languageName: node linkType: hard +"slice-ansi@npm:^5.0.0": + version: 5.0.0 + resolution: "slice-ansi@npm:5.0.0" + dependencies: + ansi-styles: "npm:^6.0.0" + is-fullwidth-code-point: "npm:^4.0.0" + checksum: 10c0/2d4d40b2a9d5cf4e8caae3f698fe24ae31a4d778701724f578e984dcb485ec8c49f0c04dab59c401821e80fcdfe89cace9c66693b0244e40ec485d72e543914f + languageName: node + linkType: hard + +"slice-ansi@npm:^7.1.0": + version: 7.1.0 + resolution: "slice-ansi@npm:7.1.0" + dependencies: + ansi-styles: "npm:^6.2.1" + is-fullwidth-code-point: "npm:^5.0.0" + checksum: 10c0/631c971d4abf56cf880f034d43fcc44ff883624867bf11ecbd538c47343911d734a4656d7bc02362b40b89d765652a7f935595441e519b59e2ad3f4d5d6fe7ca + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -3558,7 +4508,7 @@ __metadata: languageName: node linkType: hard -"split2@npm:^4.1.0": +"split2@npm:^4.0.0, split2@npm:^4.1.0": version: 4.2.0 resolution: "split2@npm:4.2.0" checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 @@ -3588,7 +4538,14 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": +"string-argv@npm:^0.3.2": + version: 0.3.2 + resolution: "string-argv@npm:0.3.2" + checksum: 10c0/75c02a83759ad1722e040b86823909d9a2fc75d15dd71ec4b537c3560746e33b5f5a07f7332d1e3f88319909f82190843aa2f0a0d8c8d591ec08e93d5b8dec82 + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -3610,6 +4567,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.0.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -3619,7 +4587,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" dependencies: @@ -3628,6 +4596,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10c0/a771a17901427bac6293fd416db7577e2bc1c34a19d38351e9d5478c3c415f523f391003b42ed475f27e33a78233035df183525395f731d3bfb8cdcbd4da08ce + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -3658,6 +4633,13 @@ __metadata: languageName: node linkType: hard +"text-extensions@npm:^2.0.0": + version: 2.4.0 + resolution: "text-extensions@npm:2.4.0" + checksum: 10c0/6790e7ee72ad4d54f2e96c50a13e158bb57ce840dddc770e80960ed1550115c57bdc2cee45d5354d7b4f269636f5ca06aab4d6e0281556c841389aa837b23fcb + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -3665,6 +4647,20 @@ __metadata: languageName: node linkType: hard +"through@npm:>=2.2.7 <3": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.0": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -3808,6 +4804,13 @@ __metadata: languageName: node linkType: hard +"unicorn-magic@npm:^0.1.0": + version: 0.1.0 + resolution: "unicorn-magic@npm:0.1.0" + checksum: 10c0/e4ed0de05b0a05e735c7d8a2930881e5efcfc3ec897204d5d33e7e6247f4c31eac92e383a15d9a6bccb7319b4271ee4bea946e211bf14951fec6ff2cbbb66a92 + languageName: node + linkType: hard + "unique-filename@npm:^3.0.0": version: 3.0.0 resolution: "unique-filename@npm:3.0.0" @@ -3878,7 +4881,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -3900,6 +4903,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^9.0.0": + version: 9.0.0 + resolution: "wrap-ansi@npm:9.0.0" + dependencies: + ansi-styles: "npm:^6.2.1" + string-width: "npm:^7.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/a139b818da9573677548dd463bd626a5a5286271211eb6e4e82f34a4f643191d74e6d4a9bb0a3c26ec90e6f904f679e0569674ac099ea12378a8b98e20706066 + languageName: node + linkType: hard + "wrappy@npm:1": version: 1.0.2 resolution: "wrappy@npm:1.0.2" @@ -3929,6 +4943,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" @@ -3936,6 +4957,37 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.7.0": + version: 2.7.0 + resolution: "yaml@npm:2.7.0" + bin: + yaml: bin.mjs + checksum: 10c0/886a7d2abbd70704b79f1d2d05fe9fb0aa63aefb86e1cb9991837dced65193d300f5554747a872b4b10ae9a12bc5d5327e4d04205f70336e863e35e89d8f4ea9 + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.0.0": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1" @@ -3949,3 +5001,10 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"yocto-queue@npm:^1.0.0": + version: 1.2.0 + resolution: "yocto-queue@npm:1.2.0" + checksum: 10c0/9fb3adeba76b69cc7c916831c092bb69ac1aa685c692ae6eb819a9599cbe0c4ecfd5269c145691a15b86d0a25b27d854d6116bbc0851a3373c0a86edb96f1602 + languageName: node + linkType: hard From 19247de2b88afd53e0864dd1d958c44b422a5238 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:13:44 -0400 Subject: [PATCH 10/14] ci: added commitlint GitHub CI action --- .github/workflows/commitlint.yml | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/commitlint.yml diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..39d8be2 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,36 @@ +name: Commitlint + +on: [push, pull_request] + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install required dependencies + run: | + apt update + apt install -y sudo + sudo apt install -y git curl + curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash - + sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs + - name: Print versions + run: | + git --version + node --version + npm --version + npx commitlint --version + - name: Install commitlint + run: | + yarn add conventional-changelog-conventionalcommits + yarn add commitlint@latest + + - name: Validate current commit (last commit) with commitlint + if: github.event_name == 'push' + run: npx commitlint --last --verbose + + - name: Validate PR commits with commitlint + if: github.event_name == 'pull_request' + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose \ No newline at end of file From c2316e8f3db64274ad16311d90f04a3f306abed8 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:21:08 -0400 Subject: [PATCH 11/14] fix(ci): fixed commitlint GitHub CI action --- .github/workflows/commitlint.yml | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 39d8be2..24ab2fd 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -4,24 +4,27 @@ on: [push, pull_request] jobs: commitlint: + name: Run commitlint scanning runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [23.x] + steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install required dependencies - run: | - apt update - apt install -y sudo - sudo apt install -y git curl - curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash - - sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs - - name: Print versions - run: | - git --version - node --version - npm --version - npx commitlint --version + + - name: Configure Corepack + run: corepack enable + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: yarn + - name: Install commitlint run: | yarn add conventional-changelog-conventionalcommits From e5bffdb8895040a21c2d4edda2fa497917e60539 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:23:25 -0400 Subject: [PATCH 12/14] fix(ci): fixed incorrect formatting for commitlint GitHub CI action Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com> --- .github/workflows/commitlint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 24ab2fd..c33af6f 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: Configure Corepack - run: corepack enable + run: corepack enable - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -36,4 +36,4 @@ jobs: - name: Validate PR commits with commitlint if: github.event_name == 'pull_request' - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose \ No newline at end of file + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose From 30cc0e9a8d8f0f802ccdc30b5a62d3d7604e39ef Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Thu, 20 Mar 2025 21:12:23 -0400 Subject: [PATCH 13/14] fix: updated drizzle config to match new config.json structure --- drizzle.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drizzle.config.ts b/drizzle.config.ts index 324e350..6fe8193 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -2,13 +2,13 @@ import fs from 'node:fs'; import { defineConfig } from 'drizzle-kit'; const config = JSON.parse(fs.readFileSync('./config.json', 'utf8')); -const { dbConnectionString } = config; +const { database } = config; export default defineConfig({ out: './drizzle', schema: './src/db/schema.ts', dialect: 'postgresql', dbCredentials: { - url: dbConnectionString, + url: database.dbConnectionString, }, }); From 6a34cf926e703f30b85737271518ed66ec40ce1d Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:45:10 -0400 Subject: [PATCH 14/14] build: updated package.json and yarn.lock files --- package.json | 14 ++--- yarn.lock | 168 +++++++++++++++++++++++++-------------------------- 2 files changed, 91 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index 26487d2..2900c6c 100644 --- a/package.json +++ b/package.json @@ -20,22 +20,22 @@ "dependencies": { "@napi-rs/canvas": "^0.1.68", "discord.js": "^14.18.0", - "drizzle-orm": "^0.40.1", + "drizzle-orm": "^0.41.0", "ioredis": "^5.6.0", "pg": "^8.14.1" }, "devDependencies": { "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "^9.22.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.23.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", - "@types/node": "^22.13.10", + "@types/node": "^22.13.13", "@types/pg": "^8.11.11", - "@typescript-eslint/eslint-plugin": "^8.27.0", - "@typescript-eslint/parser": "^8.27.0", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", "drizzle-kit": "^0.30.5", - "eslint": "^9.22.0", + "eslint": "^9.23.0", "eslint-config-prettier": "^10.1.1", "globals": "^16.0.0", "husky": "^9.1.7", diff --git a/yarn.lock b/yarn.lock index a4bd0eb..bcd6b31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -859,10 +859,10 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.1.0": - version: 0.1.0 - resolution: "@eslint/config-helpers@npm:0.1.0" - checksum: 10c0/3562b5325f42740fc83b0b92b7d13a61b383f8db064915143eec36184f09a09fad73eca6c2955ab6c248b0d04fa03c140f9af2f2c4c06770781a6b79f300a01e +"@eslint/config-helpers@npm:^0.2.0": + version: 0.2.0 + resolution: "@eslint/config-helpers@npm:0.2.0" + checksum: 10c0/743a64653e13177029108f57ab47460ded08e3412c86216a14b7e8ab2dc79c2b64be45bf55c5ef29f83692a707dc34cf1e9217e4b8b4b272a0d9b691fdaf6a2a languageName: node linkType: hard @@ -892,9 +892,9 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.3.0": - version: 3.3.0 - resolution: "@eslint/eslintrc@npm:3.3.0" +"@eslint/eslintrc@npm:^3.3.1": + version: 3.3.1 + resolution: "@eslint/eslintrc@npm:3.3.1" dependencies: ajv: "npm:^6.12.4" debug: "npm:^4.3.2" @@ -905,7 +905,7 @@ __metadata: js-yaml: "npm:^4.1.0" minimatch: "npm:^3.1.2" strip-json-comments: "npm:^3.1.1" - checksum: 10c0/215de990231b31e2fe6458f225d8cea0f5c781d3ecb0b7920703501f8cd21b3101fc5ef2f0d4f9a38865d36647b983e0e8ce8bf12fd2bcdd227fc48a5b1a43be + checksum: 10c0/b0e63f3bc5cce4555f791a4e487bf999173fcf27c65e1ab6e7d63634d8a43b33c3693e79f192cbff486d7df1be8ebb2bd2edc6e70ddd486cbfa84a359a3e3b41 languageName: node linkType: hard @@ -916,10 +916,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.22.0, @eslint/js@npm:^9.22.0": - version: 9.22.0 - resolution: "@eslint/js@npm:9.22.0" - checksum: 10c0/5bcd009bb579dc6c6ed760703bdd741e08a48cd9decd677aa2cf67fe66236658cb09a00185a0369f3904e5cffba9e6e0f2ff4d9ba4fdf598fcd81d34c49213a5 +"@eslint/js@npm:9.23.0, @eslint/js@npm:^9.23.0": + version: 9.23.0 + resolution: "@eslint/js@npm:9.23.0" + checksum: 10c0/4e70869372b6325389e0ab51cac6d3062689807d1cef2c3434857571422ce11dde3c62777af85c382b9f94d937127598d605d2086787f08611351bf99faded81 languageName: node linkType: hard @@ -1309,12 +1309,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.13.10": - version: 22.13.10 - resolution: "@types/node@npm:22.13.10" +"@types/node@npm:^22.13.13": + version: 22.13.13 + resolution: "@types/node@npm:22.13.13" dependencies: undici-types: "npm:~6.20.0" - checksum: 10c0/a3865f9503d6f718002374f7b87efaadfae62faa499c1a33b12c527cfb9fd86f733e1a1b026b80c5a0e4a965701174bc3305595a7d36078aa1abcf09daa5dee9 + checksum: 10c0/daf792ba5dcff1316abf4b33680f94b792f8d54d6ae495efc8929531e0ba1284a248d29aab117d2259f9280284d986ad5799b193b0516e2b926d713aab835f7d languageName: node linkType: hard @@ -1338,15 +1338,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.27.0" +"@typescript-eslint/eslint-plugin@npm:^8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.28.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.27.0" - "@typescript-eslint/type-utils": "npm:8.27.0" - "@typescript-eslint/utils": "npm:8.27.0" - "@typescript-eslint/visitor-keys": "npm:8.27.0" + "@typescript-eslint/scope-manager": "npm:8.28.0" + "@typescript-eslint/type-utils": "npm:8.28.0" + "@typescript-eslint/utils": "npm:8.28.0" + "@typescript-eslint/visitor-keys": "npm:8.28.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -1355,64 +1355,64 @@ __metadata: "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/95bbab011bfe51ca657ff346e4c6cac25652c88e5188a5e74d14372dba45c3d7aa713f4c90f80ebc885d77a8be89e131e8b77c096145c90da6c251a475b125fc + checksum: 10c0/f01b7d231b01ec2c1cc7c40599ddceb329532f2876664a39dec9d25c0aed4cfdbef3ec07f26bac357df000d798f652af6fdb6a2481b6120e43bfa38f7c7a7c48 languageName: node linkType: hard -"@typescript-eslint/parser@npm:^8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/parser@npm:8.27.0" +"@typescript-eslint/parser@npm:^8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/parser@npm:8.28.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.27.0" - "@typescript-eslint/types": "npm:8.27.0" - "@typescript-eslint/typescript-estree": "npm:8.27.0" - "@typescript-eslint/visitor-keys": "npm:8.27.0" + "@typescript-eslint/scope-manager": "npm:8.28.0" + "@typescript-eslint/types": "npm:8.28.0" + "@typescript-eslint/typescript-estree": "npm:8.28.0" + "@typescript-eslint/visitor-keys": "npm:8.28.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/2ada98167ca5a474544fada7658d7c8d54ea4dfdd692e3d30d18b5531e50d7308a5b09d23dca651f9fe841f96075ccd18643431f4b61d0e4e7e7ccde888258e8 + checksum: 10c0/4bde6887bbf3fe031c01e46db90f9f384a8cac2e67c2972b113a62d607db75e01db943601279aac847b9187960a038981814042cb02fd5aa27ea4613028f9313 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/scope-manager@npm:8.27.0" +"@typescript-eslint/scope-manager@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/scope-manager@npm:8.28.0" dependencies: - "@typescript-eslint/types": "npm:8.27.0" - "@typescript-eslint/visitor-keys": "npm:8.27.0" - checksum: 10c0/d87daeffb81f4e70f168c38f01c667713bda71c4545e28fcdf0792378fb3df171894ef77854c5c1a5e5a22c784ee1ccea2dd856b5baf825840710a6a74c14ac9 + "@typescript-eslint/types": "npm:8.28.0" + "@typescript-eslint/visitor-keys": "npm:8.28.0" + checksum: 10c0/f3bd76b3f54e60f1efe108b233b2d818e44ecf0dc6422cc296542f784826caf3c66d51b8acc83d8c354980bd201e1d9aa1ea01011de96e0613d320c00e40ccfd languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/type-utils@npm:8.27.0" +"@typescript-eslint/type-utils@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/type-utils@npm:8.28.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.27.0" - "@typescript-eslint/utils": "npm:8.27.0" + "@typescript-eslint/typescript-estree": "npm:8.28.0" + "@typescript-eslint/utils": "npm:8.28.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^2.0.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/f38cdc660ebcb3b71496182b9ea52301ab08a4f062558aa7061a5f0b759ae3e8f68ae250a29e74251cb52c6c56733d7dabed7002b993544cbe0933bb75d67a57 + checksum: 10c0/b8936edc2153bf794efba39bfb06393a228217830051767360f4b691fed7c82f3831c4fc6deac6d78b90a58596e61f866c17eaee9dd793c3efda3ebdcf5a71d8 languageName: node linkType: hard -"@typescript-eslint/types@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/types@npm:8.27.0" - checksum: 10c0/9c5f2ba816a9baea5982feeadebe4d19f4df77ddb025a7b2307f9e1e6914076b63cbad81f7f915814e64b4d915052cf27bd79ce3e5a831340cb5ab244133941b +"@typescript-eslint/types@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/types@npm:8.28.0" + checksum: 10c0/1f95895e20dac1cf063dc93c99142fd1871e53be816bcbbee93f22a05e6b2a82ca83c20ce3a551f65555910aa0956443a23268edbb004369d0d5cb282d13c377 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.27.0" +"@typescript-eslint/typescript-estree@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.28.0" dependencies: - "@typescript-eslint/types": "npm:8.27.0" - "@typescript-eslint/visitor-keys": "npm:8.27.0" + "@typescript-eslint/types": "npm:8.28.0" + "@typescript-eslint/visitor-keys": "npm:8.28.0" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" @@ -1421,32 +1421,32 @@ __metadata: ts-api-utils: "npm:^2.0.1" peerDependencies: typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/c04d602825ff2a7b2a89746a68b32f7052fb4ce3d2355d1f4e6f43fd064f17c3b44fb974c98838a078fdebdc35152d2ab0af34663dfca99db7a790bd3fc5d8ac + checksum: 10c0/97a91c95b1295926098c12e2d2c2abaa68994dc879da132dcce1e75ec9d7dee8187695eaa5241d09cbc42b5e633917b6d35c624e78e3d3ee9bda42d1318080b6 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/utils@npm:8.27.0" +"@typescript-eslint/utils@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/utils@npm:8.28.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.27.0" - "@typescript-eslint/types": "npm:8.27.0" - "@typescript-eslint/typescript-estree": "npm:8.27.0" + "@typescript-eslint/scope-manager": "npm:8.28.0" + "@typescript-eslint/types": "npm:8.28.0" + "@typescript-eslint/typescript-estree": "npm:8.28.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/dcfd5f2c17f1a33061e3ec70d0946ff23a4238aabacae3d85087165beccedf84fb8506d30848f2470e3b60ab98b230aef79c6e8b4c5d39648a37ac559ac5b1e0 + checksum: 10c0/d3425be7f86c1245a11f0ea39136af681027797417348d8e666d38c76646945eaed7b35eb8db66372b067dee8b02a855caf2c24c040ec9c31e59681ab223b59d languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.27.0" +"@typescript-eslint/visitor-keys@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.28.0" dependencies: - "@typescript-eslint/types": "npm:8.27.0" + "@typescript-eslint/types": "npm:8.28.0" eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/d86fd4032db07123816aab3a6b8b53f840387385ab2a4d8f96b22fc76b5438fb27ac8dc42b63caf23f3d265c33e9075dbf1ce8d31f939df12f5cd077d3b10295 + checksum: 10c0/245a78ed983fe95fbd1b0f2d4cb9e9d1d964bddc0aa3e3d6ab10c19c4273855bfb27d840bb1fd55deb7ae3078b52f26592472baf6fd2c7019a5aa3b1da974f35 languageName: node linkType: hard @@ -2017,9 +2017,9 @@ __metadata: languageName: node linkType: hard -"drizzle-orm@npm:^0.40.1": - version: 0.40.1 - resolution: "drizzle-orm@npm:0.40.1" +"drizzle-orm@npm:^0.41.0": + version: 0.41.0 + resolution: "drizzle-orm@npm:0.41.0" peerDependencies: "@aws-sdk/client-rds-data": ">=3" "@cloudflare/workers-types": ">=4" @@ -2105,7 +2105,7 @@ __metadata: optional: true sqlite3: optional: true - checksum: 10c0/653820017be66830c0efb6d5e4f0d28f3f593e78499442c5ecd903174793b3d6d8e5cf8cb00d814b3459a1fecfc76310a64e0ca5d523d0712ae22228efc69cdd + checksum: 10c0/4abc7ab2958a7862a9bdd149f3ec892ec05892e7cd2042f996b6e58c5504e51d50b408833ff5bdd6d44a4a71af4b4c280d281b01a0ce59acfc26a0117b6b6875 languageName: node linkType: hard @@ -2551,17 +2551,17 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.22.0": - version: 9.22.0 - resolution: "eslint@npm:9.22.0" +"eslint@npm:^9.23.0": + version: 9.23.0 + resolution: "eslint@npm:9.23.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" "@eslint/config-array": "npm:^0.19.2" - "@eslint/config-helpers": "npm:^0.1.0" + "@eslint/config-helpers": "npm:^0.2.0" "@eslint/core": "npm:^0.12.0" - "@eslint/eslintrc": "npm:^3.3.0" - "@eslint/js": "npm:9.22.0" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:9.23.0" "@eslint/plugin-kit": "npm:^0.2.7" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" @@ -2597,7 +2597,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/7b5ab6f2365971c16efe97349565f75d8343347562fb23f12734c6ab2cd5e35301373a0d51e194789ddcfdfca21db7b62ff481b03d524b8169896c305b65ff48 + checksum: 10c0/9616c308dfa8d09db8ae51019c87d5d05933742214531b077bd6ab618baab3bec7938256c14dcad4dc47f5ba93feb0bc5e089f68799f076374ddea21b6a9be45 languageName: node linkType: hard @@ -4147,18 +4147,18 @@ __metadata: dependencies: "@commitlint/cli": "npm:^19.8.0" "@commitlint/config-conventional": "npm:^19.8.0" - "@eslint/eslintrc": "npm:^3.3.0" - "@eslint/js": "npm:^9.22.0" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.23.0" "@microsoft/eslint-formatter-sarif": "npm:^3.1.0" "@napi-rs/canvas": "npm:^0.1.68" - "@types/node": "npm:^22.13.10" + "@types/node": "npm:^22.13.13" "@types/pg": "npm:^8.11.11" - "@typescript-eslint/eslint-plugin": "npm:^8.27.0" - "@typescript-eslint/parser": "npm:^8.27.0" + "@typescript-eslint/eslint-plugin": "npm:^8.28.0" + "@typescript-eslint/parser": "npm:^8.28.0" discord.js: "npm:^14.18.0" drizzle-kit: "npm:^0.30.5" - drizzle-orm: "npm:^0.40.1" - eslint: "npm:^9.22.0" + drizzle-orm: "npm:^0.41.0" + eslint: "npm:^9.23.0" eslint-config-prettier: "npm:^10.1.1" globals: "npm:^16.0.0" husky: "npm:^9.1.7"