diff --git a/README.md b/README.md index 2a8a40e..d784000 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ > [!WARNING] > This Discord bot is not production ready and everything is subject to change +> [!TIP] +> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY). + ## Development Commands Install Dependencies: ``yarn install`` 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/config.example.json b/config.example.json index 28fca15..cecb413 100644 --- a/config.example.json +++ b/config.example.json @@ -6,11 +6,44 @@ "redisConnectionString": "REDIS_CONNECTION_STRING", "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" } } diff --git a/package.json b/package.json index ed1c326..51c600f 100644 --- a/package.json +++ b/package.json @@ -38,5 +38,5 @@ "tsx": "^4.19.3", "typescript": "^5.8.2" }, - "packageManager": "yarn@4.6.0" + "packageManager": "yarn@4.7.0" } diff --git a/src/commands/fun/counting.ts b/src/commands/fun/counting.ts new file mode 100644 index 0000000..fb8bbce --- /dev/null +++ b/src/commands/fun/counting.ts @@ -0,0 +1,117 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionsBitField, +} from 'discord.js'; + +import { SubcommandCommand } from '../../types/CommandTypes.js'; +import { getCountingData, setCount } from '../../util/countingManager.js'; +import { loadConfig } from '../../util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('counting') + .setDescription('Commands related to the counting channel') + .addSubcommand((subcommand) => + subcommand + .setName('status') + .setDescription('Check the current counting status'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('setcount') + .setDescription( + 'Set the current count to a specific number (Admin only)', + ) + .addIntegerOption((option) => + option + .setName('count') + .setDescription('The number to set as the current count') + .setRequired(true) + .setMinValue(0), + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'status') { + const countingData = await getCountingData(); + const countingChannelId = loadConfig().channels.counting; + + const embed = new EmbedBuilder() + .setTitle('Counting Channel Status') + .setColor(0x0099ff) + .addFields( + { + name: 'Current Count', + value: countingData.currentCount.toString(), + inline: true, + }, + { + name: 'Next Number', + value: (countingData.currentCount + 1).toString(), + inline: true, + }, + { + name: 'Highest Count', + value: countingData.highestCount.toString(), + inline: true, + }, + { + name: 'Total Correct Counts', + value: countingData.totalCorrect.toString(), + inline: true, + }, + { + name: 'Counting Channel', + value: `<#${countingChannelId}>`, + inline: true, + }, + ) + .setFooter({ text: 'Remember: No user can count twice in a row!' }) + .setTimestamp(); + + if (countingData.lastUserId) { + embed.addFields({ + name: 'Last Counter', + value: `<@${countingData.lastUserId}>`, + inline: true, + }); + } + + await interaction.reply({ embeds: [embed] }); + } else if (subcommand === 'setcount') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You need administrator permissions to use this command.', + flags: ['Ephemeral'], + }); + return; + } + + const count = interaction.options.getInteger('count'); + if (count === null) { + await interaction.reply({ + content: 'Invalid count specified.', + flags: ['Ephemeral'], + }); + return; + } + + await setCount(count); + await interaction.reply({ + content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts new file mode 100644 index 0000000..b0b4c93 --- /dev/null +++ b/src/commands/fun/fact.ts @@ -0,0 +1,245 @@ +import { + SlashCommandBuilder, + PermissionsBitField, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from 'discord.js'; + +import { + addFact, + getPendingFacts, + approveFact, + deleteFact, + getLastInsertedFactId, +} from '../../db/db.js'; +import { postFactOfTheDay } from '../../util/factManager.js'; +import { loadConfig } from '../../util/configLoader.js'; +import { SubcommandCommand } from '../../types/CommandTypes.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('fact') + .setDescription('Manage facts of the day') + .addSubcommand((subcommand) => + subcommand + .setName('submit') + .setDescription('Submit a new fact for approval') + .addStringOption((option) => + option + .setName('content') + .setDescription('The fact content') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('source') + .setDescription('Source of the fact (optional)') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('approve') + .setDescription('Approve a pending fact (Mod only)') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('The ID of the fact to approve') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete a fact (Mod only)') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('The ID of the fact to delete') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('pending') + .setDescription('List all pending facts (Mod only)'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('post') + .setDescription('Post a fact of the day manually (Admin only)'), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + 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 pendingFacts = await getPendingFacts(); + + if (pendingFacts.length === 0) { + await interaction.editReply({ + content: 'There are no pending facts.', + }); + return; + } + + const embed = new EmbedBuilder() + .setTitle('Pending Facts') + .setColor(0x0099ff) + .setDescription( + pendingFacts + .map((fact) => { + return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`; + }) + .join('\n\n'), + ) + .setTimestamp(); + + await interaction.editReply({ + embeds: [embed], + }); + } 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/leaderboard.ts b/src/commands/fun/leaderboard.ts new file mode 100644 index 0000000..0d37fef --- /dev/null +++ b/src/commands/fun/leaderboard.ts @@ -0,0 +1,171 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ButtonBuilder, + ActionRowBuilder, + ButtonStyle, + StringSelectMenuBuilder, + APIEmbed, + JSONEncodable, +} from 'discord.js'; + +import { OptionsCommand } from '../../types/CommandTypes.js'; +import { getLevelLeaderboard } from '../../db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('leaderboard') + .setDescription('Shows the server XP leaderboard') + .addIntegerOption((option) => + option + .setName('limit') + .setDescription('Number of users per page (default: 10)') + .setRequired(false), + ), + execute: async (interaction) => { + if (!interaction.guild) return; + + await interaction.deferReply(); + + try { + const usersPerPage = + (interaction.options.get('limit')?.value as number) || 10; + + const allUsers = await getLevelLeaderboard(100); + + if (allUsers.length === 0) { + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription('No users found on the leaderboard yet.') + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } + + const pages: (APIEmbed | JSONEncodable)[] = []; + + for (let i = 0; i < allUsers.length; i += usersPerPage) { + const pageUsers = allUsers.slice(i, i + usersPerPage); + let leaderboardText = ''; + + for (let j = 0; j < pageUsers.length; j++) { + const user = pageUsers[j]; + const position = i + j + 1; + + try { + const member = await interaction.guild.members.fetch( + user.discordId, + ); + leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`; + } catch (error) { + leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`; + } + } + + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription(leaderboardText) + .setTimestamp() + .setFooter({ + text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`, + }); + + pages.push(embed); + } + + let currentPage = 0; + + const getButtonActionRow = () => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('previous') + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('next') + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === pages.length - 1), + ); + + const getSelectMenuRow = () => { + const options = pages.map((_, index) => ({ + label: `Page ${index + 1}`, + value: index.toString(), + default: index === currentPage, + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('select_page') + .setPlaceholder('Jump to a page') + .addOptions(options); + + return new ActionRowBuilder().addComponents( + select, + ); + }; + + const components = + pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components, + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + if (i.customId === 'previous' && currentPage > 0) { + currentPage--; + } else if (i.customId === 'next' && currentPage < pages.length - 1) { + currentPage++; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [getButtonActionRow(), getSelectMenuRow()], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } + }); + } catch (error) { + console.error('Error getting leaderboard:', error); + await interaction.editReply('Failed to get leaderboard information.'); + } + }, +}; + +export default command; diff --git a/src/commands/fun/rank.ts b/src/commands/fun/rank.ts new file mode 100644 index 0000000..0007d05 --- /dev/null +++ b/src/commands/fun/rank.ts @@ -0,0 +1,49 @@ +import { GuildMember, SlashCommandBuilder } from 'discord.js'; + +import { OptionsCommand } from '../../types/CommandTypes.js'; +import { + generateRankCard, + getXpToNextLevel, +} from '../../util/levelingSystem.js'; +import { getUserLevel } from '../../db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('rank') + .setDescription('Shows your current rank and level') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to check rank for (defaults to yourself)') + .setRequired(false), + ), + execute: async (interaction) => { + const member = await interaction.guild?.members.fetch( + (interaction.options.get('user')?.value as string) || interaction.user.id, + ); + + if (!member) { + await interaction.reply('User not found in this server.'); + return; + } + + await interaction.deferReply(); + + try { + const userData = await getUserLevel(member.id); + const rankCard = await generateRankCard(member, userData); + + const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp); + + await interaction.editReply({ + content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`, + files: [rankCard], + }); + } catch (error) { + console.error('Error getting rank:', error); + await interaction.editReply('Failed to get rank information.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/recalculatelevels.ts b/src/commands/util/recalculatelevels.ts new file mode 100644 index 0000000..0667d1a --- /dev/null +++ b/src/commands/util/recalculatelevels.ts @@ -0,0 +1,36 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { Command } from '../../types/CommandTypes.js'; +import { recalculateUserLevels } from '../../util/levelingSystem.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('recalculatelevels') + .setDescription('(Admin Only) Recalculate all user levels'), + execute: async (interaction) => { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to use this command.', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.deferReply(); + await interaction.editReply('Recalculating levels...'); + + try { + await recalculateUserLevels(); + await interaction.editReply('Levels recalculated successfully!'); + } catch (error) { + console.error('Error recalculating levels:', error); + await interaction.editReply('Failed to recalculate levels.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/xp.ts b/src/commands/util/xp.ts new file mode 100644 index 0000000..cac94e8 --- /dev/null +++ b/src/commands/util/xp.ts @@ -0,0 +1,132 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import { SubcommandCommand } from '../../types/CommandTypes.js'; +import { addXpToUser, getUserLevel } from '../../db/db.js'; +import { loadConfig } from '../../util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('xp') + .setDescription('(Manager only) Manage user XP') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add XP to a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to add XP to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to add') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove XP from a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to remove XP from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to remove') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to set XP for') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to set') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('reset') + .setDescription('Reset XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to reset XP for') + .setRequired(true), + ), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const commandUser = interaction.guild?.members.cache.get( + interaction.user.id, + ); + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + if ( + !commandUser || + !managerRoleId || + commandUser.roles.highest.comparePositionTo(managerRoleId) < 0 + ) { + await interaction.reply({ + content: 'You do not have permission to use this command', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + await interaction.editReply('Processing...'); + + const subcommand = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + const amount = interaction.options.getInteger('amount', false); + + const userData = await getUserLevel(user.id); + + if (subcommand === 'add') { + await addXpToUser(user.id, amount!); + await interaction.editReply({ + content: `Added ${amount} XP to <@${user.id}>`, + }); + } else if (subcommand === 'remove') { + await addXpToUser(user.id, -amount!); + await interaction.editReply({ + content: `Removed ${amount} XP from <@${user.id}>`, + }); + } else if (subcommand === 'set') { + await addXpToUser(user.id, amount! - userData.xp); + await interaction.editReply({ + content: `Set ${amount} XP for <@${user.id}>`, + }); + } else if (subcommand === 'reset') { + await addXpToUser(user.id, userData.xp * -1); + await interaction.editReply({ + content: `Reset XP for <@${user.id}>`, + }); + } + }, +}; + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index ebc2db8..b47d689 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,10 +1,11 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; +import { calculateLevelFromXp } from '../util/levelingSystem.js'; const { Pool } = pkg; const config = loadConfig(); @@ -162,6 +163,179 @@ export async function updateMember({ } } +export async function getUserLevel( + discordId: string, +): Promise { + try { + if (await exists(`level-${discordId}`)) { + const cachedLevel = await getJson( + `level-${discordId}`, + ); + if (cachedLevel !== null) { + return cachedLevel; + } + await del(`level-${discordId}`); + } + + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); + + if (level) { + const typedLevel: schema.levelTableTypes = { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; + await setJson(`level-${discordId}`, typedLevel); + return typedLevel; + } + + const newLevel = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + }; + + await db.insert(schema.levelTable).values(newLevel); + await setJson(`level-${discordId}`, newLevel); + return newLevel; + } catch (error) { + console.error('Error getting user level:', error); + throw error; + } +} + +export async function addXpToUser(discordId: string, amount: number) { + try { + const userData = await getUserLevel(discordId); + const currentLevel = userData.level; + + userData.xp += amount; + userData.lastMessageTimestamp = new Date(); + + const newLevel = calculateLevelFromXp(userData.xp); + userData.level = newLevel; + + await db + .update(schema.levelTable) + .set({ + xp: userData.xp, + level: newLevel, + lastMessageTimestamp: userData.lastMessageTimestamp, + }) + .where(eq(schema.levelTable.discordId, discordId)); + + await setJson(`level-${discordId}`, userData); + + await invalidateLeaderboardCache(); + + return { + leveledUp: newLevel > currentLevel, + newLevel, + oldLevel: currentLevel, + }; + } catch (error) { + console.error('Error adding XP to user:', error); + throw error; + } +} + +export async function getUserRank(discordId: string): Promise { + try { + if (await exists('xp-leaderboard-cache')) { + const leaderboardCache = await getJson< + Array<{ discordId: string; xp: number }> + >('xp-leaderboard-cache'); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; + } + } + } + + const allMembers = await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + + await setJson('xp-leaderboard-cache', allMembers, 300); + + const userIndex = allMembers.findIndex( + (member) => member.discordId === discordId, + ); + + return userIndex !== -1 ? userIndex + 1 : 1; + } catch (error) { + console.error('Error getting user rank:', error); + return 1; + } +} + +export async function invalidateLeaderboardCache() { + try { + if (await exists('xp-leaderboard-cache')) { + await del('xp-leaderboard-cache'); + } + } catch (error) { + console.error('Error invalidating leaderboard cache:', error); + } +} + +export async function getLevelLeaderboard(limit = 10) { + try { + if (await exists('xp-leaderboard-cache')) { + const leaderboardCache = await getJson< + Array<{ discordId: string; xp: number }> + >('xp-leaderboard-cache'); + + if (leaderboardCache) { + const limitedCache = leaderboardCache.slice(0, limit); + + const fullLeaderboard = await Promise.all( + limitedCache.map(async (entry) => { + const userData = await getUserLevel(entry.discordId); + return userData; + }), + ); + + return fullLeaderboard; + } + } + + const leaderboard = await db + .select() + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)) + .limit(limit); + + const allMembers = await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + + await setJson('xp-leaderboard-cache', allMembers, 300); + + return leaderboard; + } catch (error) { + console.error('Error getting leaderboard:', error); + throw error; + } +} + export async function updateMemberModerationHistory({ discordId, moderatorDiscordId, @@ -230,3 +404,132 @@ export async function getMemberModerationHistory(discordId: string) { ); } } + +export async function addFact({ + content, + source, + addedBy, + approved = false, +}: schema.factTableTypes) { + try { + const result = await db.insert(schema.factTable).values({ + content, + source, + addedBy, + approved, + }); + + await del('unusedFacts'); + return result; + } catch (error) { + console.error('Error adding fact:', error); + throw new DatabaseError('Failed to add fact:', error as Error); + } +} + +export async function getLastInsertedFactId(): Promise { + try { + const result = await db + .select({ id: sql`MAX(${schema.factTable.id})` }) + .from(schema.factTable); + + return result[0]?.id ?? 0; + } catch (error) { + console.error('Error getting last inserted fact ID:', error); + throw new DatabaseError( + 'Failed to get last inserted fact ID:', + error as Error, + ); + } +} + +export async function getRandomUnusedFact() { + try { + if (await exists('unusedFacts')) { + const facts = + await getJson<(typeof schema.factTable.$inferSelect)[]>('unusedFacts'); + if (facts && facts.length > 0) { + return facts[Math.floor(Math.random() * facts.length)]; + } + } + + const facts = await db + .select() + .from(schema.factTable) + .where( + and( + eq(schema.factTable.approved, true), + isNull(schema.factTable.usedOn), + ), + ); + + if (facts.length === 0) { + await db + .update(schema.factTable) + .set({ usedOn: null }) + .where(eq(schema.factTable.approved, true)); + + return await getRandomUnusedFact(); + } + + await setJson<(typeof schema.factTable.$inferSelect)[]>( + 'unusedFacts', + facts, + ); + return facts[Math.floor(Math.random() * facts.length)]; + } catch (error) { + console.error('Error getting random fact:', error); + throw new DatabaseError('Failed to get random fact:', error as Error); + } +} + +export async function markFactAsUsed(id: number) { + try { + await db + .update(schema.factTable) + .set({ usedOn: new Date() }) + .where(eq(schema.factTable.id, id)); + + await del('unusedFacts'); + } catch (error) { + console.error('Error marking fact as used:', error); + throw new DatabaseError('Failed to mark fact as used:', error as Error); + } +} + +export async function getPendingFacts() { + try { + return await db + .select() + .from(schema.factTable) + .where(eq(schema.factTable.approved, false)); + } catch (error) { + console.error('Error getting pending facts:', error); + throw new DatabaseError('Failed to get pending facts:', error as Error); + } +} + +export async function approveFact(id: number) { + try { + await db + .update(schema.factTable) + .set({ approved: true }) + .where(eq(schema.factTable.id, id)); + + await del('unusedFacts'); + } catch (error) { + console.error('Error approving fact:', error); + throw new DatabaseError('Failed to approve fact:', error as Error); + } +} + +export async function deleteFact(id: number) { + try { + await db.delete(schema.factTable).where(eq(schema.factTable.id, id)); + + await del('unusedFacts'); + } catch (error) { + console.error('Error deleting fact:', error); + throw new DatabaseError('Failed to delete fact:', error as Error); + } +} diff --git a/src/db/redis.ts b/src/db/redis.ts index 8938d17..e513072 100644 --- a/src/db/redis.ts +++ b/src/db/redis.ts @@ -14,7 +14,7 @@ class RedisError extends Error { } } -redis.on('error', (error) => { +redis.on('error', (error: Error) => { console.error('Redis connection error:', error); throw new RedisError('Failed to connect to Redis instance: ', error); }); diff --git a/src/db/schema.ts b/src/db/schema.ts index cbf6782..fd6ddaa 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -25,6 +25,24 @@ export const memberTable = pgTable('members', { currentlyMuted: boolean('currently_muted').notNull().default(false), }); +export interface levelTableTypes { + id?: number; + discordId: string; + xp: number; + level: number; + lastMessageTimestamp?: Date; +} + +export const levelTable = pgTable('levels', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('discord_id') + .notNull() + .references(() => memberTable.discordId, { onDelete: 'cascade' }), + xp: integer('xp').notNull().default(0), + level: integer('level').notNull().default(0), + lastMessageTimestamp: timestamp('last_message_timestamp'), +}); + export interface moderationTableTypes { id?: number; discordId: string; @@ -51,8 +69,19 @@ export const moderationTable = pgTable('moderations', { active: boolean('active').notNull().default(true), }); -export const memberRelations = relations(memberTable, ({ many }) => ({ +export const memberRelations = relations(memberTable, ({ many, one }) => ({ moderations: many(moderationTable), + levels: one(levelTable, { + fields: [memberTable.discordId], + references: [levelTable.discordId], + }), +})); + +export const levelRelations = relations(levelTable, ({ one }) => ({ + member: one(memberTable, { + fields: [levelTable.discordId], + references: [memberTable.discordId], + }), })); export const moderationRelations = relations(moderationTable, ({ one }) => ({ @@ -61,3 +90,23 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({ references: [memberTable.discordId], }), })); + +export type factTableTypes = { + id?: number; + content: string; + source?: string; + addedBy: string; + addedAt?: Date; + approved?: boolean; + usedOn?: Date; +}; + +export const factTable = pgTable('facts', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + content: varchar('content').notNull(), + source: varchar('source'), + addedBy: varchar('added_by').notNull(), + addedAt: timestamp('added_at').defaultNow().notNull(), + approved: boolean('approved').default(false).notNull(), + usedOn: timestamp('used_on'), +}); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 328b10f..10155dc 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -2,39 +2,99 @@ import { Events, Interaction } from 'discord.js'; import { ExtendedClient } from '../structures/ExtendedClient.js'; import { Event } from '../types/EventTypes.js'; +import { approveFact, deleteFact } from '../db/db.js'; export default { name: Events.InteractionCreate, execute: async (interaction: Interaction) => { - if (!interaction.isCommand()) return; + if (interaction.isCommand()) { + const client = interaction.client as ExtendedClient; + const command = client.commands.get(interaction.commandName); - const client = interaction.client as ExtendedClient; - const command = client.commands.get(interaction.commandName); + if (!command) { + console.error( + `No command matching ${interaction.commandName} was found.`, + ); + return; + } - if (!command) { - console.error( - `No command matching ${interaction.commandName} was found.`, - ); - return; - } + try { + await command.execute(interaction); + } catch (error: any) { + console.error(`Error executing ${interaction.commandName}`); + console.error(error); - try { - await command.execute(interaction); - } catch (error) { - console.error(`Error executing ${interaction.commandName}`); - console.error(error); + const isUnknownInteractionError = + error.code === 10062 || + (error.message && error.message.includes('Unknown interaction')); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], + if (!isUnknownInteractionError) { + try { + if (interaction.replied || interaction.deferred) { + await interaction + .followUp({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }) + .catch((e) => + console.error('Failed to send error followup:', e), + ); + } else { + await interaction + .reply({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }) + .catch((e) => console.error('Failed to send error reply:', e)); + } + } catch (replyError) { + console.error('Failed to respond with error message:', replyError); + } + } else { + console.warn( + 'Interaction expired before response could be sent (code 10062)', + ); + } + } + } else if (interaction.isButton()) { + const { customId } = interaction; + + if (customId.startsWith('approve_fact_')) { + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to approve facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace('approve_fact_', ''), 10); + await approveFact(factId); + + await interaction.update({ + content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`, + components: [], }); - } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], + } else if (customId.startsWith('reject_fact_')) { + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to reject facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace('reject_fact_', ''), 10); + await deleteFact(factId); + + await interaction.update({ + content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`, + components: [], }); } + } else { + console.warn('Unhandled interaction type:', interaction); + return; } }, } as Event; diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 8bd0f33..24b7a66 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -1,7 +1,17 @@ import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js'; import { Event } from '../types/EventTypes.js'; +import { loadConfig } from '../util/configLoader.js'; +import { + addCountingReactions, + processCountingMessage, + resetCounting, +} from '../util/countingManager.js'; import logAction from '../util/logging/logAction.js'; +import { + checkAndAssignLevelRoles, + processMessage, +} from '../util/levelingSystem.js'; export const messageDelete: Event = { name: Events.MessageDelete, @@ -62,4 +72,87 @@ 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 || !advancementsChannel.isTextBased()) { + console.error( + 'Advancements channel not found or is not a text channel', + ); + return; + } + + if (levelResult?.leveledUp) { + await advancementsChannel.send( + `🎉 Congratulations <@${message.author.id}>! You've leveled up to **Level ${levelResult.newLevel}**!`, + ); + + const assignedRole = await checkAndAssignLevelRoles( + message.guild, + message.author.id, + levelResult.newLevel, + ); + + if (assignedRole) { + await advancementsChannel.send( + `<@${message.author.id}> You've earned the <@&${assignedRole}> role!`, + ); + } + } + + const countingChannelId = loadConfig().channels.counting; + const countingChannel = + 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/ready.ts b/src/events/ready.ts index 2430295..b7c54ce 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -3,6 +3,7 @@ import { Client, Events } from 'discord.js'; import { setMembers } from '../db/db.js'; import { loadConfig } from '../util/configLoader.js'; import { Event } from '../types/EventTypes.js'; +import { scheduleFactOfTheDay } from '../util/factManager.js'; export default { name: Events.ClientReady, @@ -21,6 +22,8 @@ export default { const members = await guild.members.fetch(); const nonBotMembers = members.filter((m) => !m.user.bot); await setMembers(nonBotMembers); + + await scheduleFactOfTheDay(client); } catch (error) { console.error('Failed to initialize members in database:', error); } diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts index 30d175c..406f3f7 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -2,6 +2,7 @@ import { CommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, + SlashCommandSubcommandsOnlyBuilder, } from 'discord.js'; export interface Command { @@ -13,3 +14,8 @@ export interface OptionsCommand { data: SlashCommandOptionsOnlyBuilder; execute: (interaction: CommandInteraction) => Promise; } + +export interface SubcommandCommand { + data: SlashCommandSubcommandsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index e92ae94..96ed2dd 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -7,8 +7,21 @@ export interface Config { 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; }; } diff --git a/src/util/countingManager.ts b/src/util/countingManager.ts new file mode 100644 index 0000000..f910690 --- /dev/null +++ b/src/util/countingManager.ts @@ -0,0 +1,157 @@ +import { Message } from 'discord.js'; + +import { getJson, setJson } from '../db/redis.js'; + +interface CountingData { + currentCount: number; + lastUserId: string | null; + highestCount: number; + totalCorrect: number; +} + +const MILESTONE_REACTIONS = { + normal: '✅', + multiples25: '✨', + multiples50: '⭐', + multiples100: '🎉', +}; + +export async function initializeCountingData(): Promise { + const exists = await getJson('counting'); + if (exists) return exists; + + const initialData: CountingData = { + currentCount: 0, + lastUserId: null, + highestCount: 0, + totalCorrect: 0, + }; + + await setJson('counting', initialData); + return initialData; +} + +export async function getCountingData(): Promise { + const data = await getJson('counting'); + if (!data) { + return initializeCountingData(); + } + return data; +} + +export async function updateCountingData( + data: Partial, +): Promise { + const currentData = await getCountingData(); + const updatedData = { ...currentData, ...data }; + await setJson('counting', updatedData); +} + +export async function resetCounting(): Promise { + await updateCountingData({ + currentCount: 0, + lastUserId: null, + }); + return; +} + +export async function processCountingMessage(message: Message): Promise<{ + isValid: boolean; + expectedCount?: number; + isMilestone?: boolean; + milestoneType?: keyof typeof MILESTONE_REACTIONS; + reason?: string; +}> { + try { + const countingData = await getCountingData(); + + const content = message.content.trim(); + const count = Number(content); + + if (isNaN(count) || !Number.isInteger(count)) { + return { + isValid: false, + expectedCount: countingData.currentCount + 1, + reason: 'not_a_number', + }; + } + + const expectedCount = countingData.currentCount + 1; + if (count !== expectedCount) { + return { + isValid: false, + expectedCount, + reason: count > expectedCount ? 'too_high' : 'too_low', + }; + } + + if (countingData.lastUserId === message.author.id) { + return { isValid: false, expectedCount, reason: 'same_user' }; + } + + const newCount = countingData.currentCount + 1; + const newHighestCount = Math.max(newCount, countingData.highestCount); + + await updateCountingData({ + currentCount: newCount, + lastUserId: message.author.id, + highestCount: newHighestCount, + totalCorrect: countingData.totalCorrect + 1, + }); + + let isMilestone = false; + let milestoneType: keyof typeof MILESTONE_REACTIONS = 'normal'; + + if (newCount % 100 === 0) { + isMilestone = true; + milestoneType = 'multiples100'; + } else if (newCount % 50 === 0) { + isMilestone = true; + milestoneType = 'multiples50'; + } else if (newCount % 25 === 0) { + isMilestone = true; + milestoneType = 'multiples25'; + } + + return { + isValid: true, + expectedCount: newCount + 1, + isMilestone, + milestoneType, + }; + } catch (error) { + console.error('Error processing counting message:', error); + return { isValid: false, reason: 'error' }; + } +} + +export async function addCountingReactions( + message: Message, + milestoneType: keyof typeof MILESTONE_REACTIONS, +): Promise { + try { + await message.react(MILESTONE_REACTIONS[milestoneType]); + + if (milestoneType === 'multiples100') { + await message.react('💯'); + } + } catch (error) { + console.error('Error adding counting reactions:', error); + } +} + +export async function getCountingStatus(): Promise { + const data = await getCountingData(); + return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`; +} + +export async function setCount(count: number): Promise { + if (!Number.isInteger(count) || count < 0) { + throw new Error('Count must be a non-negative integer.'); + } + + await updateCountingData({ + currentCount: count, + lastUserId: null, + }); +} diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 9ce4580..98b6934 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,6 +1,6 @@ -import { REST, Routes } from 'discord.js'; import fs from 'fs'; import path from 'path'; +import { REST, Routes } from 'discord.js'; import { loadConfig } from './configLoader.js'; const config = loadConfig(); diff --git a/src/util/factManager.ts b/src/util/factManager.ts new file mode 100644 index 0000000..4663a18 --- /dev/null +++ b/src/util/factManager.ts @@ -0,0 +1,69 @@ +import { EmbedBuilder, Client } from 'discord.js'; + +import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js'; +import { loadConfig } from './configLoader.js'; + +export async function scheduleFactOfTheDay(client: Client) { + try { + const now = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(now.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const timeUntilMidnight = tomorrow.getTime() - now.getTime(); + + setTimeout(() => { + postFactOfTheDay(client); + scheduleFactOfTheDay(client); + }, timeUntilMidnight); + + console.log( + `Next fact of the day scheduled in ${Math.floor(timeUntilMidnight / 1000 / 60)} minutes`, + ); + } catch (error) { + console.error('Error scheduling fact of the day:', error); + setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000); + } +} + +export async function postFactOfTheDay(client: Client) { + try { + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + + if (!guild) { + console.error('Guild not found'); + return; + } + + const factChannel = guild.channels.cache.get(config.channels.factOfTheDay); + if (!factChannel?.isTextBased()) { + console.error('Fact channel not found or is not a text channel'); + return; + } + + const fact = await getRandomUnusedFact(); + if (!fact) { + console.error('No facts available'); + return; + } + + const embed = new EmbedBuilder() + .setTitle('🌟 Fact of the Day 🌟') + .setDescription(fact.content) + .setColor(0xffaa00) + .setTimestamp(); + + if (fact.source) { + embed.setFooter({ text: `Source: ${fact.source}` }); + } + + await factChannel.send({ + content: `<@&${config.roles.factPingRole}>`, + embeds: [embed], + }); + await markFactAsUsed(fact.id!); + } catch (error) { + console.error('Error posting fact of the day:', error); + } +} diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts new file mode 100644 index 0000000..d546250 --- /dev/null +++ b/src/util/levelingSystem.ts @@ -0,0 +1,249 @@ +import path from 'path'; +import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js'; +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; + +import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js'; +import * as schema from '../db/schema.js'; +import { loadConfig } from './configLoader.js'; + +const config = loadConfig(); + +const XP_COOLDOWN = 60 * 1000; +const MIN_XP = 15; +const MAX_XP = 25; + +const __dirname = path.resolve(); + +export const calculateXpForLevel = (level: number): number => { + if (level === 0) return 0; + return (5 / 6) * level * (2 * level * level + 27 * level + 91); +}; + +export const calculateLevelFromXp = (xp: number): number => { + if (xp < calculateXpForLevel(1)) return 0; + + let level = 0; + while (calculateXpForLevel(level + 1) <= xp) { + level++; + } + + return level; +}; + +export const getXpToNextLevel = (level: number, currentXp: number): number => { + if (level === 0) return calculateXpForLevel(1) - currentXp; + + const nextLevelXp = calculateXpForLevel(level + 1); + return nextLevelXp - currentXp; +}; + +export async function recalculateUserLevels() { + const users = await db.select().from(schema.levelTable); + + for (const user of users) { + await addXpToUser(user.discordId, 0); + } +} + +export async function processMessage(message: Message) { + if (message.author.bot || !message.guild) return; + + try { + const userId = message.author.id; + const userData = await getUserLevel(userId); + + if (userData.lastMessageTimestamp) { + const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime(); + const currentTime = Date.now(); + + if (currentTime - lastMessageTime < XP_COOLDOWN) { + return null; + } + } + + const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + const result = await addXpToUser(userId, xpToAdd); + + return result; + } catch (error) { + console.error('Error processing message for XP:', error); + return null; + } +} + +function roundRect( + ctx: Canvas.SKRSContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, + fill: boolean, +) { + if (typeof radius === 'undefined') { + radius = 5; + } + + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + + if (fill) { + ctx.fill(); + } else { + ctx.stroke(); + } +} + +export async function generateRankCard( + member: GuildMember, + userData: schema.levelTableTypes, +) { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const userRank = await getUserRank(userData.discordId); + + const canvas = Canvas.createCanvas(934, 282); + const context = canvas.getContext('2d'); + + context.fillStyle = '#23272A'; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.fillStyle = '#2C2F33'; + roundRect(context, 22, 22, 890, 238, 20, true); + + try { + const avatar = await Canvas.loadImage( + member.user.displayAvatarURL({ extension: 'png', size: 256 }), + ); + context.save(); + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.closePath(); + context.clip(); + context.drawImage(avatar, 40, 61, 160, 160); + context.restore(); + } catch (error) { + console.error('Error loading avatar image:', error); + context.fillStyle = '#5865F2'; + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.fill(); + } + + context.font = '38px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(member.user.username, 242, 142); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.textAlign = 'end'; + context.fillText(`LEVEL ${userData.level}`, 890, 82); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(`RANK #${userRank}`, 890, 122); + + const barWidth = 615; + const barHeight = 38; + const barX = 242; + const barY = 182; + + const currentLevel = userData.level; + const currentLevelXp = calculateXpForLevel(currentLevel); + const nextLevelXp = calculateXpForLevel(currentLevel + 1); + + const xpNeededForNextLevel = nextLevelXp - currentLevelXp; + + let xpIntoCurrentLevel; + if (currentLevel === 0) { + xpIntoCurrentLevel = userData.xp; + } else { + xpIntoCurrentLevel = userData.xp - currentLevelXp; + } + + const progress = Math.max( + 0, + Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1), + ); + + context.fillStyle = '#484b4E'; + roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true); + + if (progress > 0) { + context.fillStyle = '#5865F2'; + roundRect( + context, + barX, + barY, + barWidth * progress, + barHeight, + barHeight / 2, + true, + ); + } + + context.textAlign = 'center'; + context.font = '20px "Manrope"'; + context.fillStyle = '#A0A0A0'; + context.fillText( + `${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`, + barX + barWidth / 2, + barY + barHeight / 2 + 7, + ); + + return new AttachmentBuilder(canvas.toBuffer('image/png'), { + name: 'rank-card.png', + }); +} + +export async function checkAndAssignLevelRoles( + guild: Guild, + userId: string, + newLevel: number, +) { + try { + if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) { + return; + } + + const member = await guild.members.fetch(userId); + if (!member) return; + + const rolesToAdd = config.roles.levelRoles + .filter((role) => role.level <= newLevel) + .map((role) => role.roleId); + + if (rolesToAdd.length === 0) return; + + const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId); + const rolesToRemove = member.roles.cache.filter((role) => + existingLevelRoles.includes(role.id), + ); + if (rolesToRemove.size > 0) { + await member.roles.remove(rolesToRemove); + } + + const highestRole = rolesToAdd[rolesToAdd.length - 1]; + await member.roles.add(highestRole); + + return highestRole; + } catch (error) { + console.error('Error assigning level roles:', error); + } +} diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts index 7becbe5..5a0affe 100644 --- a/src/util/logging/logAction.ts +++ b/src/util/logging/logAction.ts @@ -5,6 +5,7 @@ import { ActionRowBuilder, GuildChannel, } from 'discord.js'; + import { LogActionPayload, ModerationLogAction, @@ -22,10 +23,12 @@ import { getPermissionDifference, getPermissionNames, } from './utils.js'; +import { loadConfig } from '../configLoader.js'; export default async function logAction(payload: LogActionPayload) { - const logChannel = payload.guild.channels.cache.get('1007787977432383611'); - if (!logChannel || !(logChannel instanceof TextChannel)) { + const config = loadConfig(); + const logChannel = payload.guild.channels.cache.get(config.channels.logs); + if (!logChannel?.isTextBased()) { console.error('Log channel not found or is not a Text Channel.'); return; }