diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 01ce41d..31791dc 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,2 @@ github: ahmadk953 -patreon: poixpixel thanks_dev: u/gh/ahmadk953 diff --git a/src/commands/fun/achievement.ts b/src/commands/fun/achievement.ts index c1ef61b..5fea0f2 100644 --- a/src/commands/fun/achievement.ts +++ b/src/commands/fun/achievement.ts @@ -26,7 +26,6 @@ const command = { data: new SlashCommandBuilder() .setName('achievement') .setDescription('Manage server achievements') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((subcommand) => subcommand .setName('create') @@ -185,6 +184,13 @@ async function handleCreateAchievement( const rewardType = interaction.options.getString('reward_type'); const rewardValue = interaction.options.getString('reward_value'); + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + await interaction.editReply( + 'You do not have permission to create achievements.', + ); + return; + } + if (requirementType === 'command_usage' && !commandName) { await interaction.editReply( 'Command name is required for command_usage type achievements.', @@ -252,6 +258,13 @@ async function handleDeleteAchievement( ) { const achievementId = interaction.options.getInteger('id')!; + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + await interaction.editReply( + 'You do not have permission to delete achievements.', + ); + return; + } + try { const success = await deleteAchievement(achievementId); @@ -278,6 +291,13 @@ async function handleAwardAchievement( const user = interaction.options.getUser('user')!; const achievementId = interaction.options.getInteger('achievement_id')!; + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + await interaction.editReply( + 'You do not have permission to award achievements.', + ); + return; + } + try { const allAchievements = await getAllAchievements(); const achievement = allAchievements.find((a) => a.id === achievementId); @@ -659,6 +679,13 @@ async function handleUnawardAchievement( const user = interaction.options.getUser('user')!; const achievementId = interaction.options.getInteger('achievement_id')!; + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + await interaction.editReply( + 'You do not have permission to unaward achievements.', + ); + return; + } + try { const allAchievements = await getAllAchievements(); const achievement = allAchievements.find((a) => a.id === achievementId); diff --git a/src/db/functions/achievementFunctions.ts b/src/db/functions/achievementFunctions.ts index 30cbc3b..7d3ee6a 100644 --- a/src/db/functions/achievementFunctions.ts +++ b/src/db/functions/achievementFunctions.ts @@ -129,7 +129,6 @@ export async function updateAchievementProgress( ): Promise { try { await ensureDbInitialized(); - if (!db) { console.error( 'Database not initialized, cannot update achievement progress', @@ -149,21 +148,15 @@ export async function updateAchievementProgress( .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), - }) + .set({ 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), + achievementId, + progress, }); } diff --git a/src/util/achievementManager.ts b/src/util/achievementManager.ts index adb78d9..aaf3a05 100644 --- a/src/util/achievementManager.ts +++ b/src/util/achievementManager.ts @@ -1,11 +1,4 @@ -import { - Message, - Client, - EmbedBuilder, - GuildMember, - TextChannel, - Guild, -} from 'discord.js'; +import { Message, EmbedBuilder, TextChannel, Guild } from 'discord.js'; import { addXpToUser, @@ -21,99 +14,63 @@ 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 + * Handle achievement progress updates + * @param userId - ID of the user + * @param guild - Guild instance (can be null if not applicable) + * @param achievement - Achievement definition + * @param progress - Progress percentage (0-100) + * @param options - Additional options */ -export async function processMessageAchievements( - message: Message, +async function handleProgress( + userId: string, + guild: Guild | null, + achievement: schema.achievementDefinitionsTableTypes, + progress: number, + options: { skipAward?: boolean } = {}, ): 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', + const { skipAward = false } = options; + const userAchievements = await getUserAchievements(userId); + const existing = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, ); - for (const achievement of messageAchievements) { - const progress = Math.min( - 100, - (userData.messagesSent / achievement.threshold) * 100, - ); + await updateAchievementProgress(userId, achievement.id, progress); - 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, - ); + if (progress === 100 && !existing && !skipAward) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded && guild) { + await announceAchievement(guild, userId, achievement); } } } /** - * Check achievements for level-ups - * @param memberId - Member ID who leveled up - * @param newLevel - New level value - * @guild - Guild instance + * Process message achievements based on user activity + * @param message - The message object from Discord + */ +export async function processMessageAchievements( + message: Message, +): Promise { + if (message.author.bot) return; + const userData = await getUserLevel(message.author.id); + const allAchievements = await getAllAchievements(); + + for (const ach of allAchievements.filter( + (a) => a.requirementType === 'message_count', + )) { + const progress = Math.min( + 100, + (userData.messagesSent / ach.threshold) * 100, + ); + await handleProgress(message.author.id, message.guild!, ach, progress); + } +} + +/** + * Process level-up achievements when a user levels up + * @param memberId - ID of the member who leveled up + * @param newLevel - The new level the member has reached + * @param guild - Guild instance where the member belongs */ export async function processLevelUpAchievements( memberId: string, @@ -121,37 +78,19 @@ export async function processLevelUpAchievements( guild: Guild, ): Promise { const allAchievements = await getAllAchievements(); - - const levelAchievements = allAchievements.filter( + for (const ach of 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); - } + )) { + const progress = Math.min(100, (newLevel / ach.threshold) * 100); + await handleProgress(memberId, guild, ach, progress); } } /** - * Process achievements for command usage - * @param userId - User ID who used the command - * @param commandName - Name of the command - * @param client - Guild instance + * Process command usage achievements when a command is invoked + * @param userId - ID of the user who invoked the command + * @param commandName - Name of the command invoked + * @param guild - Guild instance where the command was invoked */ export async function processCommandAchievements( userId: string, @@ -159,7 +98,6 @@ export async function processCommandAchievements( guild: Guild, ): Promise { const allAchievements = await getAllAchievements(); - const commandAchievements = allAchievements.filter( (a) => a.requirementType === 'command_usage' && @@ -167,26 +105,31 @@ export async function processCommandAchievements( (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, - ); + // fetch the user’s current achievement entries + const userAchievements = await getUserAchievements(userId); - if (!existingAchievement) { - const awarded = await awardAchievement(userId, achievement.id); - if (awarded) { - await announceAchievement(guild, userId, achievement); - } - } + for (const ach of commandAchievements) { + // find existing progress, default to 0 + const userAch = userAchievements.find((u) => u.achievementId === ach.id); + const oldProgress = userAch?.progress ?? 0; + + // compute how many times they've run this command so far + const timesRanSoFar = (oldProgress / 100) * ach.threshold; + const newCount = timesRanSoFar + 1; + + // convert back into a percentage + const newProgress = Math.min(100, (newCount / ach.threshold) * 100); + + // Delegate to handleProgress which will update or award + await handleProgress(userId, guild, ach, newProgress); } } /** - * 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) + * Process reaction achievements when a user reacts to a message + * @param userId - ID of the user who reacted + * @param guild - Guild instance where the reaction occurred + * @param isRemoval - Whether the reaction was removed (default: false) */ export async function processReactionAchievements( userId: string, @@ -198,40 +141,21 @@ export async function processReactionAchievements( if (member.user.bot) return; const allAchievements = await getAllAchievements(); - const reactionAchievements = allAchievements.filter( (a) => a.requirementType === 'reactions', ); - - if (reactionAchievements.length === 0) return; + if (!reactionAchievements.length) return; const reactionCount = await getUserReactionCount(userId); - for (const achievement of reactionAchievements) { + for (const ach of reactionAchievements) { const progress = Math.max( 0, - Math.min(100, (reactionCount / achievement.threshold) * 100), + Math.min(100, (reactionCount / ach.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); + await handleProgress(userId, guild, ach, progress, { + skipAward: isRemoval, + }); } } catch (error) { console.error('Error processing reaction achievements:', error);