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 bc1ce6f..cecb413 100644 --- a/config.example.json +++ b/config.example.json @@ -9,12 +9,41 @@ "logs": "LOG_CHANNEL_ID", "counting": "COUNTING_CHANNEL_ID", "factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID", - "factApproval": "FACT_APPROVAL_CHANNEL_ID" + "factApproval": "FACT_APPROVAL_CHANNEL_ID", + "advancements": "ADVANCEMENTS_CHANNEL_ID" }, "roles": { "joinRoles": [ "JOIN_ROLE_IDS" ], + "levelRoles": [ + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + } + ], + "staffRoles": [ + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + } + ], "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" } } diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts index 4b13186..b0b4c93 100644 --- a/src/commands/fun/fact.ts +++ b/src/commands/fun/fact.ts @@ -74,6 +74,12 @@ const command: SubcommandCommand = { execute: async (interaction) => { if (!interaction.isChatInputCommand()) return; + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + await interaction.editReply('Processing...'); + const config = loadConfig(); const subcommand = interaction.options.getSubcommand(); @@ -112,13 +118,15 @@ const command: SubcommandCommand = { ) .setTimestamp(); + const factId = await getLastInsertedFactId(); + const approveButton = new ButtonBuilder() - .setCustomId(`approve_fact_${await getLastInsertedFactId()}`) + .setCustomId(`approve_fact_${factId}`) .setLabel('Approve') .setStyle(ButtonStyle.Success); const rejectButton = new ButtonBuilder() - .setCustomId(`reject_fact_${await getLastInsertedFactId()}`) + .setCustomId(`reject_fact_${factId}`) .setLabel('Reject') .setStyle(ButtonStyle.Danger); @@ -136,11 +144,10 @@ const command: SubcommandCommand = { } } - await interaction.reply({ + await interaction.editReply({ content: isAdmin ? 'Your fact has been automatically approved and added to the database!' : 'Your fact has been submitted for approval!', - flags: ['Ephemeral'], }); } else if (subcommand === 'approve') { if ( @@ -148,9 +155,8 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to approve facts.', - flags: ['Ephemeral'], }); return; } @@ -158,9 +164,8 @@ const command: SubcommandCommand = { const id = interaction.options.getInteger('id', true); await approveFact(id); - await interaction.reply({ + await interaction.editReply({ content: `Fact #${id} has been approved!`, - flags: ['Ephemeral'], }); } else if (subcommand === 'delete') { if ( @@ -168,9 +173,8 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to delete facts.', - flags: ['Ephemeral'], }); return; } @@ -178,9 +182,8 @@ const command: SubcommandCommand = { const id = interaction.options.getInteger('id', true); await deleteFact(id); - await interaction.reply({ + await interaction.editReply({ content: `Fact #${id} has been deleted!`, - flags: ['Ephemeral'], }); } else if (subcommand === 'pending') { if ( @@ -188,9 +191,8 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to view pending facts.', - flags: ['Ephemeral'], }); return; } @@ -198,9 +200,8 @@ const command: SubcommandCommand = { const pendingFacts = await getPendingFacts(); if (pendingFacts.length === 0) { - await interaction.reply({ + await interaction.editReply({ content: 'There are no pending facts.', - flags: ['Ephemeral'], }); return; } @@ -217,9 +218,8 @@ const command: SubcommandCommand = { ) .setTimestamp(); - await interaction.reply({ + await interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'], }); } else if (subcommand === 'post') { if ( @@ -227,18 +227,16 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to manually post facts.', - flags: ['Ephemeral'], }); return; } await postFactOfTheDay(interaction.client); - await interaction.reply({ + await interaction.editReply({ content: 'Fact of the day has been posted!', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/fun/leaderboard.ts b/src/commands/fun/leaderboard.ts new file mode 100644 index 0000000..0d37fef --- /dev/null +++ b/src/commands/fun/leaderboard.ts @@ -0,0 +1,171 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ButtonBuilder, + ActionRowBuilder, + ButtonStyle, + StringSelectMenuBuilder, + APIEmbed, + JSONEncodable, +} from 'discord.js'; + +import { OptionsCommand } from '../../types/CommandTypes.js'; +import { getLevelLeaderboard } from '../../db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('leaderboard') + .setDescription('Shows the server XP leaderboard') + .addIntegerOption((option) => + option + .setName('limit') + .setDescription('Number of users per page (default: 10)') + .setRequired(false), + ), + execute: async (interaction) => { + if (!interaction.guild) return; + + await interaction.deferReply(); + + try { + const usersPerPage = + (interaction.options.get('limit')?.value as number) || 10; + + const allUsers = await getLevelLeaderboard(100); + + if (allUsers.length === 0) { + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription('No users found on the leaderboard yet.') + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } + + const pages: (APIEmbed | JSONEncodable)[] = []; + + for (let i = 0; i < allUsers.length; i += usersPerPage) { + const pageUsers = allUsers.slice(i, i + usersPerPage); + let leaderboardText = ''; + + for (let j = 0; j < pageUsers.length; j++) { + const user = pageUsers[j]; + const position = i + j + 1; + + try { + const member = await interaction.guild.members.fetch( + user.discordId, + ); + leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`; + } catch (error) { + leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`; + } + } + + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription(leaderboardText) + .setTimestamp() + .setFooter({ + text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`, + }); + + pages.push(embed); + } + + let currentPage = 0; + + const getButtonActionRow = () => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('previous') + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('next') + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === pages.length - 1), + ); + + const getSelectMenuRow = () => { + const options = pages.map((_, index) => ({ + label: `Page ${index + 1}`, + value: index.toString(), + default: index === currentPage, + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('select_page') + .setPlaceholder('Jump to a page') + .addOptions(options); + + return new ActionRowBuilder().addComponents( + select, + ); + }; + + const components = + pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components, + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + if (i.customId === 'previous' && currentPage > 0) { + currentPage--; + } else if (i.customId === 'next' && currentPage < pages.length - 1) { + currentPage++; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [getButtonActionRow(), getSelectMenuRow()], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } + }); + } catch (error) { + console.error('Error getting leaderboard:', error); + await interaction.editReply('Failed to get leaderboard information.'); + } + }, +}; + +export default command; diff --git a/src/commands/fun/rank.ts b/src/commands/fun/rank.ts new file mode 100644 index 0000000..0007d05 --- /dev/null +++ b/src/commands/fun/rank.ts @@ -0,0 +1,49 @@ +import { GuildMember, SlashCommandBuilder } from 'discord.js'; + +import { OptionsCommand } from '../../types/CommandTypes.js'; +import { + generateRankCard, + getXpToNextLevel, +} from '../../util/levelingSystem.js'; +import { getUserLevel } from '../../db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('rank') + .setDescription('Shows your current rank and level') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to check rank for (defaults to yourself)') + .setRequired(false), + ), + execute: async (interaction) => { + const member = await interaction.guild?.members.fetch( + (interaction.options.get('user')?.value as string) || interaction.user.id, + ); + + if (!member) { + await interaction.reply('User not found in this server.'); + return; + } + + await interaction.deferReply(); + + try { + const userData = await getUserLevel(member.id); + const rankCard = await generateRankCard(member, userData); + + const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp); + + await interaction.editReply({ + content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`, + files: [rankCard], + }); + } catch (error) { + console.error('Error getting rank:', error); + await interaction.editReply('Failed to get rank information.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/recalculatelevels.ts b/src/commands/util/recalculatelevels.ts new file mode 100644 index 0000000..0667d1a --- /dev/null +++ b/src/commands/util/recalculatelevels.ts @@ -0,0 +1,36 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { Command } from '../../types/CommandTypes.js'; +import { recalculateUserLevels } from '../../util/levelingSystem.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('recalculatelevels') + .setDescription('(Admin Only) Recalculate all user levels'), + execute: async (interaction) => { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to use this command.', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.deferReply(); + await interaction.editReply('Recalculating levels...'); + + try { + await recalculateUserLevels(); + await interaction.editReply('Levels recalculated successfully!'); + } catch (error) { + console.error('Error recalculating levels:', error); + await interaction.editReply('Failed to recalculate levels.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/xp.ts b/src/commands/util/xp.ts new file mode 100644 index 0000000..cac94e8 --- /dev/null +++ b/src/commands/util/xp.ts @@ -0,0 +1,132 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import { SubcommandCommand } from '../../types/CommandTypes.js'; +import { addXpToUser, getUserLevel } from '../../db/db.js'; +import { loadConfig } from '../../util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('xp') + .setDescription('(Manager only) Manage user XP') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add XP to a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to add XP to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to add') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove XP from a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to remove XP from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to remove') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to set XP for') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to set') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('reset') + .setDescription('Reset XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to reset XP for') + .setRequired(true), + ), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const commandUser = interaction.guild?.members.cache.get( + interaction.user.id, + ); + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + if ( + !commandUser || + !managerRoleId || + commandUser.roles.highest.comparePositionTo(managerRoleId) < 0 + ) { + await interaction.reply({ + content: 'You do not have permission to use this command', + flags: ['Ephemeral'], + }); + return; + } + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + await interaction.editReply('Processing...'); + + const subcommand = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + const amount = interaction.options.getInteger('amount', false); + + const userData = await getUserLevel(user.id); + + if (subcommand === 'add') { + await addXpToUser(user.id, amount!); + await interaction.editReply({ + content: `Added ${amount} XP to <@${user.id}>`, + }); + } else if (subcommand === 'remove') { + await addXpToUser(user.id, -amount!); + await interaction.editReply({ + content: `Removed ${amount} XP from <@${user.id}>`, + }); + } else if (subcommand === 'set') { + await addXpToUser(user.id, amount! - userData.xp); + await interaction.editReply({ + content: `Set ${amount} XP for <@${user.id}>`, + }); + } else if (subcommand === 'reset') { + await addXpToUser(user.id, userData.xp * -1); + await interaction.editReply({ + content: `Reset XP for <@${user.id}>`, + }); + } + }, +}; + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index 6a35c84..b47d689 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,10 +1,11 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { and, eq, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; +import { calculateLevelFromXp } from '../util/levelingSystem.js'; const { Pool } = pkg; const config = loadConfig(); @@ -162,6 +163,179 @@ export async function updateMember({ } } +export async function getUserLevel( + discordId: string, +): Promise { + try { + if (await exists(`level-${discordId}`)) { + const cachedLevel = await getJson( + `level-${discordId}`, + ); + if (cachedLevel !== null) { + return cachedLevel; + } + await del(`level-${discordId}`); + } + + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); + + if (level) { + const typedLevel: schema.levelTableTypes = { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; + await setJson(`level-${discordId}`, typedLevel); + return typedLevel; + } + + const newLevel = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + }; + + await db.insert(schema.levelTable).values(newLevel); + await setJson(`level-${discordId}`, newLevel); + return newLevel; + } catch (error) { + console.error('Error getting user level:', error); + throw error; + } +} + +export async function addXpToUser(discordId: string, amount: number) { + try { + const userData = await getUserLevel(discordId); + const currentLevel = userData.level; + + userData.xp += amount; + userData.lastMessageTimestamp = new Date(); + + const newLevel = calculateLevelFromXp(userData.xp); + userData.level = newLevel; + + await db + .update(schema.levelTable) + .set({ + xp: userData.xp, + level: newLevel, + lastMessageTimestamp: userData.lastMessageTimestamp, + }) + .where(eq(schema.levelTable.discordId, discordId)); + + await setJson(`level-${discordId}`, userData); + + await invalidateLeaderboardCache(); + + return { + leveledUp: newLevel > currentLevel, + newLevel, + oldLevel: currentLevel, + }; + } catch (error) { + console.error('Error adding XP to user:', error); + throw error; + } +} + +export async function getUserRank(discordId: string): Promise { + try { + if (await exists('xp-leaderboard-cache')) { + const leaderboardCache = await getJson< + Array<{ discordId: string; xp: number }> + >('xp-leaderboard-cache'); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; + } + } + } + + const allMembers = await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + + await setJson('xp-leaderboard-cache', allMembers, 300); + + const userIndex = allMembers.findIndex( + (member) => member.discordId === discordId, + ); + + return userIndex !== -1 ? userIndex + 1 : 1; + } catch (error) { + console.error('Error getting user rank:', error); + return 1; + } +} + +export async function invalidateLeaderboardCache() { + try { + if (await exists('xp-leaderboard-cache')) { + await del('xp-leaderboard-cache'); + } + } catch (error) { + console.error('Error invalidating leaderboard cache:', error); + } +} + +export async function getLevelLeaderboard(limit = 10) { + try { + if (await exists('xp-leaderboard-cache')) { + const leaderboardCache = await getJson< + Array<{ discordId: string; xp: number }> + >('xp-leaderboard-cache'); + + if (leaderboardCache) { + const limitedCache = leaderboardCache.slice(0, limit); + + const fullLeaderboard = await Promise.all( + limitedCache.map(async (entry) => { + const userData = await getUserLevel(entry.discordId); + return userData; + }), + ); + + return fullLeaderboard; + } + } + + const leaderboard = await db + .select() + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)) + .limit(limit); + + const allMembers = await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + + await setJson('xp-leaderboard-cache', allMembers, 300); + + return leaderboard; + } catch (error) { + console.error('Error getting leaderboard:', error); + throw error; + } +} + export async function updateMemberModerationHistory({ discordId, moderatorDiscordId, diff --git a/src/db/schema.ts b/src/db/schema.ts index 11fb6b6..d168cef 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -25,6 +25,24 @@ export const memberTable = pgTable('members', { currentlyMuted: boolean('currently_muted').notNull().default(false), }); +export interface levelTableTypes { + id?: number; + discordId: string; + xp: number; + level: number; + lastMessageTimestamp?: Date; +} + +export const levelTable = pgTable('levels', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('discord_id') + .notNull() + .references(() => memberTable.discordId, { onDelete: 'cascade' }), + xp: integer('xp').notNull().default(0), + level: integer('level').notNull().default(1), + lastMessageTimestamp: timestamp('last_message_timestamp'), +}); + export interface moderationTableTypes { id?: number; discordId: string; @@ -51,8 +69,19 @@ export const moderationTable = pgTable('moderations', { active: boolean('active').notNull().default(true), }); -export const memberRelations = relations(memberTable, ({ many }) => ({ +export const memberRelations = relations(memberTable, ({ many, one }) => ({ moderations: many(moderationTable), + levels: one(levelTable, { + fields: [memberTable.discordId], + references: [levelTable.discordId], + }), +})); + +export const levelRelations = relations(levelTable, ({ one }) => ({ + member: one(memberTable, { + fields: [levelTable.discordId], + references: [memberTable.discordId], + }), })); export const moderationRelations = relations(moderationTable, ({ one }) => ({ diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index cd3e796..10155dc 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -20,20 +20,40 @@ export default { try { await command.execute(interaction); - } catch (error) { + } catch (error: any) { console.error(`Error executing ${interaction.commandName}`); console.error(error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + const isUnknownInteractionError = + error.code === 10062 || + (error.message && error.message.includes('Unknown interaction')); + + if (!isUnknownInteractionError) { + try { + if (interaction.replied || interaction.deferred) { + await interaction + .followUp({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }) + .catch((e) => + console.error('Failed to send error followup:', e), + ); + } else { + await interaction + .reply({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }) + .catch((e) => console.error('Failed to send error reply:', e)); + } + } catch (replyError) { + console.error('Failed to respond with error message:', replyError); + } } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + console.warn( + 'Interaction expired before response could be sent (code 10062)', + ); } } } else if (interaction.isButton()) { @@ -73,7 +93,7 @@ export default { }); } } else { - console.log('Unhandled interaction type:', interaction); + console.warn('Unhandled interaction type:', interaction); return; } }, diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 94fe5c9..24b7a66 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -8,6 +8,10 @@ import { resetCounting, } from '../util/countingManager.js'; import logAction from '../util/logging/logAction.js'; +import { + checkAndAssignLevelRoles, + processMessage, +} from '../util/levelingSystem.js'; export const messageDelete: Event = { name: Events.MessageDelete, @@ -72,7 +76,38 @@ export const messageCreate: Event = { name: Events.MessageCreate, execute: async (message: Message) => { try { - if (message.author.bot) return; + if (message.author.bot || !message.guild) return; + + const levelResult = await processMessage(message); + const advancementsChannelId = loadConfig().channels.advancements; + const advancementsChannel = message.guild?.channels.cache.get( + advancementsChannelId, + ); + + if (!advancementsChannel || !advancementsChannel.isTextBased()) { + console.error( + 'Advancements channel not found or is not a text channel', + ); + return; + } + + if (levelResult?.leveledUp) { + await advancementsChannel.send( + `🎉 Congratulations <@${message.author.id}>! You've leveled up to **Level ${levelResult.newLevel}**!`, + ); + + const assignedRole = await checkAndAssignLevelRoles( + message.guild, + message.author.id, + levelResult.newLevel, + ); + + if (assignedRole) { + await advancementsChannel.send( + `<@${message.author.id}> You've earned the <@&${assignedRole}> role!`, + ); + } + } const countingChannelId = loadConfig().channels.counting; const countingChannel = @@ -115,7 +150,7 @@ export const messageCreate: Event = { await message.react('❌'); } } catch (error) { - console.error('Error handling message create:', error); + console.error('Error handling message create: ', error); } }, }; diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index 6cb16a2..96ed2dd 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -10,9 +10,18 @@ export interface Config { counting: string; factOfTheDay: string; factApproval: string; + advancements: string; }; roles: { joinRoles: string[]; + levelRoles: { + level: number; + roleId: string; + }[]; + staffRoles: { + name: string; + roleId: string; + }[]; factPingRole: string; }; } diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 9ce4580..98b6934 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,6 +1,6 @@ -import { REST, Routes } from 'discord.js'; import fs from 'fs'; import path from 'path'; +import { REST, Routes } from 'discord.js'; import { loadConfig } from './configLoader.js'; const config = loadConfig(); diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts new file mode 100644 index 0000000..d546250 --- /dev/null +++ b/src/util/levelingSystem.ts @@ -0,0 +1,249 @@ +import path from 'path'; +import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js'; +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; + +import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js'; +import * as schema from '../db/schema.js'; +import { loadConfig } from './configLoader.js'; + +const config = loadConfig(); + +const XP_COOLDOWN = 60 * 1000; +const MIN_XP = 15; +const MAX_XP = 25; + +const __dirname = path.resolve(); + +export const calculateXpForLevel = (level: number): number => { + if (level === 0) return 0; + return (5 / 6) * level * (2 * level * level + 27 * level + 91); +}; + +export const calculateLevelFromXp = (xp: number): number => { + if (xp < calculateXpForLevel(1)) return 0; + + let level = 0; + while (calculateXpForLevel(level + 1) <= xp) { + level++; + } + + return level; +}; + +export const getXpToNextLevel = (level: number, currentXp: number): number => { + if (level === 0) return calculateXpForLevel(1) - currentXp; + + const nextLevelXp = calculateXpForLevel(level + 1); + return nextLevelXp - currentXp; +}; + +export async function recalculateUserLevels() { + const users = await db.select().from(schema.levelTable); + + for (const user of users) { + await addXpToUser(user.discordId, 0); + } +} + +export async function processMessage(message: Message) { + if (message.author.bot || !message.guild) return; + + try { + const userId = message.author.id; + const userData = await getUserLevel(userId); + + if (userData.lastMessageTimestamp) { + const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime(); + const currentTime = Date.now(); + + if (currentTime - lastMessageTime < XP_COOLDOWN) { + return null; + } + } + + const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + const result = await addXpToUser(userId, xpToAdd); + + return result; + } catch (error) { + console.error('Error processing message for XP:', error); + return null; + } +} + +function roundRect( + ctx: Canvas.SKRSContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, + fill: boolean, +) { + if (typeof radius === 'undefined') { + radius = 5; + } + + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + + if (fill) { + ctx.fill(); + } else { + ctx.stroke(); + } +} + +export async function generateRankCard( + member: GuildMember, + userData: schema.levelTableTypes, +) { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const userRank = await getUserRank(userData.discordId); + + const canvas = Canvas.createCanvas(934, 282); + const context = canvas.getContext('2d'); + + context.fillStyle = '#23272A'; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.fillStyle = '#2C2F33'; + roundRect(context, 22, 22, 890, 238, 20, true); + + try { + const avatar = await Canvas.loadImage( + member.user.displayAvatarURL({ extension: 'png', size: 256 }), + ); + context.save(); + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.closePath(); + context.clip(); + context.drawImage(avatar, 40, 61, 160, 160); + context.restore(); + } catch (error) { + console.error('Error loading avatar image:', error); + context.fillStyle = '#5865F2'; + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.fill(); + } + + context.font = '38px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(member.user.username, 242, 142); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.textAlign = 'end'; + context.fillText(`LEVEL ${userData.level}`, 890, 82); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(`RANK #${userRank}`, 890, 122); + + const barWidth = 615; + const barHeight = 38; + const barX = 242; + const barY = 182; + + const currentLevel = userData.level; + const currentLevelXp = calculateXpForLevel(currentLevel); + const nextLevelXp = calculateXpForLevel(currentLevel + 1); + + const xpNeededForNextLevel = nextLevelXp - currentLevelXp; + + let xpIntoCurrentLevel; + if (currentLevel === 0) { + xpIntoCurrentLevel = userData.xp; + } else { + xpIntoCurrentLevel = userData.xp - currentLevelXp; + } + + const progress = Math.max( + 0, + Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1), + ); + + context.fillStyle = '#484b4E'; + roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true); + + if (progress > 0) { + context.fillStyle = '#5865F2'; + roundRect( + context, + barX, + barY, + barWidth * progress, + barHeight, + barHeight / 2, + true, + ); + } + + context.textAlign = 'center'; + context.font = '20px "Manrope"'; + context.fillStyle = '#A0A0A0'; + context.fillText( + `${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`, + barX + barWidth / 2, + barY + barHeight / 2 + 7, + ); + + return new AttachmentBuilder(canvas.toBuffer('image/png'), { + name: 'rank-card.png', + }); +} + +export async function checkAndAssignLevelRoles( + guild: Guild, + userId: string, + newLevel: number, +) { + try { + if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) { + return; + } + + const member = await guild.members.fetch(userId); + if (!member) return; + + const rolesToAdd = config.roles.levelRoles + .filter((role) => role.level <= newLevel) + .map((role) => role.roleId); + + if (rolesToAdd.length === 0) return; + + const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId); + const rolesToRemove = member.roles.cache.filter((role) => + existingLevelRoles.includes(role.id), + ); + if (rolesToRemove.size > 0) { + await member.roles.remove(rolesToRemove); + } + + const highestRole = rolesToAdd[rolesToAdd.length - 1]; + await member.roles.add(highestRole); + + return highestRole; + } catch (error) { + console.error('Error assigning level roles:', error); + } +}