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/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..c33af6f --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,39 @@ +name: Commitlint + +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: 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 + 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 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/.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..5d55c53 --- /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)).join(' ')}`; + +const prettierCommand = 'prettier --write'; + +export default { + '*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand], + '*.json': [prettierCommand], +}; diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 64de8d6..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": "0.1.0", - "configurations": [ - { - "name": "Build and Run", - "type": "node", - "request": "launch", - "program": "${workspaceFolder}/target/_.cjs", - "preLaunchTask": "build", - "skipFiles": ["/**"], - "outFiles": ["${workspaceFolder}/target/**/*.cjs"] - } - ], - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "node", - "args": ["${workspaceFolder}/build/compile.js"], - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a03a72f..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "node", - "args": ["${workspaceFolder}/build/compile.js"], - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} diff --git a/README.md b/README.md index 2c82f56..5bc92f9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # 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 and is still in a testing state. + +> [!TIP] +> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY). ## Documentation & Setup Instructions @@ -14,8 +17,18 @@ All documentation and setup instructions can be found at [https://docs.poixpixel 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 (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/assets/fonts/Manrope-Bold.ttf b/assets/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000..98c1c3d Binary files /dev/null and b/assets/fonts/Manrope-Bold.ttf differ diff --git a/assets/fonts/Manrope-Regular.ttf b/assets/fonts/Manrope-Regular.ttf new file mode 100644 index 0000000..1a07233 Binary files /dev/null and b/assets/fonts/Manrope-Regular.ttf differ diff --git a/assets/images/trophy.png b/assets/images/trophy.png new file mode 100644 index 0000000..78b399b Binary files /dev/null and b/assets/images/trophy.png differ diff --git a/assets/welcome-bg.png b/assets/images/welcome-bg.png similarity index 100% rename from assets/welcome-bg.png rename to assets/images/welcome-bg.png diff --git a/config.example.json b/config.example.json index 28fca15..25d2f48 100644 --- a/config.example.json +++ b/config.example.json @@ -1,16 +1,62 @@ { - "token": "DISCORD_BOT_API_KEY", + "token": "DISCORD_BOT_TOKEN", "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_CHAANNEL_ID" + "logs": "LOG_CHANNEL_ID", + "counting": "COUNTING_CHANNEL_ID", + "factOfTheDay": "FACT_OF_THE_DAY_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" + }, + "leveling": { + "xpCooldown": "XP_COOLDOWN_IN_SECONDS", + "minXpAwarded": "MINIMUM_XP_AWARDED", + "maxXpAwarded": "MAXIMUM_XP_AWARDED" } } 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, }, }); diff --git a/package.json b/package.json index b0ab9dd..f46ba47 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,13 @@ "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 ." + "format:fix": "prettier --write --ignore-path .prettierignore .", + "prepare": "ts-patch install -s && husky" }, "dependencies": { "@napi-rs/canvas": "^0.1.69", @@ -22,6 +25,8 @@ "pg": "^8.14.1" }, "devDependencies": { + "@commitlint/cli": "^19.8.0", + "@commitlint/config-conventional": "^19.8.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.24.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", @@ -33,10 +38,14 @@ "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.2", "globals": "^16.0.0", + "husky": "^9.1.7", + "lint-staged": "^15.5.0", "prettier": "3.5.3", "ts-node": "^10.9.2", + "ts-patch": "^3.3.0", "tsx": "^4.19.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "typescript-transform-paths": "^3.5.5" }, - "packageManager": "yarn@4.6.0" + "packageManager": "yarn@4.9.1" } diff --git a/src/commands/fun/achievement.ts b/src/commands/fun/achievement.ts new file mode 100644 index 0000000..e328956 --- /dev/null +++ b/src/commands/fun/achievement.ts @@ -0,0 +1,925 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + PermissionFlagsBits, + ChatInputCommandInteraction, + StringSelectMenuInteraction, + ComponentType, + ButtonInteraction, +} from 'discord.js'; + +import { + getAllAchievements, + getUserAchievements, + awardAchievement, + createAchievement, + deleteAchievement, + removeUserAchievement, +} from '@/db/db.js'; +import { announceAchievement } from '@/util/achievementManager.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command = { + data: new SlashCommandBuilder() + .setName('achievement') + .setDescription('Manage server achievements') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((subcommand) => + subcommand + .setName('create') + .setDescription('Create a new achievement') + .addStringOption((option) => + option + .setName('name') + .setDescription('Name of the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('description') + .setDescription('Description of the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('requirement_type') + .setDescription('Type of requirement for this achievement') + .setRequired(true) + .addChoices( + { name: 'Message Count', value: 'message_count' }, + { name: 'Level', value: 'level' }, + { name: 'Reactions', value: 'reactions' }, + { name: 'Command Usage', value: 'command_usage' }, + ), + ) + .addIntegerOption((option) => + option + .setName('threshold') + .setDescription('Threshold value for completing the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('image_url') + .setDescription('URL for the achievement image (optional)') + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('command_name') + .setDescription('Command name (only for command_usage type)') + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('reward_type') + .setDescription('Type of reward (optional)') + .setRequired(false) + .addChoices( + { name: 'XP', value: 'xp' }, + { name: 'Role', value: 'role' }, + ), + ) + .addStringOption((option) => + option + .setName('reward_value') + .setDescription('Value of the reward (XP amount or role ID)') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete an achievement') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('ID of the achievement to delete') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('award') + .setDescription('Award an achievement to a user') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to award the achievement to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('achievement_id') + .setDescription('ID of the achievement to award') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('view') + .setDescription('View a users achievements') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to view achievements for') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('unaward') + .setDescription('Remove an achievement from a user') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to remove the achievement from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('achievement_id') + .setDescription('ID of the achievement to remove') + .setRequired(true), + ), + ), + + async execute(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + await handleCreateAchievement(interaction); + break; + case 'delete': + await handleDeleteAchievement(interaction); + break; + case 'award': + await handleAwardAchievement(interaction); + break; + case 'unaward': + await handleUnawardAchievement(interaction); + break; + case 'view': + await handleViewUserAchievements(interaction); + break; + } + }, +}; + +async function handleCreateAchievement( + interaction: ChatInputCommandInteraction, +) { + const name = interaction.options.getString('name')!; + const description = interaction.options.getString('description')!; + const imageUrl = interaction.options.getString('image_url'); + const requirementType = interaction.options.getString('requirement_type')!; + const threshold = interaction.options.getInteger('threshold')!; + const commandName = interaction.options.getString('command_name'); + const rewardType = interaction.options.getString('reward_type'); + const rewardValue = interaction.options.getString('reward_value'); + + if (requirementType === 'command_usage' && !commandName) { + await interaction.editReply( + 'Command name is required for command_usage type achievements.', + ); + return; + } + + if (rewardType && !rewardValue) { + await interaction.editReply( + `Reward value is required when setting a ${rewardType} reward.`, + ); + return; + } + + const requirement: any = {}; + if (requirementType === 'command_usage' && commandName) { + requirement.command = commandName; + } + + try { + const achievement = await createAchievement({ + name, + description, + imageUrl: imageUrl || undefined, + requirementType, + threshold, + requirement, + rewardType: rewardType || undefined, + rewardValue: rewardValue || undefined, + }); + + if (achievement) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Achievement Created') + .setDescription(`Successfully created achievement: **${name}**`) + .addFields( + { name: 'ID', value: `${achievement.id}`, inline: true }, + { name: 'Type', value: requirementType, inline: true }, + { name: 'Threshold', value: `${threshold}`, inline: true }, + { name: 'Description', value: description }, + ); + + if (rewardType && rewardValue) { + embed.addFields({ + name: 'Reward', + value: `${rewardType === 'xp' ? `${rewardValue} XP` : `<@&${rewardValue}>`}`, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } else { + await interaction.editReply('Failed to create achievement.'); + } + } catch (error) { + console.error('Error creating achievement:', error); + await interaction.editReply( + 'An error occurred while creating the achievement.', + ); + } +} + +async function handleDeleteAchievement( + interaction: ChatInputCommandInteraction, +) { + const achievementId = interaction.options.getInteger('id')!; + + try { + const success = await deleteAchievement(achievementId); + + if (success) { + await interaction.editReply( + `Achievement with ID ${achievementId} has been deleted.`, + ); + } else { + await interaction.editReply( + `Failed to delete achievement with ID ${achievementId}.`, + ); + } + } catch (error) { + console.error('Error deleting achievement:', error); + await interaction.editReply( + 'An error occurred while deleting the achievement.', + ); + } +} + +async function handleAwardAchievement( + interaction: ChatInputCommandInteraction, +) { + const user = interaction.options.getUser('user')!; + const achievementId = interaction.options.getInteger('achievement_id')!; + + try { + const allAchievements = await getAllAchievements(); + const achievement = allAchievements.find((a) => a.id === achievementId); + + if (!achievement) { + await interaction.editReply( + `Achievement with ID ${achievementId} not found.`, + ); + return; + } + + const success = await awardAchievement(user.id, achievementId); + + if (success) { + await announceAchievement(interaction.guild!, user.id, achievement); + await interaction.editReply( + `Achievement "${achievement.name}" awarded to ${user}.`, + ); + } else { + await interaction.editReply( + 'Failed to award achievement or user already has this achievement.', + ); + } + } catch (error) { + console.error('Error awarding achievement:', error); + await interaction.editReply( + 'An error occurred while awarding the achievement.', + ); + } +} + +async function handleViewUserAchievements( + interaction: ChatInputCommandInteraction, +) { + const targetUser = interaction.options.getUser('user') || interaction.user; + + try { + const userAchievements = await getUserAchievements(targetUser.id); + const allAchievements = await getAllAchievements(); + + const totalAchievements = allAchievements.length; + const earnedCount = userAchievements.filter((ua) => ua.earnedAt).length; + const overallProgress = + totalAchievements > 0 + ? Math.round((earnedCount / totalAchievements) * 100) + : 0; + + if (totalAchievements === 0) { + await interaction.editReply( + 'No achievements have been created on this server yet.', + ); + return; + } + + const earnedAchievements = userAchievements + .filter((ua) => { + return ( + ua.earnedAt && + ua.earnedAt !== null && + ua.earnedAt !== undefined && + new Date(ua.earnedAt).getTime() > 0 + ); + }) + .map((ua) => { + const achievementDef = allAchievements.find( + (a) => a.id === ua.achievementId, + ); + return { + ...ua, + definition: achievementDef, + }; + }) + .filter((a) => a.definition); + + const inProgressAchievements = userAchievements + .filter((ua) => { + return ( + (!ua.earnedAt || + ua.earnedAt === null || + ua.earnedAt === undefined || + new Date(ua.earnedAt).getTime() <= 0) && + (ua.progress ?? 0) > 0 + ); + }) + .map((ua) => { + const achievementDef = allAchievements.find( + (a) => a.id === ua.achievementId, + ); + return { + ...ua, + definition: achievementDef, + }; + }) + .filter((a) => a.definition); + + const earnedAndInProgressIds = new Set( + userAchievements + .filter( + (ua) => + (ua.progress ?? 0) > 0 || + (ua.earnedAt && new Date(ua.earnedAt).getTime() > 0), + ) + .map((ua) => ua.achievementId), + ); + const availableAchievements = allAchievements + .filter((a) => !earnedAndInProgressIds.has(a.id)) + .map((definition) => { + const existingEntry = userAchievements.find( + (ua) => + ua.achievementId === definition.id && + (ua.progress === 0 || ua.progress === null), + ); + + return { + achievementId: definition.id, + progress: existingEntry?.progress || 0, + definition, + }; + }); + + interface AchievementViewOption { + label: string; + value: string; + count: number; + } + + const options: AchievementViewOption[] = []; + + if (earnedAchievements.length > 0) { + options.push({ + label: 'Earned Achievements', + value: 'earned', + count: earnedAchievements.length, + }); + } + + if (inProgressAchievements.length > 0) { + options.push({ + label: 'In Progress', + value: 'progress', + count: inProgressAchievements.length, + }); + } + + if (availableAchievements.length > 0) { + options.push({ + label: 'Available Achievements', + value: 'available', + count: availableAchievements.length, + }); + } + + if (options.length === 0) { + await interaction.editReply('No achievement data found.'); + return; + } + + let initialOption = options[0].value; + for (const preferredType of ['earned', 'progress', 'available']) { + const found = options.find((opt) => opt.value === preferredType); + if (found) { + initialOption = preferredType; + break; + } + } + + const initialEmbedData = + initialOption === 'earned' + ? { achievements: earnedAchievements, title: 'Earned Achievements' } + : initialOption === 'progress' + ? { + achievements: inProgressAchievements, + title: 'Achievements In Progress', + } + : { + achievements: availableAchievements, + title: 'Available Achievements', + }; + + const initialEmbed = createAchievementsEmbed( + initialEmbedData.achievements, + initialEmbedData.title, + targetUser, + overallProgress, + earnedCount, + totalAchievements, + ); + + // Define pagination variables + const achievementsPerPage = 5; + let currentPage = 0; + + const pages = splitAchievementsIntoPages( + initialEmbedData.achievements, + initialEmbedData.title, + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + + // Create achievements type selector + const selectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('achievement_view') + .setPlaceholder('Select achievement type') + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder() + .setLabel(`${opt.label} (${opt.count})`) + .setValue(opt.value) + .setDefault(opt.value === initialOption), + ), + ), + ); + + // Create pagination buttons + const paginationRow = createPaginationButtons(pages.length, currentPage); + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [selectMenu, ...(pages.length > 1 ? [paginationRow] : [])], + }); + + if (options.length <= 1 && pages.length <= 1) return; + + // Create collector for both select menu and button interactions + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + const buttonCollector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 60000, + }); + + collector.on('collect', async (i: StringSelectMenuInteraction) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use this menu.', + ephemeral: true, + }); + return; + } + + await i.deferUpdate(); + + const selected = i.values[0]; + let categoryPages; + let selectedAchievements; + + if (selected === 'earned') { + selectedAchievements = earnedAchievements; + categoryPages = splitAchievementsIntoPages( + earnedAchievements, + 'Earned Achievements', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } else if (selected === 'progress') { + selectedAchievements = inProgressAchievements; + categoryPages = splitAchievementsIntoPages( + inProgressAchievements, + 'Achievements In Progress', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } else if (selected === 'available') { + selectedAchievements = availableAchievements; + categoryPages = splitAchievementsIntoPages( + availableAchievements, + 'Available Achievements', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } + + if (categoryPages && categoryPages.length > 0) { + currentPage = 0; + pages.splice(0, pages.length, ...categoryPages); + + const updatedSelectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('achievement_view') + .setPlaceholder('Select achievement type') + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder() + .setLabel(`${opt.label} (${opt.count})`) + .setValue(opt.value) + .setDefault(opt.value === selected), + ), + ), + ); + + const updatedPaginationRow = createPaginationButtons( + pages.length, + currentPage, + ); + + await i.editReply({ + embeds: [pages[currentPage]], + components: [ + updatedSelectMenu, + ...(pages.length > 1 ? [updatedPaginationRow] : []), + ], + }); + } + }); + + buttonCollector.on('collect', async (i: ButtonInteraction) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + ephemeral: true, + }); + return; + } + + await i.deferUpdate(); + + if (i.customId === 'first') { + currentPage = 0; + } else if (i.customId === 'prev') { + currentPage = Math.max(0, currentPage - 1); + } else if (i.customId === 'next') { + currentPage = Math.min(pages.length - 1, currentPage + 1); + } else if (i.customId === 'last') { + currentPage = pages.length - 1; + } + + const updatedPaginationRow = createPaginationButtons( + pages.length, + currentPage, + ); + + await i.editReply({ + embeds: [pages[currentPage]], + components: [selectMenu, updatedPaginationRow], + }); + }); + + collector.on('end', () => { + buttonCollector.stop(); + }); + + buttonCollector.on('end', () => { + interaction.editReply({ components: [] }).catch((err) => { + console.error('Failed to edit reply after collector ended.', err); + }); + }); + } catch (error) { + console.error('Error viewing user achievements:', error); + await interaction.editReply( + 'An error occurred while fetching user achievements.', + ); + } +} + +/** + * Handle removing an achievement from a user + */ +async function handleUnawardAchievement( + interaction: ChatInputCommandInteraction, +) { + const user = interaction.options.getUser('user')!; + const achievementId = interaction.options.getInteger('achievement_id')!; + + try { + const allAchievements = await getAllAchievements(); + const achievement = allAchievements.find((a) => a.id === achievementId); + + if (!achievement) { + await interaction.editReply( + `Achievement with ID ${achievementId} not found.`, + ); + return; + } + + const userAchievements = await getUserAchievements(user.id); + const earnedAchievement = userAchievements.find( + (ua) => ua.achievementId === achievementId && ua.earnedAt !== null, + ); + + if (!earnedAchievement) { + await interaction.editReply( + `${user.username} has not earned the achievement "${achievement.name}".`, + ); + return; + } + + const success = await removeUserAchievement(user.id, achievementId); + + if (success) { + await interaction.editReply( + `Achievement "${achievement.name}" has been removed from ${user.username}.`, + ); + + if (achievement.rewardType === 'role' && achievement.rewardValue) { + try { + const member = await interaction.guild!.members.fetch(user.id); + await member.roles.remove(achievement.rewardValue); + } catch (err) { + console.error( + `Failed to remove role ${achievement.rewardValue} from user ${user.id}`, + err, + ); + await interaction.followUp({ + content: + 'Note: Failed to remove the role reward. Please check permissions and remove it manually if needed.', + ephemeral: true, + }); + } + } + } else { + await interaction.editReply( + `Failed to remove achievement "${achievement.name}" from ${user.username}.`, + ); + } + } catch (error) { + console.error('Error removing achievement from user:', error); + await interaction.editReply( + 'An error occurred while removing the achievement.', + ); + } +} + +function createAchievementsEmbed( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, +) { + return createPageEmbed( + achievements, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + 1, + 1, + ); +} + +/** + * Creates a visual progress bar + * @param progress - Number between 0-100 + * @returns A string representing a progress bar + */ +function createProgressBar(progress: number): string { + const filledBars = Math.round(progress / 10); + const emptyBars = 10 - filledBars; + + const filled = '█'.repeat(filledBars); + const empty = '░'.repeat(emptyBars); + + return `[${filled}${empty}]`; +} + +function formatType(type: string): string { + return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); +} + +/** + * Splits achievements into pages for pagination + */ +function splitAchievementsIntoPages( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, + achievementsPerPage: number = 5, +): EmbedBuilder[] { + if (achievements.length === 0) { + return [ + createAchievementsEmbed( + achievements, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + ), + ]; + } + + const groupedAchievements: Record = { + message_count: achievements.filter( + (a) => a.definition?.requirementType === 'message_count', + ), + level: achievements.filter( + (a) => a.definition?.requirementType === 'level', + ), + command_usage: achievements.filter( + (a) => a.definition?.requirementType === 'command_usage', + ), + reactions: achievements.filter( + (a) => a.definition?.requirementType === 'reactions', + ), + other: achievements.filter( + (a) => + !['message_count', 'level', 'command_usage', 'reactions'].includes( + a.definition?.requirementType, + ), + ), + }; + + let orderedAchievements: typeof achievements = []; + for (const [type, typeAchievements] of Object.entries(groupedAchievements)) { + if (typeAchievements.length > 0) { + orderedAchievements = orderedAchievements.concat( + typeAchievements.map((ach) => ({ + ...ach, + achievementType: type, + })), + ); + } + } + + const chunks: (typeof achievements)[] = []; + for (let i = 0; i < orderedAchievements.length; i += achievementsPerPage) { + chunks.push(orderedAchievements.slice(i, i + achievementsPerPage)); + } + + return chunks.map((chunk, index) => { + return createPageEmbed( + chunk, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + index + 1, + chunks.length, + ); + }); +} + +/** + * Creates an embed for a single page of achievements + */ +function createPageEmbed( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, + pageNumber: number = 1, + totalPages: number = 1, +): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle(`${user.username}'s ${title}`) + .setThumbnail(user.displayAvatarURL()) + .setFooter({ text: `Page ${pageNumber}/${totalPages}` }); + + if (achievements.length === 0) { + embed.setDescription('No achievements found.'); + return embed; + } + + let currentType: string | null = null; + + achievements.forEach((achievement) => { + const { definition, achievementType } = achievement; + if (!definition) return; + + if (achievementType && achievementType !== currentType) { + currentType = achievementType; + embed.addFields({ + name: `${formatType(currentType || '')} Achievements`, + value: '⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯', + }); + } + + let fieldValue = definition.description; + + if ( + achievement.earnedAt && + achievement.earnedAt !== null && + achievement.earnedAt !== undefined && + new Date(achievement.earnedAt).getTime() > 0 + ) { + const earnedDate = new Date(achievement.earnedAt); + fieldValue += `\n✅ **Completed**: `; + } else { + const progress = achievement.progress || 0; + const progressBar = createProgressBar(progress); + fieldValue += `\n${progressBar} **${progress}%**`; + + if (definition.requirementType === 'message_count') { + fieldValue += `\n📨 Send ${definition.threshold} messages`; + } else if (definition.requirementType === 'level') { + fieldValue += `\n🏆 Reach level ${definition.threshold}`; + } else if (definition.requirementType === 'command_usage') { + const cmdName = definition.requirement?.command || 'unknown'; + fieldValue += `\n🔧 Use /${cmdName} command`; + } else if (definition.requirementType === 'reactions') { + fieldValue += `\n😀 Add ${definition.threshold} reactions`; + } + } + + if (definition.rewardType && definition.rewardValue) { + fieldValue += `\n💰 **Reward**: ${ + definition.rewardType === 'xp' + ? `${definition.rewardValue} XP` + : `Role <@&${definition.rewardValue}>` + }`; + } + + embed.addFields({ + name: definition.name, + value: fieldValue, + }); + }); + + embed.addFields({ + name: '📊 Overall Achievement Progress', + value: + `${createProgressBar(overallProgress)} **${overallProgress}%**\n` + + `You've earned **${earnedCount}** of ${totalAchievements} achievements`, + }); + + return embed; +} + +export default command; diff --git a/src/commands/fun/counting.ts b/src/commands/fun/counting.ts new file mode 100644 index 0000000..ce7cb6c --- /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/commands/fun/fact.ts b/src/commands/fun/fact.ts new file mode 100644 index 0000000..45869e0 --- /dev/null +++ b/src/commands/fun/fact.ts @@ -0,0 +1,312 @@ +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'; +import { createPaginationButtons } from '@/util/helpers.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; + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + await interaction.editReply('Processing...'); + + 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 factId = await getLastInsertedFactId(); + + const approveButton = new ButtonBuilder() + .setCustomId(`approve_fact_${factId}`) + .setLabel('Approve') + .setStyle(ButtonStyle.Success); + + const rejectButton = new ButtonBuilder() + .setCustomId(`reject_fact_${factId}`) + .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.editReply({ + content: isAdmin + ? 'Your fact has been automatically approved and added to the database!' + : 'Your fact has been submitted for approval!', + }); + } else if (subcommand === 'approve') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to approve facts.', + }); + return; + } + + const id = interaction.options.getInteger('id', true); + await approveFact(id); + + await interaction.editReply({ + content: `Fact #${id} has been approved!`, + }); + } else if (subcommand === 'delete') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to delete facts.', + }); + return; + } + + const id = interaction.options.getInteger('id', true); + await deleteFact(id); + + await interaction.editReply({ + content: `Fact #${id} has been deleted!`, + }); + } else if (subcommand === 'pending') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to view pending facts.', + }); + return; + } + + const FACTS_PER_PAGE = 5; + const pendingFacts = await getPendingFacts(); + + if (pendingFacts.length === 0) { + await interaction.editReply({ + content: 'There are no pending facts.', + }); + return; + } + + const pages: EmbedBuilder[] = []; + for (let i = 0; i < pendingFacts.length; i += FACTS_PER_PAGE) { + const pageFacts = pendingFacts.slice(i, i + FACTS_PER_PAGE); + + const embed = new EmbedBuilder() + .setTitle('Pending Facts') + .setColor(0x0099ff) + .setDescription( + pageFacts + .map((fact) => { + return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`; + }) + .join('\n\n'), + ) + .setTimestamp(); + + pages.push(embed); + } + + let currentPage = 0; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + 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()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + } + + 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: [createPaginationButtons(pages.length, currentPage)], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } + }); + } else if (subcommand === 'post') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to manually post facts.', + }); + return; + } + + await postFactOfTheDay(interaction.client); + + await interaction.editReply({ + content: 'Fact of the day has been posted!', + }); + } + }, +}; + +export default command; diff --git a/src/commands/fun/giveaway.ts b/src/commands/fun/giveaway.ts new file mode 100644 index 0000000..29a557c --- /dev/null +++ b/src/commands/fun/giveaway.ts @@ -0,0 +1,361 @@ +import { + SlashCommandBuilder, + PermissionsBitField, + EmbedBuilder, + ChatInputCommandInteraction, +} from 'discord.js'; + +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { + getGiveaway, + getActiveGiveaways, + endGiveaway, + rerollGiveaway, +} from '@/db/db.js'; +import { + createGiveawayEmbed, + formatWinnerMentions, + builder, +} from '@/util/giveaways/giveawayManager.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('giveaway') + .setDescription('Create and manage giveaways') + .addSubcommand((sub) => + sub.setName('create').setDescription('Start creating a new giveaway'), + ) + .addSubcommand((sub) => + sub.setName('list').setDescription('List all active giveaways'), + ) + .addSubcommand((sub) => + sub + .setName('end') + .setDescription('End a giveaway early') + .addStringOption((opt) => + opt + .setName('id') + .setDescription('Id of the giveaway') + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('reroll') + .setDescription('Reroll winners for a giveaway') + .addStringOption((opt) => + opt + .setName('id') + .setDescription('Id of the giveaway') + .setRequired(true), + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to manage giveaways.', + ephemeral: true, + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + await handleCreateGiveaway(interaction); + break; + case 'list': + await handleListGiveaways(interaction); + break; + case 'end': + await handleEndGiveaway(interaction); + break; + case 'reroll': + await handleRerollGiveaway(interaction); + break; + } + }, +}; + +/** + * Initialize the giveaway creation process + */ +async function handleCreateGiveaway(interaction: ChatInputCommandInteraction) { + await builder.startGiveawayBuilder(interaction); +} + +/** + * Handle the list giveaways subcommand + */ +async function handleListGiveaways(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + const GIVEAWAYS_PER_PAGE = 5; + + try { + const activeGiveaways = await getActiveGiveaways(); + + if (activeGiveaways.length === 0) { + await interaction.editReply({ + content: 'There are no active giveaways at the moment.', + }); + return; + } + + const pages: EmbedBuilder[] = []; + for (let i = 0; i < activeGiveaways.length; i += GIVEAWAYS_PER_PAGE) { + const pageGiveaways = activeGiveaways.slice(i, i + GIVEAWAYS_PER_PAGE); + + const embed = new EmbedBuilder() + .setTitle('🎉 Active Giveaways') + .setColor(0x00ff00) + .setDescription('Here are the currently active giveaways:') + .setTimestamp(); + + pageGiveaways.forEach((giveaway) => { + embed.addFields({ + name: `${giveaway.prize} (ID: ${giveaway.id})`, + value: [ + `**Hosted by:** <@${giveaway.hostId}>`, + `**Winners:** ${giveaway.winnerCount}`, + `**Ends:** `, + `**Entries:** ${giveaway.participants?.length || 0}`, + `[Jump to Giveaway](https://discord.com/channels/${interaction.guildId}/${giveaway.channelId}/${giveaway.messageId})`, + ].join('\n'), + inline: false, + }); + }); + + pages.push(embed); + } + + let currentPage = 0; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + ephemeral: true, + }); + return; + } + + if (i.isButton()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + } + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ + components: [], + }); + } catch (error) { + console.error('Error removing components:', error); + } + }); + } catch (error) { + console.error('Error fetching giveaways:', error); + await interaction.editReply({ + content: 'There was an error fetching the giveaways.', + }); + } +} + +/** + * Handle the end giveaway subcommand + */ +async function handleEndGiveaway(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const id = interaction.options.getString('id', true); + const giveaway = await getGiveaway(id, true); + + if (!giveaway) { + await interaction.editReply(`Giveaway with ID ${id} not found.`); + return; + } + + if (giveaway.status !== 'active') { + await interaction.editReply('This giveaway has already ended.'); + return; + } + + const endedGiveaway = await endGiveaway(id, true); + if (!endedGiveaway) { + await interaction.editReply( + 'Failed to end the giveaway. Please try again.', + ); + return; + } + + try { + const channel = interaction.guild?.channels.cache.get(giveaway.channelId); + if (!channel?.isTextBased()) { + await interaction.editReply( + 'Giveaway channel not found or is not a text channel.', + ); + return; + } + + const messageId = giveaway.messageId; + const giveawayMessage = await channel.messages.fetch(messageId); + + if (!giveawayMessage) { + await interaction.editReply('Giveaway message not found.'); + return; + } + + await giveawayMessage.edit({ + embeds: [ + createGiveawayEmbed({ + id: endedGiveaway.id, + prize: endedGiveaway.prize, + hostId: endedGiveaway.hostId, + winnersIds: endedGiveaway.winnersIds ?? [], + isEnded: true, + footerText: 'Ended early by a moderator', + }), + ], + components: [], + }); + + if (endedGiveaway.winnersIds?.length) { + const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds); + await channel.send({ + content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`, + allowedMentions: { users: endedGiveaway.winnersIds }, + }); + } else { + await channel.send( + `No one entered the giveaway for **${endedGiveaway.prize}**!`, + ); + } + + await interaction.editReply('Giveaway ended successfully!'); + } catch (error) { + console.error('Error ending giveaway:', error); + await interaction.editReply('Failed to update the giveaway message.'); + } +} + +/** + * Handle the reroll giveaway subcommand + */ +async function handleRerollGiveaway(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: ['Ephemeral'] }); + const id = interaction.options.getString('id', true); + + const originalGiveaway = await getGiveaway(id, true); + + if (!originalGiveaway) { + await interaction.editReply(`Giveaway with ID ${id} not found.`); + return; + } + + if (originalGiveaway.status !== 'ended') { + await interaction.editReply( + 'This giveaway is not yet ended. You can only reroll ended giveaways.', + ); + return; + } + + if (!originalGiveaway.participants?.length) { + await interaction.editReply( + 'Cannot reroll because no one entered this giveaway.', + ); + return; + } + + const rerolledGiveaway = await rerollGiveaway(id); + + if (!rerolledGiveaway) { + await interaction.editReply( + 'Failed to reroll the giveaway. An internal error occurred.', + ); + return; + } + + const previousWinners = originalGiveaway.winnersIds ?? []; + const newWinners = rerolledGiveaway.winnersIds ?? []; + + const winnersChanged = !( + previousWinners.length === newWinners.length && + previousWinners.every((w) => newWinners.includes(w)) + ); + + if (!winnersChanged && newWinners.length > 0) { + await interaction.editReply( + 'Could not reroll: No other eligible participants found besides the previous winner(s).', + ); + return; + } + if (newWinners.length === 0) { + await interaction.editReply( + 'Could not reroll: No eligible participants found.', + ); + return; + } + + try { + const channel = interaction.guild?.channels.cache.get( + rerolledGiveaway.channelId, + ); + if (!channel?.isTextBased()) { + await interaction.editReply( + 'Giveaway channel not found or is not a text channel. Reroll successful but announcement failed.', + ); + return; + } + + const winnerMentions = formatWinnerMentions(newWinners); + await channel.send({ + content: `🎉 The giveaway for **${rerolledGiveaway.prize}** has been rerolled! New winner(s): ${winnerMentions}`, + allowedMentions: { users: newWinners }, + }); + + await interaction.editReply('Giveaway rerolled successfully!'); + } catch (error) { + console.error('Error announcing rerolled giveaway:', error); + await interaction.editReply( + 'Giveaway rerolled, but failed to announce the new winners.', + ); + } +} + +export default command; diff --git a/src/commands/fun/leaderboard.ts b/src/commands/fun/leaderboard.ts new file mode 100644 index 0000000..5d33eb4 --- /dev/null +++ b/src/commands/fun/leaderboard.ts @@ -0,0 +1,168 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + APIEmbed, + JSONEncodable, +} from 'discord.js'; + +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { getLevelLeaderboard } from '@/db/db.js'; +import { createPaginationButtons } from '@/util/helpers.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 = () => + createPaginationButtons(pages.length, currentPage); + + 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: 300000, + }); + + 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()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + } + + 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..7863779 --- /dev/null +++ b/src/commands/fun/rank.ts @@ -0,0 +1,46 @@ +import { 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/moderation/ban.ts b/src/commands/moderation/ban.ts index 4cbc40f..84a5ee8 100644 --- a/src/commands/moderation/ban.ts +++ b/src/commands/moderation/ban.ts @@ -1,9 +1,9 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { updateMember, updateMemberModerationHistory } from '../../db/db.js'; -import { parseDuration, scheduleUnban } from '../../util/helpers.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; -import logAction from '../../util/logging/logAction.js'; +import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; +import { parseDuration, scheduleUnban } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() diff --git a/src/commands/moderation/unban.ts b/src/commands/moderation/unban.ts index 8a458f2..c160148 100644 --- a/src/commands/moderation/unban.ts +++ b/src/commands/moderation/unban.ts @@ -1,7 +1,7 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { executeUnban } from '../../util/helpers.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; +import { executeUnban } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index c92c7ae..be2f386 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -1,8 +1,8 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { updateMemberModerationHistory } from '../../db/db.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; -import logAction from '../../util/logging/logAction.js'; +import { updateMemberModerationHistory } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() diff --git a/src/commands/testing/testJoin.ts b/src/commands/testing/testJoin.ts index b1998e0..cf66f6b 100644 --- a/src/commands/testing/testJoin.ts +++ b/src/commands/testing/testJoin.ts @@ -1,6 +1,6 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/testing/testLeave.ts b/src/commands/testing/testLeave.ts index b643b0e..1aba95c 100644 --- a/src/commands/testing/testLeave.ts +++ b/src/commands/testing/testLeave.ts @@ -1,7 +1,7 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { updateMember } from '../../db/db.js'; -import { Command } from '../../types/CommandTypes.js'; +import { updateMember } from '@/db/db.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index 083ee64..6dd4762 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -1,16 +1,15 @@ import { SlashCommandBuilder, EmbedBuilder, - ButtonBuilder, ActionRowBuilder, - ButtonStyle, StringSelectMenuBuilder, APIEmbed, JSONEncodable, } from 'discord.js'; -import { getAllMembers } from '../../db/db.js'; -import { Command } from '../../types/CommandTypes.js'; +import { getAllMembers } from '@/db/db.js'; +import { Command } from '@/types/CommandTypes.js'; +import { createPaginationButtons } from '@/util/helpers.js'; const command: Command = { data: new SlashCommandBuilder() @@ -19,7 +18,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; @@ -42,18 +41,7 @@ const command: Command = { 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), - ); + createPaginationButtons(pages.length, currentPage); const getSelectMenuRow = () => { const options = pages.map((_, index) => ({ @@ -85,7 +73,7 @@ const command: Command = { if (pages.length <= 1) return; const collector = message.createMessageComponentCollector({ - time: 60000, + time: 300000, }); collector.on('collect', async (i) => { @@ -98,10 +86,19 @@ const command: Command = { } if (i.isButton()) { - if (i.customId === 'previous' && currentPage > 0) { - currentPage--; - } else if (i.customId === 'next' && currentPage < pages.length - 1) { - currentPage++; + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; } } diff --git a/src/commands/util/ping.ts b/src/commands/util/ping.ts index ed64529..5e2bddc 100644 --- a/src/commands/util/ping.ts +++ b/src/commands/util/ping.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/recalculatelevels.ts b/src/commands/util/recalculatelevels.ts new file mode 100644 index 0000000..4c30806 --- /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/reconnect.ts b/src/commands/util/reconnect.ts new file mode 100644 index 0000000..886fcca --- /dev/null +++ b/src/commands/util/reconnect.ts @@ -0,0 +1,200 @@ +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..31ecfc9 --- /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/commands/util/rules.ts b/src/commands/util/rules.ts index c8ff029..8720c1a 100644 --- a/src/commands/util/rules.ts +++ b/src/commands/util/rules.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const rulesEmbed = new EmbedBuilder() .setColor(0x0099ff) diff --git a/src/commands/util/server.ts b/src/commands/util/server.ts index a391e03..766c9f0 100644 --- a/src/commands/util/server.ts +++ b/src/commands/util/server.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/user-info.ts b/src/commands/util/user-info.ts index 7f1ac68..7b0a52b 100644 --- a/src/commands/util/user-info.ts +++ b/src/commands/util/user-info.ts @@ -5,8 +5,8 @@ import { PermissionsBitField, } from 'discord.js'; -import { getMember } from '../../db/db.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; +import { getMember } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() diff --git a/src/commands/util/xp.ts b/src/commands/util/xp.ts new file mode 100644 index 0000000..979da6a --- /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 ebc2db8..694b2b1 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,20 +1,45 @@ +// ======================== +// External Imports +// ======================== import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; +import { Client } from 'discord.js'; +// ======================== +// Internal Imports +// ======================== import * as schema from './schema.js'; -import { loadConfig } from '../util/configLoader.js'; +import { loadConfig } from '@/util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; +import { + logManagerNotification, + NotificationType, + notifyManagers, +} from '@/util/notificationHandler.js'; +// ======================== +// Database Configuration +// ======================== const { Pool } = pkg; const config = loadConfig(); -const dbPool = new Pool({ - connectionString: config.dbConnectionString, - ssl: true, -}); -export const db = drizzle({ client: dbPool, schema }); +// Connection parameters +const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts; +const INITIAL_DB_RETRY_DELAY = config.database.retryDelay; +// ======================== +// Connection State Variables +// ======================== +let isDbConnected = false; +let connectionAttempts = 0; +let hasNotifiedDbDisconnect = false; +let discordClient: Client | null = null; +let dbPool: pkg.Pool; +export let db: ReturnType; + +/** + * Custom error class for database operations + */ class DatabaseError extends Error { constructor( message: string, @@ -25,208 +50,264 @@ class DatabaseError extends Error { } } -export async function getAllMembers() { +// ======================== +// Client Management +// ======================== + +/** + * Sets the Discord client for sending notifications + * @param client - The Discord client + */ +export function setDiscordClient(client: Client): void { + discordClient = client; +} + +// ======================== +// Connection Management +// ======================== + +/** + * Initializes the database connection with retry logic + * @returns Promise resolving to true if connected successfully, false otherwise + */ +export async function initializeDatabaseConnection(): Promise { try { - if (await exists('nonBotMembers')) { - const memberData = - await getJson<(typeof schema.memberTable.$inferSelect)[]>( - 'nonBotMembers', + // Check if existing connection is working + 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', ); - if (memberData && memberData.length > 0) { - return memberData; - } else { - await del('nonBotMembers'); - return await getAllMembers(); + try { + await dbPool.end(); + } catch (endError) { + console.error('Error ending pool:', endError); + } } - } else { - 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); - } + // Log the database connection attempt + console.log( + `Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`, + ); + + // Create new connection pool + dbPool = new Pool({ + connectionString: config.database.dbConnectionString, + ssl: true, + connectionTimeoutMillis: 10000, }); + + // Test connection + await dbPool.query('SELECT 1'); + + // Initialize Drizzle ORM + db = drizzle({ client: dbPool, schema }); + + // Connection successful + console.info('Successfully connected to database'); + isDbConnected = true; + connectionAttempts = 0; + + // Send notification if connection was previously lost + if (hasNotifiedDbDisconnect && discordClient) { + logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED); + notifyManagers( + discordClient, + NotificationType.DATABASE_CONNECTION_RESTORED, + ); + hasNotifiedDbDisconnect = false; + } + + return true; } catch (error) { - console.error('Error setting members: ', error); - throw new DatabaseError('Failed to set members: ', error as Error); - } -} + console.error('Failed to connect to database:', error); + isDbConnected = false; + connectionAttempts++; -export async function getMember(discordId: string) { - 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`); - - 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); + // Handle max retry attempts exceeded + 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; } - } 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, - ); + // Terminate after sending notifications + setTimeout(() => { + console.error('Database connection failed, shutting down bot'); + process.exit(1); + }, 3000); - return member; + return false; } - } catch (error) { - console.error('Error getting member: ', error); - throw new DatabaseError('Failed to get member: ', error as Error); + + // Retry connection 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; } } -export async function updateMember({ - discordId, - discordUsername, - currentlyInServer, - currentlyBanned, -}: schema.memberTableTypes) { - try { - const result = await db - .update(schema.memberTable) - .set({ - discordUsername, - currentlyInServer, - currentlyBanned, - }) - .where(eq(schema.memberTable.discordId, discordId)); +// Initialize database connection +let dbInitPromise = initializeDatabaseConnection().catch((error) => { + console.error('Failed to initialize database connection:', error); + process.exit(1); +}); - if (await exists(`${discordId}-memberInfo`)) { - await del(`${discordId}-memberInfo`); - } - if (await exists('nonBotMembers')) { - await del('nonBotMembers'); - } +// ======================== +// Helper Functions +// ======================== - return result; - } catch (error) { - console.error('Error updating member: ', error); - throw new DatabaseError('Failed to update member: ', error as Error); +/** + * 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; } } -export async function updateMemberModerationHistory({ - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, -}: schema.moderationTableTypes) { +/** + * 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 { - const moderationEntry = { - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - 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`); - } - - return result; + await dbPool.query('SELECT 1'); + return true; } catch (error) { - console.error('Error updating moderation history: ', error); - throw new DatabaseError( - 'Failed to update moderation history: ', - error as Error, + console.error('Database connection test failed:', error); + isDbConnected = false; + return await initializeDatabaseConnection(); + } +} + +/** + * Generic error handler for database operations + * @param errorMessage - Error message to log + * @param error - Original error object + * @throws {DatabaseError} - Always throws a wrapped database error + */ +export const handleDbError = (errorMessage: string, error: Error): never => { + console.error(`${errorMessage}:`, error); + + // Check if error is related to connection and attempt to reconnect + 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); +}; + +// ======================== +// Cache Management +// ======================== + +/** + * 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 in seconds + * @returns Cached or freshly fetched data + */ +export 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; } -export async function getMemberModerationHistory(discordId: string) { +/** + * Invalidates a cache key if it exists + * @param cacheKey - Key to invalidate + */ +export async function invalidateCache(cacheKey: string): Promise { 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; + if (await exists(cacheKey)) { + await del(cacheKey); } } catch (error) { - console.error('Error getting moderation history: ', error); - throw new DatabaseError( - 'Failed to get moderation history: ', - error as Error, - ); + console.warn(`Error invalidating cache for key ${cacheKey}:`, error); } } + +// ======================== +// Database Functions Exports +// ======================== + +// Achievement related functions +export * from './functions/achievementFunctions.js'; + +// Facts system functions +export * from './functions/factFunctions.js'; + +// Giveaway management functions +export * from './functions/giveawayFunctions.js'; + +// User leveling system functions +export * from './functions/levelFunctions.js'; + +// Guild member management functions +export * from './functions/memberFunctions.js'; + +// Moderation and administration functions +export * from './functions/moderationFunctions.js'; diff --git a/src/db/functions/achievementFunctions.ts b/src/db/functions/achievementFunctions.ts new file mode 100644 index 0000000..30cbc3b --- /dev/null +++ b/src/db/functions/achievementFunctions.ts @@ -0,0 +1,282 @@ +import { and, eq } from 'drizzle-orm'; + +import { db, ensureDbInitialized, handleDbError } from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Get all achievement definitions + * @returns Array of achievement definitions + */ +export async function getAllAchievements(): Promise< + schema.achievementDefinitionsTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get achievements'); + return []; + } + + return await db + .select() + .from(schema.achievementDefinitionsTable) + .orderBy(schema.achievementDefinitionsTable.threshold); + } catch (error) { + return handleDbError('Failed to get all achievements', error as Error); + } +} + +/** + * Get achievements for a specific user + * @param userId - Discord ID of the user + * @returns Array of user achievements + */ +export async function getUserAchievements( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user achievements'); + return []; + } + + return await db + .select({ + id: schema.userAchievementsTable.id, + discordId: schema.userAchievementsTable.discordId, + achievementId: schema.userAchievementsTable.achievementId, + earnedAt: schema.userAchievementsTable.earnedAt, + progress: schema.userAchievementsTable.progress, + }) + .from(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.discordId, userId)); + } catch (error) { + return handleDbError('Failed to get user achievements', error as Error); + } +} + +/** + * Award an achievement to a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @returns Boolean indicating success + */ +export async function awardAchievement( + userId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot award achievement'); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + earnedAt: new Date(), + progress: 100, + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + earnedAt: new Date(), + progress: 100, + }); + } + + return true; + } catch (error) { + handleDbError('Failed to award achievement', error as Error); + return false; + } +} + +/** + * Update achievement progress for a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @param progress - Progress value (0-100) + * @returns Boolean indicating success + */ +export async function updateAchievementProgress( + userId: string, + achievementId: number, + progress: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot update achievement progress', + ); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }); + } + + return true; + } catch (error) { + handleDbError('Failed to update achievement progress', error as Error); + return false; + } +} + +/** + * Create a new achievement definition + * @param achievementData - Achievement definition data + * @returns Created achievement or undefined on failure + */ +export async function createAchievement(achievementData: { + name: string; + description: string; + imageUrl?: string; + requirementType: string; + threshold: number; + requirement?: any; + rewardType?: string; + rewardValue?: string; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create achievement'); + return undefined; + } + + const [achievement] = await db + .insert(schema.achievementDefinitionsTable) + .values({ + name: achievementData.name, + description: achievementData.description, + imageUrl: achievementData.imageUrl || null, + requirementType: achievementData.requirementType, + threshold: achievementData.threshold, + requirement: achievementData.requirement || {}, + rewardType: achievementData.rewardType || null, + rewardValue: achievementData.rewardValue || null, + }) + .returning(); + + return achievement; + } catch (error) { + return handleDbError('Failed to create achievement', error as Error); + } +} + +/** + * Delete an achievement definition + * @param achievementId - ID of the achievement to delete + * @returns Boolean indicating success + */ +export async function deleteAchievement( + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.achievementId, achievementId)); + + await db + .delete(schema.achievementDefinitionsTable) + .where(eq(schema.achievementDefinitionsTable.id, achievementId)); + + return true; + } catch (error) { + handleDbError('Failed to delete achievement', error as Error); + return false; + } +} + +/** + * Removes an achievement from a user + * @param discordId - Discord user ID + * @param achievementId - Achievement ID to remove + * @returns boolean indicating success + */ +export async function removeUserAchievement( + discordId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot remove user achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, discordId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ); + return true; + } catch (error) { + handleDbError('Failed to remove user achievement', error as Error); + return false; + } +} diff --git a/src/db/functions/factFunctions.ts b/src/db/functions/factFunctions.ts new file mode 100644 index 0000000..10134f3 --- /dev/null +++ b/src/db/functions/factFunctions.ts @@ -0,0 +1,198 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; + +/** + * 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): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add fact'); + } + + await db.insert(schema.factTable).values({ + content, + source, + addedBy, + approved, + }); + + await invalidateCache('unused-facts'); + } catch (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) { + return handleDbError('Failed to get last inserted fact ID', error as Error); + } +} + +/** + * Get a random fact that hasn't been used yet + * @returns Random fact object + */ +export async function getRandomUnusedFact(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get random unused fact'); + } + + 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 + .update(schema.factTable) + .set({ usedOn: null }) + .where(eq(schema.factTable.approved, true)); + + await invalidateCache(cacheKey); + return await getRandomUnusedFact(); + } + + return facts[ + Math.floor(Math.random() * facts.length) + ] as schema.factTableTypes; + } catch (error) { + return handleDbError('Failed to get random fact', error as Error); + } +} + +/** + * 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 invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to mark fact as used', error as Error); + } +} + +/** + * Get all pending facts that need approval + * @returns Array of pending fact objects + */ +export async function getPendingFacts(): Promise { + try { + 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))) as schema.factTableTypes[]; + } catch (error) { + return handleDbError('Failed to get pending facts', error as Error); + } +} + +/** + * 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 invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to approve fact', error as Error); + } +} + +/** + * 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 invalidateCache('unused-facts'); + } catch (error) { + return handleDbError('Failed to delete fact', error as Error); + } +} diff --git a/src/db/functions/giveawayFunctions.ts b/src/db/functions/giveawayFunctions.ts new file mode 100644 index 0000000..354b07f --- /dev/null +++ b/src/db/functions/giveawayFunctions.ts @@ -0,0 +1,275 @@ +import { eq } from 'drizzle-orm'; + +import { db, ensureDbInitialized, handleDbError } from '../db.js'; +import { selectGiveawayWinners } from '@/util/giveaways/utils.js'; +import * as schema from '../schema.js'; + +/** + * Create a giveaway in the database + * @param giveawayData - Data for the giveaway + * @returns Created giveaway object + */ +export async function createGiveaway(giveawayData: { + channelId: string; + messageId: string; + endAt: Date; + prize: string; + winnerCount: number; + hostId: string; + requirements?: { + level?: number; + roleId?: string; + messageCount?: number; + requireAll?: boolean; + }; + bonuses?: { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; + }; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create giveaway'); + } + + const [giveaway] = await db + .insert(schema.giveawayTable) + .values({ + channelId: giveawayData.channelId, + messageId: giveawayData.messageId, + endAt: giveawayData.endAt, + prize: giveawayData.prize, + winnerCount: giveawayData.winnerCount, + hostId: giveawayData.hostId, + requiredLevel: giveawayData.requirements?.level, + requiredRoleId: giveawayData.requirements?.roleId, + requiredMessageCount: giveawayData.requirements?.messageCount, + requireAllCriteria: giveawayData.requirements?.requireAll ?? true, + bonusEntries: + giveawayData.bonuses as schema.giveawayTableTypes['bonusEntries'], + }) + .returning(); + + return giveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to create giveaway', error as Error); + } +} + +/** + * Get a giveaway by ID or message ID + * @param id - ID of the giveaway + * @param isDbId - Whether the ID is a database ID + * @returns Giveaway object or undefined if not found + */ +export async function getGiveaway( + id: string | number, + isDbId = false, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get giveaway'); + return undefined; + } + + if (isDbId) { + const numId = typeof id === 'string' ? parseInt(id) : id; + const [giveaway] = await db + .select() + .from(schema.giveawayTable) + .where(eq(schema.giveawayTable.id, numId)) + .limit(1); + + return giveaway as schema.giveawayTableTypes; + } else { + const [giveaway] = await db + .select() + .from(schema.giveawayTable) + .where(eq(schema.giveawayTable.messageId, id as string)) + .limit(1); + + return giveaway as schema.giveawayTableTypes; + } + } catch (error) { + return handleDbError('Failed to get giveaway', error as Error); + } +} + +/** + * Get all active giveaways + * @returns Array of active giveaway objects + */ +export async function getActiveGiveaways(): Promise< + schema.giveawayTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get active giveaways'); + } + + return (await db + .select() + .from(schema.giveawayTable) + .where( + eq(schema.giveawayTable.status, 'active'), + )) as schema.giveawayTableTypes[]; + } catch (error) { + return handleDbError('Failed to get active giveaways', error as Error); + } +} + +/** + * Update giveaway participants + * @param messageId - ID of the giveaway message + * @param userId - ID of the user to add + * @param entries - Number of entries to add + * @return 'success' | 'already_entered' | 'inactive' | 'error' + */ +export async function addGiveawayParticipant( + messageId: string, + userId: string, + entries = 1, +): Promise<'success' | 'already_entered' | 'inactive' | 'error'> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add participant'); + return 'error'; + } + + const giveaway = await getGiveaway(messageId); + if (!giveaway || giveaway.status !== 'active') { + return 'inactive'; + } + + if (giveaway.participants?.includes(userId)) { + return 'already_entered'; + } + + const participants = [...(giveaway.participants || [])]; + for (let i = 0; i < entries; i++) { + participants.push(userId); + } + + await db + .update(schema.giveawayTable) + .set({ participants: participants }) + .where(eq(schema.giveawayTable.messageId, messageId)); + + return 'success'; + } catch (error) { + handleDbError('Failed to add giveaway participant', error as Error); + return 'error'; + } +} + +/** + * End a giveaway + * @param id - ID of the giveaway + * @param isDbId - Whether the ID is a database ID + * @param forceWinners - Array of user IDs to force as winners + * @return Updated giveaway object + */ +export async function endGiveaway( + id: string | number, + isDbId = false, + forceWinners?: string[], +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot end giveaway'); + return undefined; + } + + const giveaway = await getGiveaway(id, isDbId); + if (!giveaway || giveaway.status !== 'active' || !giveaway.participants) { + return undefined; + } + + const winners = selectGiveawayWinners( + giveaway.participants, + giveaway.winnerCount, + forceWinners, + ); + + const [updatedGiveaway] = await db + .update(schema.giveawayTable) + .set({ + status: 'ended', + winnersIds: winners, + }) + .where(eq(schema.giveawayTable.id, giveaway.id)) + .returning(); + + return updatedGiveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to end giveaway', error as Error); + } +} + +/** + * Reroll winners for a giveaway + * @param id - ID of the giveaway + * @return Updated giveaway object + */ +export async function rerollGiveaway( + id: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot reroll giveaway'); + return undefined; + } + + const giveaway = await getGiveaway(id, true); + if ( + !giveaway || + !giveaway.participants || + giveaway.participants.length === 0 || + giveaway.status !== 'ended' + ) { + console.warn( + `Cannot reroll giveaway ${id}: Not found, no participants, or not ended.`, + ); + return undefined; + } + + const newWinners = selectGiveawayWinners( + giveaway.participants, + giveaway.winnerCount, + undefined, + giveaway.winnersIds ?? [], + ); + + if (newWinners.length === 0) { + console.warn( + `Cannot reroll giveaway ${id}: No eligible participants left after excluding previous winners.`, + ); + return giveaway; + } + + const [updatedGiveaway] = await db + .update(schema.giveawayTable) + .set({ + winnersIds: newWinners, + }) + .where(eq(schema.giveawayTable.id, giveaway.id)) + .returning(); + + return updatedGiveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to reroll giveaway', error as Error); + } +} diff --git a/src/db/functions/levelFunctions.ts b/src/db/functions/levelFunctions.ts new file mode 100644 index 0000000..3bfd42a --- /dev/null +++ b/src/db/functions/levelFunctions.ts @@ -0,0 +1,329 @@ +import { desc, eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; +import { calculateLevelFromXp } from '@/util/levelingSystem.js'; + +/** + * 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 { + 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, + }; + } + + const newLevel: schema.levelTableTypes = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + messagesSent: 0, + reactionCount: 0, + }; + + await db.insert(schema.levelTable).values(newLevel); + return newLevel; + }, + 300, + ); + } catch (error) { + return handleDbError('Error getting user level', error as Error); + } +} + +/** + * 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; + messagesSent: 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; + const currentXp = Number(userData.xp); + const xpToAdd = Number(amount); + + userData.xp = currentXp + xpToAdd; + + userData.lastMessageTimestamp = new Date(); + userData.level = calculateLevelFromXp(userData.xp); + userData.messagesSent += 1; + + 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, + messagesSent: userData.messagesSent, + }) + .where(eq(schema.levelTable.discordId, discordId)) + .returning(); + + return result[0] as schema.levelTableTypes; + }, + 300, + ); + + return { + leveledUp: userData.level > currentLevel, + newLevel: userData.level, + oldLevel: currentLevel, + messagesSent: userData.messagesSent, + }; + } catch (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 { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user rank'); + } + + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; + } + } + + return 1; + } catch (error) { + return handleDbError('Failed to get user rank', error as Error); + } +} + +/** + * Clear leaderboard cache + */ +export async function invalidateLeaderboardCache(): Promise { + await invalidateCache('xp-leaderboard'); +} + +/** + * Helper function to get or create leaderboard data + * @returns Array of leaderboard data + */ +async function getLeaderboardData(): Promise< + Array<{ + discordId: string; + xp: number; + }> +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get leaderboard data'); + } + + const cacheKey = 'xp-leaderboard'; + 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); + } +} + +/** + * Increments the user's reaction count + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function incrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = (levelData.reactionCount || 0) + 1; + await db + .update(schema.levelTable) + .set({ reactionCount: newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error incrementing user reaction count:', error); + return 0; + } +} + +/** + * Decrements the user's reaction count (but not below zero) + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function decrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = Math.max(0, levelData.reactionCount - 1); + await db + .update(schema.levelTable) + .set({ reactionCount: newCount < 0 ? 0 : newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error decrementing user reaction count:', error); + return 0; + } +} + +/** + * Gets the user's reaction count + * @param userId - Discord user ID + * @returns The user's reaction count + */ +export async function getUserReactionCount(userId: string): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user reaction count'); + } + + const levelData = await getUserLevel(userId); + return levelData.reactionCount; + } catch (error) { + console.error('Error getting user reaction count:', error); + return 0; + } +} + +/** + * 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)) as schema.levelTableTypes[]; + } catch (error) { + return handleDbError('Failed to get leaderboard', error as Error); + } +} diff --git a/src/db/functions/memberFunctions.ts b/src/db/functions/memberFunctions.ts new file mode 100644 index 0000000..aa72b30 --- /dev/null +++ b/src/db/functions/memberFunctions.ts @@ -0,0 +1,160 @@ +import { Collection, GuildMember } from 'discord.js'; +import { eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; +import { getMemberModerationHistory } from './moderationFunctions.js'; + +/** + * Get all non-bot members currently in the server + * @returns Array of member objects + */ +export async function getAllMembers() { + try { + 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)); + return nonBotMembers; + }); + } catch (error) { + return handleDbError('Failed to get all members', error as Error); + } +} + +/** + * Set or update multiple members at once + * @param nonBotMembers - Array of member objects + */ +export async function setMembers( + nonBotMembers: Collection, +): Promise { + try { + await ensureDbInitialized(); + + 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) { + 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): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot update member'); + } + + await db + .update(schema.memberTable) + .set({ + discordUsername, + currentlyInServer, + currentlyBanned, + }) + .where(eq(schema.memberTable.discordId, discordId)); + + await Promise.all([ + invalidateCache(`${discordId}-memberInfo`), + invalidateCache('nonBotMembers'), + ]); + } catch (error) { + handleDbError('Failed to update member', error as Error); + } +} diff --git a/src/db/functions/moderationFunctions.ts b/src/db/functions/moderationFunctions.ts new file mode 100644 index 0000000..601a9ad --- /dev/null +++ b/src/db/functions/moderationFunctions.ts @@ -0,0 +1,96 @@ +import { eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; + +/** + * 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, + action, + reason, + duration, + createdAt, + expiresAt, + active, +}: schema.moderationTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, update member moderation history', + ); + } + + const moderationEntry = { + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, + }; + + await db.insert(schema.moderationTable).values(moderationEntry); + + await Promise.all([ + invalidateCache(`${discordId}-moderationHistory`), + invalidateCache(`${discordId}-memberInfo`), + ]); + } catch (error) { + handleDbError('Failed to update moderation history', error as Error); + } +} + +/** + * 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 { + 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); + } +} diff --git a/src/db/redis.ts b/src/db/redis.ts index 8938d17..7897caa 100644 --- a/src/db/redis.ts +++ b/src/db/redis.ts @@ -1,9 +1,31 @@ import Redis from 'ioredis'; -import { loadConfig } from '../util/configLoader.js'; +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) => { - 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 cbf6782..843decb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,11 +1,13 @@ import { boolean, integer, + json, + jsonb, pgTable, timestamp, varchar, } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; +import { InferSelectModel, relations } from 'drizzle-orm'; export interface memberTableTypes { id?: number; @@ -25,6 +27,28 @@ export const memberTable = pgTable('members', { currentlyMuted: boolean('currently_muted').notNull().default(false), }); +export interface levelTableTypes { + id?: number; + discordId: string; + xp: number; + level: number; + messagesSent: number; + reactionCount: 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(0), + messagesSent: integer('messages_sent').notNull().default(0), + reactionCount: integer('reaction_count').notNull().default(0), + lastMessageTimestamp: timestamp('last_message_timestamp'), +}); + export interface moderationTableTypes { id?: number; discordId: string; @@ -51,8 +75,20 @@ 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], + }), + facts: many(factTable), +})); + +export const levelRelations = relations(levelTable, ({ one }) => ({ + member: one(memberTable, { + fields: [levelTable.discordId], + references: [memberTable.discordId], + }), })); export const moderationRelations = relations(moderationTable, ({ one }) => ({ @@ -61,3 +97,85 @@ 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'), +}); + +export type giveawayTableTypes = InferSelectModel & { + bonusEntries: { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; + }; +}; + +export const giveawayTable = pgTable('giveaways', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + channelId: varchar('channel_id').notNull(), + messageId: varchar('message_id').notNull().unique(), + createdAt: timestamp('created_at').defaultNow(), + endAt: timestamp('end_at').notNull(), + prize: varchar('prize').notNull(), + winnerCount: integer('winner_count').notNull().default(1), + hostId: varchar('host_id') + .references(() => memberTable.discordId) + .notNull(), + status: varchar('status').notNull().default('active'), + participants: varchar('participants').array().default([]), + winnersIds: varchar('winners_ids').array().default([]), + requiredLevel: integer('required_level'), + requiredRoleId: varchar('required_role_id'), + requiredMessageCount: integer('required_message_count'), + requireAllCriteria: boolean('require_all_criteria').default(true), + bonusEntries: jsonb('bonus_entries').default({}), +}); + +export type userAchievementsTableTypes = InferSelectModel< + typeof userAchievementsTable +>; + +export const userAchievementsTable = pgTable('user_achievements', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('user_id', { length: 50 }) + .notNull() + .references(() => memberTable.discordId), + achievementId: integer('achievement_id') + .notNull() + .references(() => achievementDefinitionsTable.id), + earnedAt: timestamp('earned_at'), + progress: integer().default(0), +}); + +export type achievementDefinitionsTableTypes = InferSelectModel< + typeof achievementDefinitionsTable +>; + +export const achievementDefinitionsTable = pgTable('achievement_definitions', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + name: varchar({ length: 100 }).notNull(), + description: varchar({ length: 255 }).notNull(), + imageUrl: varchar('image_url', { length: 255 }), + requirement: json().notNull(), + requirementType: varchar('requirement_type', { length: 50 }).notNull(), + threshold: integer().notNull(), + rewardType: varchar('reward_type', { length: 50 }), + rewardValue: varchar('reward_value', { length: 50 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); diff --git a/src/discord-bot.ts b/src/discord-bot.ts index d7a5c81..f754db7 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -1,6 +1,6 @@ import { GatewayIntentBits } from 'discord.js'; -import { ExtendedClient } from './structures/ExtendedClient.js'; -import { loadConfig } from './util/configLoader.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; +import { loadConfig } from '@/util/configLoader.js'; async function startBot() { try { @@ -13,6 +13,7 @@ async function startBot() { GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.GuildModeration, ], }, diff --git a/src/events/channelEvents.ts b/src/events/channelEvents.ts index 523eb29..0978017 100644 --- a/src/events/channelEvents.ts +++ b/src/events/channelEvents.ts @@ -7,9 +7,9 @@ import { PermissionOverwrites, } from 'discord.js'; -import { ChannelLogAction } from '../util/logging/types.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { ChannelLogAction } from '@/util/logging/types.js'; +import { Event } from '@/types/EventTypes.js'; +import logAction from '@/util/logging/logAction.js'; function arePermissionsEqual( oldPerms: Map, diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 328b10f..5792eb4 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,40 +1,224 @@ -import { Events, Interaction } from 'discord.js'; +import { + Events, + Interaction, + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; -import { ExtendedClient } from '../structures/ExtendedClient.js'; -import { Event } from '../types/EventTypes.js'; +import { Event } from '@/types/EventTypes.js'; +import { approveFact, deleteFact } from '@/db/db.js'; +import * as GiveawayManager from '@/util/giveaways/giveawayManager.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; +import { safelyRespond, validateInteraction } from '@/util/helpers.js'; +import { processCommandAchievements } from '@/util/achievementManager.js'; export default { name: Events.InteractionCreate, execute: async (interaction: Interaction) => { - if (!interaction.isCommand()) return; - - 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 (!(await validateInteraction(interaction))) return; 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'], - }); + if (interaction.isCommand()) { + await handleCommand(interaction); + } else if (interaction.isButton()) { + await handleButton(interaction); + } else if (interaction.isModalSubmit()) { + await handleModal(interaction); + } else if (interaction.isStringSelectMenu()) { + await handleSelectMenu(interaction); } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + console.warn('Unhandled interaction type:', interaction); } + } catch (error) { + handleInteractionError(error, interaction); } }, } as Event; + +async function handleCommand(interaction: Interaction) { + if (!interaction.isCommand()) return; + + 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 (interaction.isChatInputCommand()) { + await command.execute(interaction); + await processCommandAchievements( + interaction.user.id, + command.data.name, + interaction.guild!, + ); + } else if ( + interaction.isUserContextMenuCommand() || + interaction.isMessageContextMenuCommand() + ) { + // @ts-expect-error + await command.execute(interaction); + await processCommandAchievements( + interaction.user.id, + command.data.name, + interaction.guild!, + ); + } +} + +async function handleButton(interaction: Interaction) { + if (!interaction.isButton()) return; + + const { customId } = interaction; + + try { + const giveawayHandlers: Record< + string, + (buttonInteraction: ButtonInteraction) => Promise + > = { + giveaway_start_builder: GiveawayManager.builder.startGiveawayBuilder, + giveaway_next: GiveawayManager.builder.nextBuilderStep, + giveaway_previous: GiveawayManager.builder.previousBuilderStep, + giveaway_set_prize: GiveawayManager.modals.showPrizeModal, + giveaway_set_duration: GiveawayManager.dropdowns.showDurationSelect, + giveaway_set_winners: GiveawayManager.dropdowns.showWinnerSelect, + giveaway_set_requirements: GiveawayManager.modals.showRequirementsModal, + giveaway_toggle_logic: GiveawayManager.toggleRequirementLogic, + giveaway_set_channel: + (interaction.guild?.channels.cache.size ?? 0) > 25 + ? GiveawayManager.modals.showChannelSelectModal + : GiveawayManager.dropdowns.showChannelSelect, + giveaway_bonus_entries: GiveawayManager.modals.showBonusEntriesModal, + giveaway_set_ping_role: + (interaction.guild?.roles.cache.size ?? 0) > 25 + ? GiveawayManager.modals.showPingRoleSelectModal + : GiveawayManager.dropdowns.showPingRoleSelect, + giveaway_publish: GiveawayManager.publishGiveaway, + enter_giveaway: GiveawayManager.handlers.handleGiveawayEntry, + }; + + if (giveawayHandlers[customId]) { + await giveawayHandlers[customId](interaction); + return; + } + + if ( + customId.startsWith('approve_fact_') || + customId.startsWith('reject_fact_') + ) { + await handleFactModeration(interaction, customId); + return; + } + + console.warn('Unhandled button interaction:', customId); + } catch (error) { + throw new Error(`Button interaction failed: ${error}`); + } +} + +async function handleFactModeration( + interaction: Interaction, + customId: string, +) { + if (!interaction.isButton()) return; + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to moderate facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace(/^(approve|reject)_fact_/, ''), 10); + const isApproval = customId.startsWith('approve_fact_'); + + if (isApproval) { + await approveFact(factId); + await interaction.update({ + content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`, + components: [], + }); + } else { + await deleteFact(factId); + await interaction.update({ + content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`, + components: [], + }); + } +} + +async function handleModal(interaction: Interaction) { + if (!interaction.isModalSubmit()) return; + + const { customId } = interaction; + const modalHandlers: Record< + string, + (modalInteraction: ModalSubmitInteraction) => Promise + > = { + giveaway_prize_modal: GiveawayManager.handlers.handlePrizeSubmit, + giveaway_custom_duration: + GiveawayManager.handlers.handleCustomDurationSubmit, + giveaway_requirements_modal: + GiveawayManager.handlers.handleRequirementsSubmit, + giveaway_bonus_entries_modal: + GiveawayManager.handlers.handleBonusEntriesSubmit, + giveaway_ping_role_id_modal: + GiveawayManager.handlers.handlePingRoleIdSubmit, + giveaway_channel_id_modal: GiveawayManager.handlers.handleChannelIdSubmit, + }; + + try { + if (modalHandlers[customId]) { + await modalHandlers[customId](interaction); + } else { + console.warn('Unhandled modal submission interaction:', customId); + } + } catch (error) { + throw new Error(`Modal submission failed: ${error}`); + } +} + +async function handleSelectMenu(interaction: Interaction) { + if (!interaction.isStringSelectMenu()) return; + + const { customId } = interaction; + const selectHandlers: Record< + string, + (selectInteraction: StringSelectMenuInteraction) => Promise + > = { + giveaway_duration_select: GiveawayManager.handlers.handleDurationSelect, + giveaway_winners_select: GiveawayManager.handlers.handleWinnerSelect, + giveaway_channel_select: GiveawayManager.handlers.handleChannelSelect, + giveaway_ping_role_select: GiveawayManager.handlers.handlePingRoleSelect, + }; + + try { + if (selectHandlers[customId]) { + await selectHandlers[customId](interaction); + } else { + console.warn('Unhandled string select menu interaction:', customId); + } + } catch (error) { + throw new Error(`Select menu interaction failed: ${error}`); + } +} + +function handleInteractionError(error: unknown, interaction: Interaction) { + console.error('Interaction error:', error); + + const isUnknownInteractionError = + (error as { code?: number })?.code === 10062 || + String(error).includes('Unknown interaction'); + + if (isUnknownInteractionError) { + console.warn( + 'Interaction expired before response could be sent (code 10062)', + ); + return; + } + + const errorMessage = 'An error occurred while processing your request.'; + safelyRespond(interaction, errorMessage).catch(console.error); +} diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index a6ddc80..655547d 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -1,10 +1,15 @@ -import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js'; +import { + Collection, + Events, + GuildMember, + PartialGuildMember, +} from 'discord.js'; -import { updateMember, setMembers } from '../db/db.js'; -import { generateMemberBanner } from '../util/helpers.js'; -import { loadConfig } from '../util/configLoader.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { updateMember, setMembers } from '@/db/db.js'; +import { generateMemberBanner } from '@/util/helpers.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { Event } from '@/types/EventTypes.js'; +import logAction from '@/util/logging/logAction.js'; export const memberJoin: Event = { name: Events.GuildMemberAdd, @@ -19,12 +24,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 8bd0f33..908de53 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -1,7 +1,18 @@ import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.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'; +import { + checkAndAssignLevelRoles, + processMessage, +} from '@/util/levelingSystem.js'; +import { processLevelUpAchievements } from '@/util/achievementManager.js'; export const messageDelete: Event = { name: Events.MessageDelete, @@ -62,4 +73,93 @@ export const messageUpdate: Event = { }, }; -export default [messageDelete, messageUpdate]; +export const messageCreate: Event = { + name: Events.MessageCreate, + execute: async (message: Message) => { + try { + 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?.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, + ); + + await processLevelUpAchievements( + message.author.id, + levelResult.newLevel, + message.guild, + ); + + if (assignedRole) { + await advancementsChannel.send( + `<@${message.author.id}> You've earned the <@&${assignedRole}> role!`, + ); + } + } + + 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/events/reactionEvents.ts b/src/events/reactionEvents.ts new file mode 100644 index 0000000..0599409 --- /dev/null +++ b/src/events/reactionEvents.ts @@ -0,0 +1,52 @@ +import { + Events, + MessageReaction, + PartialMessageReaction, + User, + PartialUser, +} from 'discord.js'; + +import { Event } from '@/types/EventTypes.js'; +import { + decrementUserReactionCount, + incrementUserReactionCount, +} from '@/db/db.js'; +import { processReactionAchievements } from '@/util/achievementManager.js'; + +export const reactionAdd: Event = { + name: Events.MessageReactionAdd, + execute: async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot || !reaction.message.guild) return; + + await incrementUserReactionCount(user.id); + + await processReactionAchievements(user.id, reaction.message.guild); + } catch (error) { + console.error('Error handling reaction add:', error); + } + }, +}; + +export const reactionRemove: Event = { + name: Events.MessageReactionRemove, + execute: async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot || !reaction.message.guild) return; + + await decrementUserReactionCount(user.id); + + await processReactionAchievements(user.id, reaction.message.guild, true); + } catch (error) { + console.error('Error handling reaction remove:', error); + } + }, +}; + +export default [reactionAdd, reactionRemove]; diff --git a/src/events/ready.ts b/src/events/ready.ts index 2430295..3f93c26 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,15 +1,29 @@ 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 { 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 { scheduleGiveaways } from '@/util/giveaways/giveawayManager.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 { + const config = loadConfig(); + setRedisDiscordClient(client); + setDbDiscordClient(client); + + await ensureDbInitialized(); + await ensureRedisConnection(); + const guild = client.guilds.cache.find( (guilds) => guilds.id === config.guildId, ); @@ -21,10 +35,13 @@ export default { const members = await guild.members.fetch(); const nonBotMembers = members.filter((m) => !m.user.bot); await setMembers(nonBotMembers); - } catch (error) { - console.error('Failed to initialize members in database:', error); - } - console.log(`Ready! Logged in as ${client.user?.tag}`); + await scheduleFactOfTheDay(client); + await scheduleGiveaways(client); + + console.log(`Ready! Logged in as ${client.user?.tag}`); + } catch (error) { + console.error('Failed to initialize the bot:', error); + } }, } as Event; diff --git a/src/events/roleEvents.ts b/src/events/roleEvents.ts index 8fc06f6..a45b471 100644 --- a/src/events/roleEvents.ts +++ b/src/events/roleEvents.ts @@ -1,7 +1,7 @@ import { AuditLogEvent, Events, Role } from 'discord.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { Event } from '@/types/EventTypes.js'; +import logAction from '@/util/logging/logAction.js'; const convertRoleProperties = (role: Role) => ({ name: role.name, diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts index 4dfba26..6cf3d9d 100644 --- a/src/structures/ExtendedClient.ts +++ b/src/structures/ExtendedClient.ts @@ -1,9 +1,12 @@ import { Client, ClientOptions, Collection } from 'discord.js'; -import { Command } from '../types/CommandTypes.js'; -import { Config } from '../types/ConfigTypes.js'; -import { deployCommands } from '../util/deployCommand.js'; -import { registerEvents } from '../util/eventLoader.js'; +import { Command } from '@/types/CommandTypes.js'; +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 30d175c..9e229f1 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -1,15 +1,30 @@ import { - CommandInteraction, + ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, + SlashCommandSubcommandsOnlyBuilder, } from 'discord.js'; +/** + * Command interface for normal commands + */ export interface Command { data: Omit; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; } +/** + * Command interface for options commands + */ export interface OptionsCommand { data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; +} + +/** + * Command interface for subcommand commands + */ +export interface SubcommandCommand { + data: SlashCommandSubcommandsOnlyBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; } diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index e92ae94..b38b126 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -1,14 +1,43 @@ +/** + * 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; + counting: string; + factOfTheDay: string; + factApproval: string; + advancements: string; }; roles: { joinRoles: string[]; + levelRoles: { + level: number; + roleId: string; + }[]; + staffRoles: { + name: string; + roleId: string; + }[]; + 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/achievementCardGenerator.ts b/src/util/achievementCardGenerator.ts new file mode 100644 index 0000000..955fc18 --- /dev/null +++ b/src/util/achievementCardGenerator.ts @@ -0,0 +1,115 @@ +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; +import { AttachmentBuilder } from 'discord.js'; +import path from 'path'; + +import * as schema from '@/db/schema.js'; +import { drawMultilineText, roundRect } from './helpers.js'; + +const __dirname = path.resolve(); + +/** + * Generates an achievement card for a user + * TODO: Make this look better + * @param achievement - The achievement to generate a card for + * @returns - The generated card as an AttachmentBuilder + */ +export async function generateAchievementCard( + achievement: schema.achievementDefinitionsTableTypes, +): Promise { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const width = 600; + const height = 180; + const canvas = Canvas.createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + const gradient = ctx.createLinearGradient(0, 0, width, 0); + gradient.addColorStop(0, '#5865F2'); + gradient.addColorStop(1, '#EB459E'); + ctx.fillStyle = gradient; + roundRect({ ctx, x: 0, y: 0, width, height, radius: 16, fill: true }); + + ctx.lineWidth = 4; + ctx.strokeStyle = '#FFFFFF'; + roundRect({ + ctx, + x: 2, + y: 2, + width: width - 4, + height: height - 4, + radius: 16, + fill: false, + }); + + const padding = 40; + const iconSize = 72; + const iconX = padding; + const iconY = height / 2 - iconSize / 2; + + try { + const iconImage = await Canvas.loadImage( + achievement.imageUrl || + path.join(__dirname, 'assets', 'images', 'trophy.png'), + ); + + ctx.save(); + ctx.beginPath(); + ctx.arc( + iconX + iconSize / 2, + iconY + iconSize / 2, + iconSize / 2, + 0, + Math.PI * 2, + ); + ctx.clip(); + ctx.drawImage(iconImage, iconX, iconY, iconSize, iconSize); + ctx.restore(); + + ctx.beginPath(); + ctx.arc( + iconX + iconSize / 2, + iconY + iconSize / 2, + iconSize / 2 + 4, + 0, + Math.PI * 2, + ); + ctx.lineWidth = 3; + ctx.strokeStyle = '#FFFFFF'; + ctx.stroke(); + } catch (e) { + console.error('Error loading icon:', e); + } + + const textX = iconX + iconSize + 24; + const titleY = 60; + const nameY = titleY + 35; + const descY = nameY + 34; + + ctx.fillStyle = '#FFFFFF'; + + ctx.font = '22px "Manrope Bold"'; + ctx.fillText('Achievement Unlocked!', textX, titleY); + + ctx.font = '32px "Manrope Bold"'; + ctx.fillText(achievement.name, textX, nameY); + + ctx.font = '20px "Manrope"'; + drawMultilineText( + ctx, + achievement.description, + textX, + descY, + width - textX - 32, + 24, + ); + + const buffer = canvas.toBuffer('image/png'); + return new AttachmentBuilder(buffer, { name: 'achievement.png' }); +} diff --git a/src/util/achievementManager.ts b/src/util/achievementManager.ts new file mode 100644 index 0000000..adb78d9 --- /dev/null +++ b/src/util/achievementManager.ts @@ -0,0 +1,303 @@ +import { + Message, + Client, + EmbedBuilder, + GuildMember, + TextChannel, + Guild, +} from 'discord.js'; + +import { + addXpToUser, + awardAchievement, + getAllAchievements, + getUserAchievements, + getUserLevel, + getUserReactionCount, + updateAchievementProgress, +} from '@/db/db.js'; +import * as schema from '@/db/schema.js'; +import { loadConfig } from './configLoader.js'; +import { generateAchievementCard } from './achievementCardGenerator.js'; + +/** + * Check and process achievements for a user based on a message + * @param message - The message that triggered the check + */ +export async function processMessageAchievements( + message: Message, +): Promise { + if (message.author.bot) return; + + const userData = await getUserLevel(message.author.id); + const allAchievements = await getAllAchievements(); + + const messageAchievements = allAchievements.filter( + (a) => a.requirementType === 'message_count', + ); + + for (const achievement of messageAchievements) { + const progress = Math.min( + 100, + (userData.messagesSent / achievement.threshold) * 100, + ); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(message.author.id); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement( + message.author.id, + achievement.id, + ); + if (awarded) { + await announceAchievement( + message.guild!, + message.author.id, + achievement, + ); + } + } + } else { + await updateAchievementProgress( + message.author.id, + achievement.id, + progress, + ); + } + } + + const levelAchievements = allAchievements.filter( + (a) => a.requirementType === 'level', + ); + + for (const achievement of levelAchievements) { + const progress = Math.min( + 100, + (userData.level / achievement.threshold) * 100, + ); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(message.author.id); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement( + message.author.id, + achievement.id, + ); + if (awarded) { + await announceAchievement( + message.guild!, + message.author.id, + achievement, + ); + } + } + } else { + await updateAchievementProgress( + message.author.id, + achievement.id, + progress, + ); + } + } +} + +/** + * Check achievements for level-ups + * @param memberId - Member ID who leveled up + * @param newLevel - New level value + * @guild - Guild instance + */ +export async function processLevelUpAchievements( + memberId: string, + newLevel: number, + guild: Guild, +): Promise { + const allAchievements = await getAllAchievements(); + + const levelAchievements = allAchievements.filter( + (a) => a.requirementType === 'level', + ); + + for (const achievement of levelAchievements) { + const progress = Math.min(100, (newLevel / achievement.threshold) * 100); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(memberId); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(memberId, achievement.id); + if (awarded) { + await announceAchievement(guild, memberId, achievement); + } + } + } else { + await updateAchievementProgress(memberId, achievement.id, progress); + } + } +} + +/** + * Process achievements for command usage + * @param userId - User ID who used the command + * @param commandName - Name of the command + * @param client - Guild instance + */ +export async function processCommandAchievements( + userId: string, + commandName: string, + guild: Guild, +): Promise { + const allAchievements = await getAllAchievements(); + + const commandAchievements = allAchievements.filter( + (a) => + a.requirementType === 'command_usage' && + a.requirement && + (a.requirement as any).command === commandName, + ); + + for (const achievement of commandAchievements) { + const userAchievements = await getUserAchievements(userId); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded) { + await announceAchievement(guild, userId, achievement); + } + } + } +} + +/** + * Process achievements for reaction events (add or remove) + * @param userId - User ID who added/removed the reaction + * @param guild - Guild instance + * @param isRemoval - Whether this is a reaction removal (true) or addition (false) + */ +export async function processReactionAchievements( + userId: string, + guild: Guild, + isRemoval: boolean = false, +): Promise { + try { + const member = await guild.members.fetch(userId); + if (member.user.bot) return; + + const allAchievements = await getAllAchievements(); + + const reactionAchievements = allAchievements.filter( + (a) => a.requirementType === 'reactions', + ); + + if (reactionAchievements.length === 0) return; + + const reactionCount = await getUserReactionCount(userId); + + for (const achievement of reactionAchievements) { + const progress = Math.max( + 0, + Math.min(100, (reactionCount / achievement.threshold) * 100), + ); + + if (progress >= 100 && !isRemoval) { + const userAchievements = await getUserAchievements(userId); + const existingAchievement = userAchievements.find( + (a) => + a.achievementId === achievement.id && + a.earnedAt !== null && + a.earnedAt !== undefined && + new Date(a.earnedAt).getTime() > 0, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded) { + await announceAchievement(guild, userId, achievement); + } + } + } + + await updateAchievementProgress(userId, achievement.id, progress); + } + } catch (error) { + console.error('Error processing reaction achievements:', error); + } +} + +/** + * Announce a newly earned achievement + * @param guild - Guild instance + * @param userId - ID of the user who earned the achievement + * @param achievement - Achievement definition + */ +export async function announceAchievement( + guild: Guild, + userId: string, + achievement: schema.achievementDefinitionsTableTypes, +): Promise { + try { + const config = loadConfig(); + + if (!guild) { + console.error(`Guild ${guild} not found`); + return; + } + + const member = await guild.members.fetch(userId); + if (!member) { + console.warn(`Member ${userId} not found in guild`); + return; + } + + const achievementCard = await generateAchievementCard(achievement); + + const embed = new EmbedBuilder() + .setColor(0xffd700) + .setDescription( + `**${member.user.username}** just unlocked the achievement: **${achievement.name}**! 🎉`, + ) + .setImage('attachment://achievement.png') + .setTimestamp(); + + const advChannel = guild.channels.cache.get(config.channels.advancements); + if (advChannel?.isTextBased()) { + await (advChannel as TextChannel).send({ + content: `Congratulations <@${userId}>!`, + embeds: [embed], + files: [achievementCard], + }); + } + + if (achievement.rewardType === 'xp' && achievement.rewardValue) { + const xpAmount = parseInt(achievement.rewardValue); + if (!isNaN(xpAmount)) { + await addXpToUser(userId, xpAmount); + } + } else if (achievement.rewardType === 'role' && achievement.rewardValue) { + try { + await member.roles.add(achievement.rewardValue); + } catch (err) { + console.error( + `Failed to add role ${achievement.rewardValue} to user ${userId}`, + err, + ); + } + } + } catch (error) { + console.error('Error announcing achievement:', error); + } +} diff --git a/src/util/configLoader.ts b/src/util/configLoader.ts index 497e5a0..9c61352 100644 --- a/src/util/configLoader.ts +++ b/src/util/configLoader.ts @@ -1,7 +1,12 @@ -import { Config } from '../types/ConfigTypes.js'; import fs from 'node:fs'; import path from 'node:path'; +import { Config } from '@/types/ConfigTypes.js'; + +/** + * 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 new file mode 100644 index 0000000..b1b37d9 --- /dev/null +++ b/src/util/countingManager.ts @@ -0,0 +1,191 @@ +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: '🎉', +}; + +/** + * 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; + + const initialData: CountingData = { + currentCount: 0, + lastUserId: null, + highestCount: 0, + totalCorrect: 0, + }; + + await setJson('counting', initialData); + return initialData; +} + +/** + * Gets the current counting data + * @returns - The current counting data + */ +export async function getCountingData(): Promise { + const data = await getJson('counting'); + if (!data) { + return initializeCountingData(); + } + 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 { + const currentData = await getCountingData(); + const updatedData = { ...currentData, ...data }; + 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, + lastUserId: null, + }); + 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; + 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' }; + } +} + +/** + * 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, +): Promise { + try { + await message.react(MILESTONE_REACTIONS[milestoneType]); + + if (milestoneType === 'multiples100') { + await message.react('💯'); + } + } catch (error) { + console.error('Error adding counting reactions:', error); + } +} + +/** + * 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.'); + } + + await updateCountingData({ + currentCount: count, + lastUserId: null, + }); +} diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 9ce4580..c7e4486 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,6 +1,7 @@ -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(); @@ -11,6 +12,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 +36,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 +76,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..bd7a721 100644 --- a/src/util/eventLoader.ts +++ b/src/util/eventLoader.ts @@ -1,12 +1,15 @@ import { Client } from 'discord.js'; import { readdirSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -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 new file mode 100644 index 0000000..56aa654 --- /dev/null +++ b/src/util/factManager.ts @@ -0,0 +1,89 @@ +import { EmbedBuilder, Client } from 'discord.js'; + +import { getRandomUnusedFact, markFactAsUsed } from '@/db/db.js'; +import { loadConfig } from './configLoader.js'; + +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); + tomorrow.setHours(0, 0, 0, 0); + + const timeUntilMidnight = tomorrow.getTime() - now.getTime(); + + setTimeout(() => { + postFactOfTheDay(client); + isFactScheduled = false; + 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); + isFactScheduled = false; + setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000); + } +} + +/** + * 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); + + 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/giveaways/builder.ts b/src/util/giveaways/builder.ts new file mode 100644 index 0000000..8be6a73 --- /dev/null +++ b/src/util/giveaways/builder.ts @@ -0,0 +1,375 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChatInputCommandInteraction, + EmbedBuilder, +} from 'discord.js'; + +import { GiveawaySession } from './types.js'; +import { DEFAULT_REQUIRE_ALL, DEFAULT_WINNER_COUNT } from './constants.js'; +import { getSession, saveSession } from './utils.js'; + +/** + * Handles the start of the giveaway builder. + * @param interaction The interaction object from the command or button click. + */ +export async function startGiveawayBuilder( + interaction: ChatInputCommandInteraction | ButtonInteraction, +): Promise { + await interaction.deferReply({ flags: ['Ephemeral'] }); + + const session: GiveawaySession = { + step: 1, + winnerCount: DEFAULT_WINNER_COUNT, + requirements: { + requireAll: DEFAULT_REQUIRE_ALL, + }, + }; + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the display of the current step in the giveaway builder. + * @param interaction The interaction object from the command or button click. + * @param session The current giveaway session. + */ +export async function showBuilderStep( + interaction: any, + session: GiveawaySession, +): Promise { + if (!interaction.isCommand() && interaction.responded) { + return; + } + + try { + let embed: EmbedBuilder; + const components: ActionRowBuilder[] = []; + + switch (session.step) { + case 1: + embed = createStep1Embed(session); + components.push(createStep1Buttons(session)); + break; + case 2: + embed = createStep2Embed(session); + components.push(...createStep2Buttons(session)); + break; + case 3: + embed = createStep3Embed(session); + components.push(...createStep3Buttons(session)); + break; + case 4: + embed = createStep4Embed(session); + components.push(...createStep4Buttons()); + break; + case 5: + embed = createStep5Embed(session); + components.push(...createStep5Buttons()); + break; + default: + embed = new EmbedBuilder() + .setTitle('🎉 Giveaway Creation') + .setDescription('Setting up your giveaway...') + .setColor(0x3498db); + } + + if (interaction.replied || interaction.deferred) { + await interaction.editReply({ embeds: [embed], components }); + } else { + await interaction.update({ embeds: [embed], components }); + } + } catch (error) { + console.error('Error in showBuilderStep:', error); + if (!interaction.replied) { + try { + await interaction.reply({ + content: 'There was an error updating the giveaway builder.', + flags: ['Ephemeral'], + }); + } catch (replyError) { + console.error('Failed to send error reply:', replyError); + } + } + } +} + +/** + * Handles the next step in the giveaway builder. + * @param interaction The interaction object from the button click. + */ +export async function nextBuilderStep( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (session.step === 1) { + if (!session.prize || !session.endTime) { + await interaction.reply({ + content: 'Please set both prize and duration before continuing.', + flags: ['Ephemeral'], + }); + return; + } + + if (!(session.endTime instanceof Date)) { + await interaction.reply({ + content: 'Invalid duration setting. Please set the duration again.', + flags: ['Ephemeral'], + }); + return; + } + } + + session.step = Math.min(session.step + 1, 5); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the previous step in the giveaway builder. + * @param interaction The interaction object from the button click. + */ +export async function previousBuilderStep( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.step = Math.max(session.step - 1, 1); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +function createStep1Embed(session: GiveawaySession): EmbedBuilder { + const endTimeValue = + session.endTime instanceof Date + ? `${session.duration} (ends )` + : 'Not set'; + + return new EmbedBuilder() + .setTitle(' Giveaway Creation - Step 1/5') + .setDescription('Set the basic details for your giveaway.') + .setColor(0x3498db) + .addFields([ + { name: 'Prize', value: session.prize || 'Not set', inline: true }, + { name: 'Duration', value: endTimeValue, inline: true }, + { name: 'Winners', value: session.winnerCount.toString(), inline: true }, + ]); +} + +function createStep1Buttons( + session: GiveawaySession, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_prize') + .setLabel('Set Prize') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_set_duration') + .setLabel('Set Duration') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_set_winners') + .setLabel('Set Winners') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success) + .setDisabled(!session.prize || !session.endTime), + ); +} + +function createStep2Embed(session: GiveawaySession): EmbedBuilder { + const requirementsList = []; + if (session.requirements?.level) { + requirementsList.push(`• Level ${session.requirements.level}+`); + } + if (session.requirements?.roleId) { + requirementsList.push(`• Role <@&${session.requirements.roleId}>`); + } + if (session.requirements?.messageCount) { + requirementsList.push(`• ${session.requirements.messageCount}+ messages`); + } + + const requirementsText = requirementsList.length + ? `${session.requirements.requireAll ? 'ALL requirements must be met' : 'ANY ONE requirement must be met'}\n${requirementsList.join('\n')}` + : 'No requirements set'; + + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 2/5') + .setDescription('Set entry requirements for your giveaway (optional).') + .setColor(0x3498db) + .addFields([ + { name: 'Prize', value: session.prize || 'Not set' }, + { name: 'Requirements', value: requirementsText }, + ]); +} + +function createStep2Buttons( + session: GiveawaySession, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_requirements') + .setLabel('Set Requirements') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_toggle_logic') + .setLabel( + session.requirements.requireAll ? 'Require ANY' : 'Require ALL', + ) + .setStyle(ButtonStyle.Secondary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep3Embed(session: GiveawaySession): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 3/5') + .setDescription('Select Giveaway Channel (optional).') + .setColor(0x3498db) + .addFields([ + { + name: 'Channel', + value: session.channelId + ? `<#${session.channelId}>` + : 'Current Channel', + }, + ]); + + return embed; +} + +function createStep3Buttons( + session: GiveawaySession, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_channel') + .setLabel(session.channelId ? 'Change Channel' : 'Set Channel') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep4Embed(session: GiveawaySession): EmbedBuilder { + const bonusEntries = session.bonusEntries || {}; + + const rolesText = + bonusEntries.roles?.map((r) => `<@&${r.id}>: +${r.entries}`).join('\n') || + 'None'; + const levelsText = + bonusEntries.levels + ?.map((l) => `Level ${l.threshold}+: +${l.entries}`) + .join('\n') || 'None'; + const messagesText = + bonusEntries.messages + ?.map((m) => `${m.threshold}+ messages: +${m.entries}`) + .join('\n') || 'None'; + + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 4/5') + .setDescription('Configure bonus entries for your giveaway.') + .setColor(0x3498db) + .addFields([ + { name: 'Role Bonuses', value: rolesText, inline: true }, + { name: 'Level Bonuses', value: levelsText, inline: true }, + { name: 'Message Bonuses', value: messagesText, inline: true }, + ]); +} + +function createStep4Buttons(): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_bonus_entries') + .setLabel('Set Bonus Entries') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep5Embed(session: GiveawaySession): EmbedBuilder { + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 5/5') + .setDescription('Finalize your giveaway settings.') + .setColor(0x3498db) + .addFields([ + { + name: 'Role to Ping', + value: session.pingRoleId ? `<@&${session.pingRoleId}>` : 'None', + }, + ]); +} + +function createStep5Buttons(): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_ping_role') + .setLabel('Set Ping Role') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_publish') + .setLabel('Create Giveaway') + .setStyle(ButtonStyle.Success), + ), + ]; +} diff --git a/src/util/giveaways/constants.ts b/src/util/giveaways/constants.ts new file mode 100644 index 0000000..bbb521c --- /dev/null +++ b/src/util/giveaways/constants.ts @@ -0,0 +1,4 @@ +export const SESSION_TIMEOUT = 1800; +export const SESSION_PREFIX = 'giveaway:session:'; +export const DEFAULT_WINNER_COUNT = 1; +export const DEFAULT_REQUIRE_ALL = true; diff --git a/src/util/giveaways/dropdowns.ts b/src/util/giveaways/dropdowns.ts new file mode 100644 index 0000000..d01cadb --- /dev/null +++ b/src/util/giveaways/dropdowns.ts @@ -0,0 +1,149 @@ +import { + ActionRowBuilder, + ButtonInteraction, + StringSelectMenuBuilder, +} from 'discord.js'; + +/** + * Show a select menu for pinging a role. + * @param interaction The button interaction that triggered this function. + */ +export async function showPingRoleSelect( + interaction: ButtonInteraction, +): Promise { + const roles = interaction.guild?.roles.cache + .filter((role) => role.id !== interaction.guild?.id) + .sort((a, b) => a.position - b.position) + .map((role) => ({ + label: role.name.substring(0, 25), + value: role.id, + description: `@${role.name}`, + })); + + if (!roles?.length) { + await interaction.reply({ + content: 'No roles found in this server.', + flags: ['Ephemeral'], + }); + return; + } + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_ping_role_select') + .setPlaceholder('Select a role to ping (optional)') + .addOptions([...roles.slice(0, 25)]), + ); + + await interaction.reply({ + content: 'Select a role to ping when the giveaway starts:', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing a duration for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showDurationSelect( + interaction: ButtonInteraction, +): Promise { + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_duration_select') + .setPlaceholder('Select duration') + .addOptions([ + { label: '1 hour', value: '1h', description: 'End giveaway in 1 hour' }, + { + label: '6 hours', + value: '6h', + description: 'End giveaway in 6 hours', + }, + { + label: '12 hours', + value: '12h', + description: 'End giveaway in 12 hours', + }, + { label: '1 day', value: '1d', description: 'End giveaway in 1 day' }, + { label: '3 days', value: '3d', description: 'End giveaway in 3 days' }, + { label: '7 days', value: '7d', description: 'End giveaway in 7 days' }, + { + label: 'Custom', + value: 'custom', + description: 'Set a custom duration', + }, + ]), + ); + + await interaction.reply({ + content: 'Select the duration for your giveaway:', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing the number of winners for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showWinnerSelect( + interaction: ButtonInteraction, +): Promise { + const options = [1, 2, 3, 5, 10].map((num) => ({ + label: `${num} winner${num > 1 ? 's' : ''}`, + value: num.toString(), + description: `Select ${num} winner${num > 1 ? 's' : ''} for the giveaway`, + })); + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_winners_select') + .setPlaceholder('Select number of winners') + .addOptions(options), + ); + + await interaction.reply({ + content: 'How many winners should this giveaway have?', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing a channel for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showChannelSelect( + interaction: ButtonInteraction, +): Promise { + const channels = interaction.guild?.channels.cache + .filter((channel) => channel.isTextBased()) + .map((channel) => ({ + label: channel.name.substring(0, 25), + value: channel.id, + description: `#${channel.name}`, + })) + .slice(0, 25); + + if (!channels?.length) { + await interaction.reply({ + content: 'No suitable text channels found in this server.', + flags: ['Ephemeral'], + }); + return; + } + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_channel_select') + .setPlaceholder('Select a channel') + .addOptions(channels), + ); + + await interaction.reply({ + content: 'Select the channel to host the giveaway in:', + components: [row], + flags: ['Ephemeral'], + }); +} diff --git a/src/util/giveaways/giveawayManager.ts b/src/util/giveaways/giveawayManager.ts new file mode 100644 index 0000000..17ab6b4 --- /dev/null +++ b/src/util/giveaways/giveawayManager.ts @@ -0,0 +1,362 @@ +import { + ButtonInteraction, + Client, + EmbedBuilder, + TextChannel, +} from 'discord.js'; + +import { createGiveaway, endGiveaway, getActiveGiveaways } from '@/db/db.js'; +import { GiveawayEmbedParams } from './types.js'; +import { + createGiveawayButtons, + deleteSession, + formatWinnerMentions, + getSession, + toggleRequirementLogic, + selectGiveawayWinners, +} from './utils.js'; +import { loadConfig } from '../configLoader.js'; +import * as builder from './builder.js'; +import * as dropdowns from './dropdowns.js'; +import * as handlers from './handlers.js'; +import * as modals from './modals.js'; + +/** + * Creates a Discord embed for a giveaway based on the provided parameters. + * Handles both active and ended giveaway states. + * + * @param params - The parameters needed to build the giveaway embed. + * @returns A configured EmbedBuilder instance for the giveaway. + */ +export function createGiveawayEmbed(params: GiveawayEmbedParams): EmbedBuilder { + const { + id, + prize, + endTime, + winnerCount = 1, + hostId, + participantCount = 0, + winnersIds, + isEnded = false, + footerText, + requiredLevel, + requiredRoleId, + requiredMessageCount, + requireAllCriteria = true, + bonusEntries, + } = params; + + const embed = new EmbedBuilder() + .setTitle(isEnded ? '🎉 Giveaway Ended 🎉' : '🎉 Giveaway 🎉') + .setDescription( + `**Prize**: ${prize}${id ? `\n**Giveaway ID**: ${id}` : ''}`, + ) + .setColor(isEnded ? 0xff0000 : 0x00ff00); + + if (isEnded) { + embed.addFields( + { name: 'Winner(s)', value: formatWinnerMentions(winnersIds) }, + { name: 'Hosted by', value: `<@${hostId}>` }, + ); + embed.setFooter({ text: footerText || 'Ended at' }); + embed.setTimestamp(); + } else { + embed.addFields( + { name: 'Winner(s)', value: winnerCount.toString(), inline: true }, + { name: 'Entries', value: participantCount.toString(), inline: true }, + { + name: 'Ends at', + value: endTime + ? `` + : 'Soon', + inline: true, + }, + { name: 'Hosted by', value: `<@${hostId}>` }, + ); + + const requirements: string[] = []; + if (requiredLevel) requirements.push(`• Level ${requiredLevel}+ required`); + if (requiredRoleId) { + requirements.push(`• <@&${requiredRoleId}> role required`); + } + if (requiredMessageCount) { + requirements.push(`• ${requiredMessageCount}+ messages required`); + } + + if (requirements.length) { + embed.addFields({ + name: `📋 Entry Requirements (${requireAllCriteria ? 'ALL required' : 'ANY one required'})`, + value: requirements.join('\n'), + }); + } + + const bonusDetails: string[] = []; + bonusEntries?.roles?.forEach((r) => + bonusDetails.push(`• <@&${r.id}>: +${r.entries} entries`), + ); + bonusEntries?.levels?.forEach((l) => + bonusDetails.push(`• Level ${l.threshold}+: +${l.entries} entries`), + ); + bonusEntries?.messages?.forEach((m) => + bonusDetails.push(`• ${m.threshold}+ messages: +${m.entries} entries`), + ); + + if (bonusDetails.length) { + embed.addFields({ + name: '✨ Bonus Entries', + value: bonusDetails.join('\n'), + }); + } + + embed.setFooter({ text: 'End time' }); + if (endTime) embed.setTimestamp(endTime); + } + + return embed; +} + +/** + * Processes a giveaway that has ended. Fetches the ended giveaway data, + * updates the original message, announces the winners (if any), and handles errors. + * + * @param client - The Discord Client instance. + * @param messageId - The message ID of the giveaway to process. + */ +export async function processEndedGiveaway( + client: Client, + messageId: string, +): Promise { + try { + const endedGiveaway = await endGiveaway(messageId); + if (!endedGiveaway) { + console.warn( + `Attempted to process non-existent or already ended giveaway: ${messageId}`, + ); + return; + } + + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + if (!guild) { + console.error(`Guild ${config.guildId} not found.`); + return; + } + + const channel = guild.channels.cache.get(endedGiveaway.channelId); + if (!channel?.isTextBased()) { + console.warn( + `Giveaway channel ${endedGiveaway.channelId} not found or not text-based.`, + ); + return; + } + + try { + const giveawayMessage = await channel.messages.fetch(messageId); + if (!giveawayMessage) { + console.warn( + `Giveaway message ${messageId} not found in channel ${channel.id}.`, + ); + return; + } + + await giveawayMessage.edit({ + embeds: [ + createGiveawayEmbed({ + id: endedGiveaway.id, + prize: endedGiveaway.prize, + hostId: endedGiveaway.hostId, + winnersIds: endedGiveaway.winnersIds ?? [], + isEnded: true, + }), + ], + components: [], + }); + + if (endedGiveaway.winnersIds?.length) { + const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds); + await channel.send({ + content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`, + allowedMentions: { users: endedGiveaway.winnersIds }, + }); + } else { + await channel.send( + `No one entered the giveaway for **${endedGiveaway.prize}**!`, + ); + } + } catch (error) { + console.error(`Error updating giveaway message ${messageId}:`, error); + } + } catch (error) { + console.error(`Error processing ended giveaway ${messageId}:`, error); + } +} + +/** + * Schedules all active giveaways fetched from the database to end at their designated time. + * If a giveaway's end time is already past, it processes it immediately. + * This function should be called on bot startup. + * + * @param client - The Discord Client instance. + */ +export async function scheduleGiveaways(client: Client): Promise { + try { + const activeGiveaways = await getActiveGiveaways(); + console.log( + `Found ${activeGiveaways.length} active giveaways to schedule.`, + ); + + for (const giveaway of activeGiveaways) { + const endTime = giveaway.endAt.getTime(); + const now = Date.now(); + const timeLeft = endTime - now; + + if (timeLeft <= 0) { + console.log( + `Giveaway ID ${giveaway.id} end time has passed. Processing now.`, + ); + await processEndedGiveaway(client, giveaway.messageId); + } else { + console.log( + `Scheduling giveaway ID ${giveaway.id} to end in ${Math.floor(timeLeft / 1000)} seconds.`, + ); + setTimeout(() => { + processEndedGiveaway(client, giveaway.messageId); + }, timeLeft); + } + } + console.log('Finished scheduling active giveaways.'); + } catch (error) { + console.error('Error scheduling giveaways:', error); + } +} + +/** + * Publishes a giveaway based on the session data associated with the interacting user. + * Sends the giveaway message to the designated channel, saves it to the database, + * schedules its end, and cleans up the user's session. + * + * @param interaction - The button interaction triggering the publish action. + */ +export async function publishGiveaway( + interaction: ButtonInteraction, +): Promise { + await interaction.deferUpdate(); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.followUp({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (!session.prize || !session.endTime) { + await interaction.followUp({ + content: 'Missing required information. Please complete all steps.', + flags: ['Ephemeral'], + }); + return; + } + + try { + const channelId = session.channelId || interaction.channelId; + const channel = await interaction.guild?.channels.fetch(channelId); + if (!channel?.isTextBased()) { + await interaction.followUp({ + content: 'Invalid channel selected.', + flags: ['Ephemeral'], + }); + return; + } + + const pingContent = session.pingRoleId ? `<@&${session.pingRoleId}>` : ''; + + const initialEmbed = createGiveawayEmbed({ + prize: session.prize, + endTime: session.endTime, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + participantCount: 0, + requiredLevel: session.requirements?.level, + requiredRoleId: session.requirements?.roleId, + requiredMessageCount: session.requirements?.messageCount, + requireAllCriteria: session.requirements.requireAll, + bonusEntries: session.bonusEntries, + }); + + const giveawayMessage = await (channel as TextChannel).send({ + content: pingContent, + embeds: [initialEmbed], + components: [createGiveawayButtons()], + allowedMentions: { + roles: session.pingRoleId ? [session.pingRoleId] : [], + }, + }); + + const createdGiveaway = await createGiveaway({ + channelId: channel.id, + messageId: giveawayMessage.id, + endAt: session.endTime, + prize: session.prize, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + requirements: { + level: session.requirements?.level, + roleId: session.requirements?.roleId, + messageCount: session.requirements?.messageCount, + requireAll: session.requirements.requireAll, + }, + bonuses: session.bonusEntries, + }); + + const updatedEmbed = createGiveawayEmbed({ + id: createdGiveaway.id, + prize: session.prize, + endTime: session.endTime, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + participantCount: 0, + requiredLevel: session.requirements?.level, + requiredRoleId: session.requirements?.roleId, + requiredMessageCount: session.requirements?.messageCount, + requireAllCriteria: session.requirements.requireAll, + bonusEntries: session.bonusEntries, + }); + + await giveawayMessage.edit({ + embeds: [updatedEmbed], + components: [createGiveawayButtons()], + }); + + const timeLeft = session.endTime.getTime() - Date.now(); + setTimeout(() => { + processEndedGiveaway(interaction.client, giveawayMessage.id); + }, timeLeft); + + await interaction.editReply({ + content: `✅ Giveaway created successfully in <#${channel.id}>!\nIt will end `, + components: [], + embeds: [], + }); + + await deleteSession(interaction.user.id); + } catch (error) { + console.error('Error publishing giveaway:', error); + await interaction.followUp({ + content: + 'An error occurred while creating the giveaway. Please try again.', + flags: ['Ephemeral'], + }); + } +} + +export { + builder, + dropdowns, + handlers, + modals, + toggleRequirementLogic, + formatWinnerMentions, + selectGiveawayWinners, +}; diff --git a/src/util/giveaways/handlers.ts b/src/util/giveaways/handlers.ts new file mode 100644 index 0000000..0e5c06a --- /dev/null +++ b/src/util/giveaways/handlers.ts @@ -0,0 +1,452 @@ +import { + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; + +import { addGiveawayParticipant, getGiveaway, getUserLevel } from '@/db/db.js'; +import { createGiveawayEmbed } from './giveawayManager.js'; +import { + checkUserRequirements, + createGiveawayButtons, + getSession, + parseRoleBonusEntries, + parseThresholdBonusEntries, + saveSession, +} from './utils.js'; +import { parseDuration } from '../helpers.js'; +import { showCustomDurationModal } from './modals.js'; +import { showBuilderStep } from './builder.js'; + +// ======================== +// Button Handlers +// ======================== + +/** + * Handles the entry for a giveaway. + * @param interaction - The interaction object from the button click + */ +export async function handleGiveawayEntry( + interaction: ButtonInteraction, +): Promise { + await interaction.deferUpdate(); + + try { + const messageId = interaction.message.id; + const giveaway = await getGiveaway(messageId); + + if (!giveaway || giveaway.status !== 'active') { + await interaction.followUp({ + content: 'This giveaway has ended or does not exist.', + flags: ['Ephemeral'], + }); + return; + } + + const [requirementsFailed, requirementsMet] = await checkUserRequirements( + interaction, + giveaway, + ); + const requireAll = giveaway.requireAllCriteria ?? true; + const totalRequirements = [ + giveaway.requiredLevel, + giveaway.requiredRoleId, + giveaway.requiredMessageCount, + ].filter(Boolean).length; + + if ( + (requireAll && requirementsFailed.length) || + (!requireAll && totalRequirements > 0 && !requirementsMet.length) + ) { + const reqType = requireAll ? 'ALL' : 'ANY ONE'; + await interaction.followUp({ + content: `You don't meet the requirements to enter this giveaway (${reqType} required):\n${requirementsFailed.join('\n')}`, + flags: ['Ephemeral'], + }); + return; + } + + const userData = await getUserLevel(interaction.user.id); + const member = await interaction.guild?.members.fetch(interaction.user.id); + let totalEntries = 1; + + giveaway.bonusEntries?.roles?.forEach((bonus) => { + if (member?.roles.cache.has(bonus.id)) { + totalEntries += bonus.entries; + } + }); + + giveaway.bonusEntries?.levels?.forEach((bonus) => { + if (userData.level >= bonus.threshold) { + totalEntries += bonus.entries; + } + }); + + giveaway.bonusEntries?.messages?.forEach((bonus) => { + if (userData.messagesSent >= bonus.threshold) { + totalEntries += bonus.entries; + } + }); + + const addResult = await addGiveawayParticipant( + messageId, + interaction.user.id, + totalEntries, + ); + + if (addResult === 'already_entered') { + await interaction.followUp({ + content: 'You have already entered this giveaway!', + flags: ['Ephemeral'], + }); + return; + } + + if (addResult === 'inactive') { + await interaction.followUp({ + content: 'This giveaway is no longer active.', + flags: ['Ephemeral'], + }); + return; + } + + if (addResult === 'error') { + await interaction.followUp({ + content: 'An error occurred while trying to enter the giveaway.', + flags: ['Ephemeral'], + }); + return; + } + + const updatedGiveaway = await getGiveaway(messageId); + if (!updatedGiveaway) { + console.error( + `Failed to fetch giveaway ${messageId} after successful entry.`, + ); + await interaction.followUp({ + content: `🎉 You have entered the giveaway with ${totalEntries} entries! Good luck! (Failed to update embed)`, + flags: ['Ephemeral'], + }); + return; + } + + const embed = createGiveawayEmbed({ + id: updatedGiveaway.id, + prize: updatedGiveaway.prize, + endTime: updatedGiveaway.endAt, + winnerCount: updatedGiveaway.winnerCount, + hostId: updatedGiveaway.hostId, + participantCount: updatedGiveaway.participants?.length || 0, + requiredLevel: updatedGiveaway.requiredLevel ?? undefined, + requiredRoleId: updatedGiveaway.requiredRoleId ?? undefined, + requiredMessageCount: updatedGiveaway.requiredMessageCount ?? undefined, + requireAllCriteria: updatedGiveaway.requireAllCriteria ?? undefined, + bonusEntries: updatedGiveaway.bonusEntries, + }); + + await interaction.message.edit({ + embeds: [embed], + components: [createGiveawayButtons()], + }); + + await interaction.followUp({ + content: `🎉 You have entered the giveaway with **${totalEntries}** entries! Good luck!`, + flags: ['Ephemeral'], + }); + } catch (error) { + console.error('Error handling giveaway entry:', error); + throw error; + } +} + +// ======================== +// Dropdown Handlers +// ======================== + +/** + * Handles the duration selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleDurationSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const duration = interaction.values[0]; + + if (duration === 'custom') { + showCustomDurationModal(interaction); + return; + } + + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + const durationMs = parseDuration(duration); + if (durationMs) { + session.duration = duration; + session.endTime = new Date(Date.now() + durationMs); + await saveSession(interaction.user.id, session); + } + + await showBuilderStep(interaction, session); +} + +/** + * Handles the winner selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleWinnerSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const winnerCount = parseInt(interaction.values[0]); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.winnerCount = winnerCount; + await saveSession(interaction.user.id, session); + + await showBuilderStep(interaction, session); +} + +/** + * Handles the channel selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleChannelSelect( + interaction: StringSelectMenuInteraction, +): Promise { + try { + const channelId = interaction.values[0]; + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.channelId = channelId; + await saveSession(interaction.user.id, session); + + if (interaction.replied || interaction.deferred) { + await showBuilderStep(interaction, session); + } else { + await interaction.deferUpdate(); + await showBuilderStep(interaction, session); + } + } catch (error) { + console.error('Error in handleChannelSelect:', error); + if (!interaction.replied) { + await interaction + .reply({ + content: 'An error occurred while processing your selection.', + flags: ['Ephemeral'], + }) + .catch(console.error); + } + } +} + +/** + * Handles the requirements selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handlePingRoleSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const roleId = interaction.values[0]; + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.pingRoleId = roleId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +// ======================== +// Modal Handlers +// ======================== + +/** + * Handles the prize input for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handlePrizeSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const prize = interaction.fields.getTextInputValue('prize_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.prize = prize; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the custom duration input for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleCustomDurationSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const customDuration = interaction.fields.getTextInputValue('duration_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + const durationMs = parseDuration(customDuration); + if (!durationMs || durationMs <= 0) { + await interaction.reply({ + content: 'Invalid duration format. Please use formats like 1d, 12h, 30m.', + flags: ['Ephemeral'], + }); + return; + } + + session.duration = customDuration; + session.endTime = new Date(Date.now() + durationMs); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the requirements submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleRequirementsSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const levelStr = interaction.fields.getTextInputValue('level_input'); + const messageStr = interaction.fields.getTextInputValue('message_input'); + const roleStr = interaction.fields.getTextInputValue('role_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (levelStr.trim()) { + const level = parseInt(levelStr); + if (!isNaN(level) && level > 0) { + session.requirements.level = level; + } else { + delete session.requirements.level; + } + } else { + delete session.requirements.level; + } + + if (messageStr.trim()) { + const messages = parseInt(messageStr); + if (!isNaN(messages) && messages > 0) { + session.requirements.messageCount = messages; + } else { + delete session.requirements.messageCount; + } + } else { + delete session.requirements.messageCount; + } + + if (roleStr.trim()) { + const roleId = roleStr.replace(/\D/g, ''); + if (roleId) { + session.requirements.roleId = roleId; + } else { + delete session.requirements.roleId; + } + } + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the bonus entries submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleBonusEntriesSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) return; + + const rolesStr = interaction.fields.getTextInputValue('roles_input'); + const levelsStr = interaction.fields.getTextInputValue('levels_input'); + const messagesStr = interaction.fields.getTextInputValue('messages_input'); + + session.bonusEntries = { + roles: parseRoleBonusEntries(rolesStr), + levels: parseThresholdBonusEntries(levelsStr), + messages: parseThresholdBonusEntries(messagesStr), + }; + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the ping role ID submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handlePingRoleIdSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const roleId = interaction.fields.getTextInputValue('role_input'); + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.pingRoleId = roleId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the channel ID submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleChannelIdSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const channelId = interaction.fields.getTextInputValue('channel_input'); + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.channelId = channelId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} diff --git a/src/util/giveaways/modals.ts b/src/util/giveaways/modals.ts new file mode 100644 index 0000000..3bef0e0 --- /dev/null +++ b/src/util/giveaways/modals.ts @@ -0,0 +1,186 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + StringSelectMenuInteraction, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +/** + * Shows a modal to set the prize for a giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showPrizeModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_prize_modal') + .setTitle('Set Giveaway Prize'); + + const prizeInput = new TextInputBuilder() + .setCustomId('prize_input') + .setLabel('What are you giving away?') + .setPlaceholder('e.g. Discord Nitro, Steam Game, etc.') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(prizeInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to set custom duration. + * @param interaction The interaction that triggered the modal. + */ +export async function showCustomDurationModal( + interaction: StringSelectMenuInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_custom_duration') + .setTitle('Set Custom Duration'); + + const durationInput = new TextInputBuilder() + .setCustomId('duration_input') + .setLabel('Duration (e.g. 4h30m, 2d12h)') + .setPlaceholder('Enter custom duration') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(durationInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to set entry requirements. + * @param interaction The interaction that triggered the modal. + */ +export async function showRequirementsModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_requirements_modal') + .setTitle('Set Entry Requirements'); + + const levelInput = new TextInputBuilder() + .setCustomId('level_input') + .setLabel('Min level (leave empty for none)') + .setPlaceholder('e.g. 10') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const messageInput = new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Min messages (leave empty for none)') + .setPlaceholder('e.g. 100') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const roleInput = new TextInputBuilder() + .setCustomId('role_input') + .setLabel('Role ID (leave empty for none)') + .setPlaceholder('e.g. 123456789012345678') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(levelInput), + new ActionRowBuilder().addComponents(messageInput), + new ActionRowBuilder().addComponents(roleInput), + ); + + await interaction.showModal(modal); +} + +/** + * Shows a modal to set bonus entries for the giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showBonusEntriesModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_bonus_entries_modal') + .setTitle('Bonus Entries Configuration'); + + const rolesInput = new TextInputBuilder() + .setCustomId('roles_input') + .setLabel('Role bonuses') + .setPlaceholder('format: roleId:entries,roleId:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const levelsInput = new TextInputBuilder() + .setCustomId('levels_input') + .setLabel('Level bonuses') + .setPlaceholder('format: level:entries,level:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const messagesInput = new TextInputBuilder() + .setCustomId('messages_input') + .setLabel('Message bonuses') + .setPlaceholder('format: count:entries,count:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(rolesInput), + new ActionRowBuilder().addComponents(levelsInput), + new ActionRowBuilder().addComponents(messagesInput), + ); + + await interaction.showModal(modal); +} + +/** + * Shows a modal to select a role to ping. + * @param interaction The interaction that triggered the modal. + */ +export async function showPingRoleSelectModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_ping_role_id_modal') + .setTitle('Enter Role ID'); + + const roleInput = new TextInputBuilder() + .setCustomId('role_input') + .setLabel('Role ID') + .setPlaceholder('Enter the role ID to ping') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(roleInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to select the channel to host the giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showChannelSelectModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_channel_id_modal') + .setTitle('Select Channel for Giveaway'); + + const channelInput = new TextInputBuilder() + .setCustomId('channel_input') + .setLabel('Channel ID') + .setPlaceholder('Enter the channel ID to host the giveaway') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(channelInput), + ); + await interaction.showModal(modal); +} diff --git a/src/util/giveaways/types.ts b/src/util/giveaways/types.ts new file mode 100644 index 0000000..6cce4be --- /dev/null +++ b/src/util/giveaways/types.ts @@ -0,0 +1,39 @@ +export interface BonusEntries { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; +} + +export interface GiveawaySession { + step: number; + prize?: string; + duration?: string; + endTime?: Date; + winnerCount: number; + channelId?: string; + requirements: { + level?: number; + roleId?: string; + messageCount?: number; + requireAll: boolean; + }; + pingRoleId?: string; + bonusEntries?: BonusEntries; +} + +export interface GiveawayEmbedParams { + id?: number; + prize: string; + endTime?: Date; + winnerCount?: number; + hostId: string; + participantCount?: number; + winnersIds?: string[]; + isEnded?: boolean; + footerText?: string; + requiredLevel?: number; + requiredRoleId?: string; + requiredMessageCount?: number; + requireAllCriteria?: boolean; + bonusEntries?: BonusEntries; +} diff --git a/src/util/giveaways/utils.ts b/src/util/giveaways/utils.ts new file mode 100644 index 0000000..d3128af --- /dev/null +++ b/src/util/giveaways/utils.ts @@ -0,0 +1,220 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, +} from 'discord.js'; + +import { del, getJson, setJson } from '@/db/redis.js'; +import { getUserLevel } from '@/db/db.js'; +import { GiveawaySession } from './types.js'; +import { SESSION_PREFIX, SESSION_TIMEOUT } from './constants.js'; +import { showBuilderStep } from './builder.js'; + +/** + * Select winners for the giveaway. + * @param participants - Array of participant IDs + * @param winnerCount - Number of winners to select + * @param forceWinners - Array of IDs to force as winners + * @param excludeIds - Array of IDs to exclude from selection + * @returns - Array of winner IDs + */ +export function selectGiveawayWinners( + participants: string[], + winnerCount: number, + forceWinners?: string[], + excludeIds?: string[], +): string[] { + if (forceWinners?.length) return forceWinners; + + const eligibleParticipants = excludeIds + ? participants.filter((p) => !excludeIds.includes(p)) + : participants; + + if (!eligibleParticipants.length) return []; + + const uniqueParticipants = [...new Set(eligibleParticipants)]; + + const actualWinnerCount = Math.min(winnerCount, uniqueParticipants.length); + const shuffled = uniqueParticipants.sort(() => 0.5 - Math.random()); + return shuffled.slice(0, actualWinnerCount); +} + +/** + * Format the winner mentions for the giveaway embed. + * @param winnerIds - Array of winner IDs + * @returns - Formatted string of winner mentions + */ +export function formatWinnerMentions(winnerIds?: string[]): string { + return winnerIds?.length + ? winnerIds.map((id) => `<@${id}>`).join(', ') + : 'No valid participants'; +} + +/** + * Create the giveaway button for users to enter. + * @returns - ActionRowBuilder with the giveaway button + */ +export function createGiveawayButtons(): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('enter_giveaway') + .setLabel('Enter Giveaway') + .setStyle(ButtonStyle.Success) + .setEmoji('🎉'), + ); +} + +/** + * Check if the user meets the giveaway requirements. + * @param interaction - Button interaction from Discord + * @param giveaway - Giveaway data + * @returns - Array of failed and met requirements + */ +export async function checkUserRequirements( + interaction: ButtonInteraction, + giveaway: any, +): Promise<[string[], string[]]> { + const requirementsFailed: string[] = []; + const requirementsMet: string[] = []; + + if (giveaway.requiredLevel) { + const userData = await getUserLevel(interaction.user.id); + if (userData.level < giveaway.requiredLevel) { + requirementsFailed.push( + `You need to be level ${giveaway.requiredLevel}+ to enter (you're level ${userData.level})`, + ); + } else { + requirementsMet.push(`Level requirement met (${userData.level})`); + } + } + + if (giveaway.requiredRoleId) { + const member = await interaction.guild?.members.fetch(interaction.user.id); + if (!member?.roles.cache.has(giveaway.requiredRoleId)) { + requirementsFailed.push( + `You need the <@&${giveaway.requiredRoleId}> role to enter`, + ); + } else { + requirementsMet.push('Role requirement met'); + } + } + + if (giveaway.requiredMessageCount) { + const userData = await getUserLevel(interaction.user.id); + if (userData.messagesSent < giveaway.requiredMessageCount) { + requirementsFailed.push( + `You need to have sent ${giveaway.requiredMessageCount}+ messages to enter (you've sent ${userData.messagesSent})`, + ); + } else { + requirementsMet.push( + `Message count requirement met (${userData.messagesSent})`, + ); + } + } + + return [requirementsFailed, requirementsMet]; +} + +/** + * Check if the user has already entered the giveaway. + * @param interaction - Button interaction from Discord + * @param giveaway - Giveaway data + * @returns - Boolean indicating if the user has entered + */ +export async function saveSession( + userId: string, + data: GiveawaySession, +): Promise { + const sessionToStore = { + ...data, + endTime: data.endTime?.toISOString(), + }; + await setJson(`${SESSION_PREFIX}${userId}`, sessionToStore, SESSION_TIMEOUT); +} + +/** + * Get the giveaway session for a user. + * @param userId - The ID of the user + * @returns - The user's giveaway session or null if not found + */ +export async function getSession( + userId: string, +): Promise { + const session = await getJson(`${SESSION_PREFIX}${userId}`); + if (!session) return null; + + return { + ...session, + endTime: session.endTime ? new Date(session.endTime) : undefined, + }; +} + +/** + * Delete the giveaway session for a user. + * @param userId - The ID of the user + */ +export async function deleteSession(userId: string): Promise { + await del(`${SESSION_PREFIX}${userId}`); +} + +/** + * Toggle the requirement logic for the giveaway session. + * @param interaction - Button interaction from Discord + */ +export async function toggleRequirementLogic( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.requirements.requireAll = !session.requirements.requireAll; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Parse the role bonus entries from a string input. + * @param input - String input in the format "roleId:entries,roleId:entries" + * @returns - Array of objects containing role ID and entries + */ +export function parseRoleBonusEntries( + input: string, +): Array<{ id: string; entries: number }> { + if (!input.trim()) return []; + + return input + .split(',') + .map((entry) => entry.trim().split(':')) + .filter(([key, value]) => key && value) + .map(([key, value]) => ({ + id: key, + entries: Number(value) || 0, + })); +} + +/** + * Parse the level bonus entries from a string input. + * @param input - String input in the format "level:entries,level:entries" + * @returns - Array of objects containing level and entries + */ +export function parseThresholdBonusEntries( + input: string, +): Array<{ threshold: number; entries: number }> { + if (!input.trim()) return []; + + return input + .split(',') + .map((entry) => entry.trim().split(':')) + .filter(([key, value]) => key && value) + .map(([key, value]) => ({ + threshold: Number(key) || 0, + entries: Number(value) || 0, + })); +} diff --git a/src/util/helpers.ts b/src/util/helpers.ts index dcc6fca..1cb210d 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,15 +1,29 @@ import Canvas from '@napi-rs/canvas'; import path from 'path'; -import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js'; +import { + AttachmentBuilder, + Client, + GuildMember, + Guild, + Interaction, + ButtonStyle, + ButtonBuilder, + ActionRowBuilder, +} from 'discord.js'; import { and, eq } from 'drizzle-orm'; -import { moderationTable } from '../db/schema.js'; -import { db, updateMember } from '../db/db.js'; +import { moderationTable } from '@/db/schema.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 +44,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 +116,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 +137,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 +178,208 @@ 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); + 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(); + } +} + +/** + * Draw wrapped text in multiple lines + * @param ctx - The canvas context to use + * @param text - The text to draw + * @param x - The x position to draw the text + * @param y - The y position to draw the text + * @param maxWidth - The maximum width of the text + * @param lineHeight - The height of each line + */ +export function drawMultilineText( + ctx: Canvas.SKRSContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + lineHeight: number, +) { + const words = text.split(' '); + let line = ''; + for (let i = 0; i < words.length; i++) { + const testLine = line + words[i] + ' '; + if (ctx.measureText(testLine).width > maxWidth && i > 0) { + ctx.fillText(line, x, y); + line = words[i] + ' '; + y += lineHeight; } else { - await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt); + line = testLine; } } + ctx.fillText(line, x, y); +} + +/** + * Checks if an interaction is valid + * @param interaction - The interaction to check + * @returns - Whether the interaction is valid + */ +export async function validateInteraction( + interaction: Interaction, +): Promise { + if (!interaction.inGuild()) return false; + if (!interaction.channel) return false; + + if (interaction.isMessageComponent()) { + try { + await interaction.channel.messages.fetch(interaction.message.id); + return true; + } catch { + return false; + } + } + + return true; +} + +/** + * Safely responds to an interaction + * @param interaction - The interaction to respond to + * @param content - The content to send + */ +export async function safelyRespond(interaction: Interaction, content: string) { + try { + if (!interaction.isRepliable()) return; + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, flags: ['Ephemeral'] }); + } else { + await interaction.reply({ content, flags: ['Ephemeral'] }); + } + } catch (error) { + console.error('Failed to respond to interaction:', error); + } +} + +/** + * Creates pagination buttons for navigating through multiple pages + * @param totalPages - The total number of pages + * @param currentPage - The current page number + * @returns - The action row with pagination buttons + */ +export function createPaginationButtons( + totalPages: number, + currentPage: number, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('first') + .setLabel('⏮️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('prev') + .setLabel('◀️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('pageinfo') + .setLabel(`Page ${currentPage + 1}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('next') + .setLabel('▶️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === totalPages - 1), + new ButtonBuilder() + .setCustomId('last') + .setLabel('⏭️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === totalPages - 1), + ); } diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts new file mode 100644 index 0000000..e6bf4b9 --- /dev/null +++ b/src/util/levelingSystem.ts @@ -0,0 +1,309 @@ +import path from 'path'; +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; +import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.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'; +import { processMessageAchievements } from './achievementManager.js'; + +const config = loadConfig(); + +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; + + let low = 1; + let high = 200; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const xpForMid = calculateXpForLevel(mid); + const xpForNext = calculateXpForLevel(mid + 1); + + if (xp >= xpForMid && xp < xpForNext) { + return mid; + } else if (xp < xpForMid) { + high = mid - 1; + } else { + low = mid + 1; + } + } + + return low - 1; +}; + +/** + * 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; + + const nextLevelXp = calculateXpForLevel(level + 1); + return nextLevelXp - currentXp; +}; + +/** + * Recalculates the levels for all users in the database + */ +export async function recalculateUserLevels() { + try { + const users = await db.select().from(schema.levelTable); + + 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; + + try { + const userId = message.author.id; + const userData = await getUserLevel(userId); + const oldXp = userData.xp; + + if (userData.lastMessageTimestamp) { + const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime(); + const currentTime = Date.now(); + + if (currentTime - lastMessageTime < XP_COOLDOWN) { + return null; + } + } + + let xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + + if (xpToAdd > 100) { + console.error( + `Unusually large XP amount generated: ${xpToAdd}. Capping at 100.`, + ); + xpToAdd = 100; + } + + const result = await addXpToUser(userId, xpToAdd); + + const newUserData = await getUserLevel(userId); + if (newUserData.xp > oldXp + 100) { + console.error( + `Detected abnormal XP increase: ${oldXp} → ${newUserData.xp}`, + ); + } + + await processMessageAchievements(message); + return result; + } catch (error) { + console.error('Error processing message for XP:', error); + return null; + } +} + +/** + * 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, +) { + 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({ + ctx: context, + x: 22, + y: 22, + width: 890, + height: 238, + radius: 20, + fill: 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({ + ctx: context, + x: barX, + y: barY, + width: barWidth, + height: barHeight, + radius: barHeight / 2, + fill: true, + }); + + if (progress > 0) { + context.fillStyle = '#5865F2'; + roundRect({ + ctx: context, + x: barX, + y: barY, + width: barWidth * progress, + height: barHeight, + radius: barHeight / 2, + fill: 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', + }); +} + +/** + * 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, + 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 newRolesToAdd = rolesToAdd.filter( + (roleId) => !member.roles.cache.has(roleId), + ); + + if (newRolesToAdd.length > 0) { + await member.roles.add(newRolesToAdd); + } + + const highestRole = rolesToAdd[rolesToAdd.length - 1]; + return highestRole; + } catch (error) { + console.error('Error assigning level roles:', error); + } +} 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 7becbe5..a78ea48 100644 --- a/src/util/logging/logAction.ts +++ b/src/util/logging/logAction.ts @@ -1,10 +1,10 @@ import { - TextChannel, ButtonStyle, ButtonBuilder, ActionRowBuilder, GuildChannel, } from 'discord.js'; + import { LogActionPayload, ModerationLogAction, @@ -22,10 +22,18 @@ 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)) { +/** + * 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()) { console.error('Log channel not found or is not a Text Channel.'); return; } 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}` : ''}`, + ); +} diff --git a/tsconfig.json b/tsconfig.json index a5e1167..2c00197 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,8 +30,10 @@ "module": "esnext" /* Specify what module code is generated. */, "rootDir": "src" /* Specify the root folder within your source files. */, "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, + "paths": { + "@/*": ["src/*", "./"] + } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ @@ -76,7 +78,7 @@ // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, @@ -86,8 +88,8 @@ /* Type Checking */ "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ @@ -106,7 +108,11 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "plugins": [ + { "transform": "typescript-transform-paths" }, + { "transform": "typescript-transform-paths", "afterDeclarations": true } + ] }, "include": ["src/**/*"] } diff --git a/yarn.lock b/yarn.lock index a6c76c2..4328c0d 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" @@ -1075,6 +1284,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" @@ -1253,6 +1471,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" @@ -1327,6 +1557,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" @@ -1350,7 +1601,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 @@ -1371,6 +1622,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" @@ -1440,7 +1698,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": +"chalk@npm:^4.0.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -1450,6 +1708,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" @@ -1464,6 +1729,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" @@ -1487,6 +1782,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" @@ -1494,6 +1813,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" @@ -1501,7 +1882,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: @@ -1512,6 +1893,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" @@ -1536,6 +1924,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" @@ -1600,6 +2000,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.31.0": version: 0.31.0 resolution: "drizzle-kit@npm:0.31.0" @@ -1713,6 +2122,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" @@ -1736,13 +2152,20 @@ __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 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" @@ -1750,6 +2173,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" @@ -2010,6 +2442,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" @@ -2232,6 +2671,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" @@ -2273,6 +2736,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" @@ -2319,6 +2789,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" @@ -2401,6 +2882,34 @@ __metadata: languageName: node linkType: hard +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + 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" @@ -2410,6 +2919,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" @@ -2458,6 +2980,26 @@ __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 + +"global-prefix@npm:^4.0.0": + version: 4.0.0 + resolution: "global-prefix@npm:4.0.0" + dependencies: + ini: "npm:^4.1.3" + kind-of: "npm:^6.0.3" + which: "npm:^4.0.0" + checksum: 10c0/a757bba494f0542a34e82716450506a076e769e05993a9739aea3bf27c3f710cd5635d0f4c1c242650c0dc133bf20a8e8fc9cfd3d1d1c371717218ef561f1ac4 + languageName: node + linkType: hard + "globals@npm:^13.19.0": version: 13.24.0 resolution: "globals@npm:13.24.0" @@ -2502,6 +3044,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -2529,6 +3080,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" @@ -2555,6 +3122,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" @@ -2586,6 +3170,20 @@ __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 + +"ini@npm:^4.1.3": + version: 4.1.3 + resolution: "ini@npm:4.1.3" + checksum: 10c0/0d27eff094d5f3899dd7c00d0c04ea733ca03a8eb6f9406ce15daac1a81de022cb417d6eaff7e4342451ffa663389c565ffc68d6825eaf686bf003280b945764 + languageName: node + linkType: hard + "ioredis@npm:^5.6.1": version: 5.6.1 resolution: "ioredis@npm:5.6.1" @@ -2613,6 +3211,22 @@ __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-core-module@npm:^2.16.0": + version: 2.16.1 + resolution: "is-core-module@npm:2.16.1" + dependencies: + hasown: "npm:^2.0.2" + checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -2627,6 +3241,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" @@ -2650,6 +3280,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" @@ -2657,6 +3294,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" @@ -2684,6 +3337,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" @@ -2716,6 +3385,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" @@ -2723,6 +3399,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" @@ -2730,6 +3413,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" @@ -2739,6 +3429,13 @@ __metadata: languageName: node linkType: hard +"kind-of@npm:^6.0.3": + version: 6.0.3 + resolution: "kind-of@npm:6.0.3" + checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4 + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -2749,6 +3446,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" @@ -2758,6 +3503,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" @@ -2772,6 +3533,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" @@ -2779,13 +3554,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" @@ -2793,6 +3596,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" @@ -2834,6 +3650,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" @@ -2841,7 +3671,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: @@ -2851,6 +3681,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" @@ -2860,7 +3704,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.4": +"minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -2869,6 +3713,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" @@ -3021,6 +3872,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" @@ -3037,6 +3897,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" @@ -3060,6 +3938,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" @@ -3069,6 +3956,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" @@ -3094,6 +3990,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" @@ -3101,6 +4009,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" @@ -3115,6 +4030,20 @@ __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-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 + languageName: node + linkType: hard + "path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" @@ -3235,6 +4164,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" @@ -3242,10 +4178,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.1" "@eslint/js": "npm:^9.24.0" "@microsoft/eslint-formatter-sarif": "npm:^3.1.0" @@ -3260,12 +4207,16 @@ __metadata: eslint: "npm:^9.24.0" eslint-config-prettier: "npm:^10.1.2" globals: "npm:^16.0.0" + husky: "npm:^9.1.7" ioredis: "npm:^5.6.1" + lint-staged: "npm:^15.5.0" pg: "npm:^8.14.1" prettier: "npm:3.5.3" ts-node: "npm:^10.9.2" + ts-patch: "npm:^3.3.0" tsx: "npm:^4.19.3" typescript: "npm:^5.8.3" + typescript-transform-paths: "npm:^3.5.5" languageName: unknown linkType: soft @@ -3399,6 +4350,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" @@ -3406,6 +4371,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" @@ -3413,6 +4385,42 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.22.2": + version: 1.22.10 + resolution: "resolve@npm:1.22.10" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.22.2#optional!builtin": + version: 1.22.10 + resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 + 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" @@ -3427,6 +4435,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" @@ -3463,6 +4478,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.7.1 + resolution: "semver@npm:7.7.1" + bin: + semver: bin/semver.js + checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -3479,13 +4503,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" @@ -3531,7 +4575,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 @@ -3561,7 +4605,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: @@ -3583,6 +4634,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" @@ -3592,7 +4654,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: @@ -3601,6 +4663,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" @@ -3617,6 +4686,13 @@ __metadata: languageName: node linkType: hard +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 + languageName: node + linkType: hard + "tar@npm:^6.1.11, tar@npm:^6.2.1": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -3631,6 +4707,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" @@ -3638,6 +4721,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" @@ -3701,6 +4798,23 @@ __metadata: languageName: node linkType: hard +"ts-patch@npm:^3.3.0": + version: 3.3.0 + resolution: "ts-patch@npm:3.3.0" + dependencies: + chalk: "npm:^4.1.2" + global-prefix: "npm:^4.0.0" + minimist: "npm:^1.2.8" + resolve: "npm:^1.22.2" + semver: "npm:^7.6.3" + strip-ansi: "npm:^6.0.1" + bin: + ts-patch: bin/ts-patch.js + tspc: bin/tspc.js + checksum: 10c0/41abfa08ea70755f44f39c32b8906479cddf66f163ea37bdd8b543dcda548ec6cc3d7b6f53371161fbfaa9ff48e4fbb0d5839f46f425f7058f7710253e607c20 + languageName: node + linkType: hard + "tslib@npm:^2.6.2, tslib@npm:^2.6.3": version: 2.7.0 resolution: "tslib@npm:2.7.0" @@ -3740,6 +4854,17 @@ __metadata: languageName: node linkType: hard +"typescript-transform-paths@npm:^3.5.5": + version: 3.5.5 + resolution: "typescript-transform-paths@npm:3.5.5" + dependencies: + minimatch: "npm:^9.0.5" + peerDependencies: + typescript: ">=3.6.5" + checksum: 10c0/253aa063b43588753ac651c12b22e1e2ce32273a0b5a59be038de7aba70b95e3363461bc2cc6ad5244525890c90f3ee350fe70fa0680846614eadf92738a87ed + languageName: node + linkType: hard + "typescript@npm:^5.8.3": version: 5.8.3 resolution: "typescript@npm:5.8.3" @@ -3781,6 +4906,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" @@ -3851,7 +4983,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: @@ -3873,6 +5005,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" @@ -3902,6 +5045,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" @@ -3909,6 +5059,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" @@ -3922,3 +5103,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