fix(bot): achievement system fixes and improvements
Some checks are pending
Commitlint / Run commitlint scanning (push) Waiting to run
Docker Build and Push / pgbouncer (push) Waiting to run
ESLint / Run eslint scanning (push) Waiting to run
NodeJS Build / build (24.x) (push) Waiting to run

This commit is contained in:
Ahmad 2025-06-24 19:58:42 -04:00 committed by GitHub
commit 8d14616f39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 113 additions and 170 deletions

1
.github/FUNDING.yml vendored
View file

@ -1,3 +1,2 @@
github: ahmadk953 github: ahmadk953
patreon: poixpixel
thanks_dev: u/gh/ahmadk953 thanks_dev: u/gh/ahmadk953

View file

@ -26,7 +26,6 @@ const command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('achievement') .setName('achievement')
.setDescription('Manage server achievements') .setDescription('Manage server achievements')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((subcommand) => .addSubcommand((subcommand) =>
subcommand subcommand
.setName('create') .setName('create')
@ -185,6 +184,13 @@ async function handleCreateAchievement(
const rewardType = interaction.options.getString('reward_type'); const rewardType = interaction.options.getString('reward_type');
const rewardValue = interaction.options.getString('reward_value'); 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) { if (requirementType === 'command_usage' && !commandName) {
await interaction.editReply( await interaction.editReply(
'Command name is required for command_usage type achievements.', 'Command name is required for command_usage type achievements.',
@ -252,6 +258,13 @@ async function handleDeleteAchievement(
) { ) {
const achievementId = interaction.options.getInteger('id')!; 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 { try {
const success = await deleteAchievement(achievementId); const success = await deleteAchievement(achievementId);
@ -278,6 +291,13 @@ async function handleAwardAchievement(
const user = interaction.options.getUser('user')!; const user = interaction.options.getUser('user')!;
const achievementId = interaction.options.getInteger('achievement_id')!; 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 { try {
const allAchievements = await getAllAchievements(); const allAchievements = await getAllAchievements();
const achievement = allAchievements.find((a) => a.id === achievementId); const achievement = allAchievements.find((a) => a.id === achievementId);
@ -659,6 +679,13 @@ async function handleUnawardAchievement(
const user = interaction.options.getUser('user')!; const user = interaction.options.getUser('user')!;
const achievementId = interaction.options.getInteger('achievement_id')!; 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 { try {
const allAchievements = await getAllAchievements(); const allAchievements = await getAllAchievements();
const achievement = allAchievements.find((a) => a.id === achievementId); const achievement = allAchievements.find((a) => a.id === achievementId);

View file

@ -129,7 +129,6 @@ export async function updateAchievementProgress(
): Promise<boolean> { ): Promise<boolean> {
try { try {
await ensureDbInitialized(); await ensureDbInitialized();
if (!db) { if (!db) {
console.error( console.error(
'Database not initialized, cannot update achievement progress', 'Database not initialized, cannot update achievement progress',
@ -149,21 +148,15 @@ export async function updateAchievementProgress(
.then((rows) => rows[0]); .then((rows) => rows[0]);
if (existing) { if (existing) {
if (existing.earnedAt) {
return false;
}
await db await db
.update(schema.userAchievementsTable) .update(schema.userAchievementsTable)
.set({ .set({ progress })
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
})
.where(eq(schema.userAchievementsTable.id, existing.id)); .where(eq(schema.userAchievementsTable.id, existing.id));
} else { } else {
await db.insert(schema.userAchievementsTable).values({ await db.insert(schema.userAchievementsTable).values({
discordId: userId, discordId: userId,
achievementId: achievementId, achievementId,
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), progress,
}); });
} }

View file

@ -1,11 +1,4 @@
import { import { Message, EmbedBuilder, TextChannel, Guild } from 'discord.js';
Message,
Client,
EmbedBuilder,
GuildMember,
TextChannel,
Guild,
} from 'discord.js';
import { import {
addXpToUser, addXpToUser,
@ -21,99 +14,63 @@ import { loadConfig } from './configLoader.js';
import { generateAchievementCard } from './achievementCardGenerator.js'; import { generateAchievementCard } from './achievementCardGenerator.js';
/** /**
* Check and process achievements for a user based on a message * Handle achievement progress updates
* @param message - The message that triggered the check * @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( async function handleProgress(
message: Message, userId: string,
guild: Guild | null,
achievement: schema.achievementDefinitionsTableTypes,
progress: number,
options: { skipAward?: boolean } = {},
): Promise<void> { ): Promise<void> {
if (message.author.bot) return; const { skipAward = false } = options;
const userAchievements = await getUserAchievements(userId);
const userData = await getUserLevel(message.author.id); const existing = userAchievements.find(
const allAchievements = await getAllAchievements();
const messageAchievements = allAchievements.filter(
(a) => a.requirementType === 'message_count',
);
for (const achievement of messageAchievements) {
const progress = Math.min(
100,
(userData.messagesSent / achievement.threshold) * 100,
);
if (progress >= 100) {
const userAchievements = await getUserAchievements(message.author.id);
const existingAchievement = userAchievements.find(
(a) => a.achievementId === achievement.id && a.earnedAt !== null, (a) => a.achievementId === achievement.id && a.earnedAt !== null,
); );
if (!existingAchievement) { await updateAchievementProgress(userId, achievement.id, progress);
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( if (progress === 100 && !existing && !skipAward) {
(a) => a.requirementType === 'level', const awarded = await awardAchievement(userId, achievement.id);
); if (awarded && guild) {
await announceAchievement(guild, userId, achievement);
for (const achievement of levelAchievements) {
const progress = Math.min(
100,
(userData.level / achievement.threshold) * 100,
);
if (progress >= 100) {
const userAchievements = await getUserAchievements(message.author.id);
const existingAchievement = userAchievements.find(
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
);
if (!existingAchievement) {
const awarded = await awardAchievement(
message.author.id,
achievement.id,
);
if (awarded) {
await announceAchievement(
message.guild!,
message.author.id,
achievement,
);
}
}
} else {
await updateAchievementProgress(
message.author.id,
achievement.id,
progress,
);
} }
} }
} }
/** /**
* Check achievements for level-ups * Process message achievements based on user activity
* @param memberId - Member ID who leveled up * @param message - The message object from Discord
* @param newLevel - New level value */
* @guild - Guild instance export async function processMessageAchievements(
message: Message,
): Promise<void> {
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( export async function processLevelUpAchievements(
memberId: string, memberId: string,
@ -121,37 +78,19 @@ export async function processLevelUpAchievements(
guild: Guild, guild: Guild,
): Promise<void> { ): Promise<void> {
const allAchievements = await getAllAchievements(); const allAchievements = await getAllAchievements();
for (const ach of allAchievements.filter(
const levelAchievements = allAchievements.filter(
(a) => a.requirementType === 'level', (a) => a.requirementType === 'level',
); )) {
const progress = Math.min(100, (newLevel / ach.threshold) * 100);
for (const achievement of levelAchievements) { await handleProgress(memberId, guild, ach, progress);
const progress = Math.min(100, (newLevel / achievement.threshold) * 100);
if (progress >= 100) {
const userAchievements = await getUserAchievements(memberId);
const existingAchievement = userAchievements.find(
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
);
if (!existingAchievement) {
const awarded = await awardAchievement(memberId, achievement.id);
if (awarded) {
await announceAchievement(guild, memberId, achievement);
}
}
} else {
await updateAchievementProgress(memberId, achievement.id, progress);
}
} }
} }
/** /**
* Process achievements for command usage * Process command usage achievements when a command is invoked
* @param userId - User ID who used the command * @param userId - ID of the user who invoked the command
* @param commandName - Name of the command * @param commandName - Name of the command invoked
* @param client - Guild instance * @param guild - Guild instance where the command was invoked
*/ */
export async function processCommandAchievements( export async function processCommandAchievements(
userId: string, userId: string,
@ -159,7 +98,6 @@ export async function processCommandAchievements(
guild: Guild, guild: Guild,
): Promise<void> { ): Promise<void> {
const allAchievements = await getAllAchievements(); const allAchievements = await getAllAchievements();
const commandAchievements = allAchievements.filter( const commandAchievements = allAchievements.filter(
(a) => (a) =>
a.requirementType === 'command_usage' && a.requirementType === 'command_usage' &&
@ -167,26 +105,31 @@ export async function processCommandAchievements(
(a.requirement as any).command === commandName, (a.requirement as any).command === commandName,
); );
for (const achievement of commandAchievements) { // fetch the users current achievement entries
const userAchievements = await getUserAchievements(userId); const userAchievements = await getUserAchievements(userId);
const existingAchievement = userAchievements.find(
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
);
if (!existingAchievement) { for (const ach of commandAchievements) {
const awarded = await awardAchievement(userId, achievement.id); // find existing progress, default to 0
if (awarded) { const userAch = userAchievements.find((u) => u.achievementId === ach.id);
await announceAchievement(guild, userId, achievement); 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) * Process reaction achievements when a user reacts to a message
* @param userId - User ID who added/removed the reaction * @param userId - ID of the user who reacted
* @param guild - Guild instance * @param guild - Guild instance where the reaction occurred
* @param isRemoval - Whether this is a reaction removal (true) or addition (false) * @param isRemoval - Whether the reaction was removed (default: false)
*/ */
export async function processReactionAchievements( export async function processReactionAchievements(
userId: string, userId: string,
@ -198,40 +141,21 @@ export async function processReactionAchievements(
if (member.user.bot) return; if (member.user.bot) return;
const allAchievements = await getAllAchievements(); const allAchievements = await getAllAchievements();
const reactionAchievements = allAchievements.filter( const reactionAchievements = allAchievements.filter(
(a) => a.requirementType === 'reactions', (a) => a.requirementType === 'reactions',
); );
if (!reactionAchievements.length) return;
if (reactionAchievements.length === 0) return;
const reactionCount = await getUserReactionCount(userId); const reactionCount = await getUserReactionCount(userId);
for (const achievement of reactionAchievements) { for (const ach of reactionAchievements) {
const progress = Math.max( const progress = Math.max(
0, 0,
Math.min(100, (reactionCount / achievement.threshold) * 100), Math.min(100, (reactionCount / ach.threshold) * 100),
); );
await handleProgress(userId, guild, ach, progress, {
if (progress >= 100 && !isRemoval) { skipAward: isRemoval,
const userAchievements = await getUserAchievements(userId); });
const existingAchievement = userAchievements.find(
(a) =>
a.achievementId === achievement.id &&
a.earnedAt !== null &&
a.earnedAt !== undefined &&
new Date(a.earnedAt).getTime() > 0,
);
if (!existingAchievement) {
const awarded = await awardAchievement(userId, achievement.id);
if (awarded) {
await announceAchievement(guild, userId, achievement);
}
}
}
await updateAchievementProgress(userId, achievement.id, progress);
} }
} catch (error) { } catch (error) {
console.error('Error processing reaction achievements:', error); console.error('Error processing reaction achievements:', error);