From 89de99044d5405bb0b61152c35d98af19b1b6dd2 Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Mon, 8 Dec 2025 21:36:53 +0000 Subject: [PATCH] Improve role management for user milestones by adding new command and refactoring logic Refactors `xpTracker.js` to export `checkMilestoneRoles` and updates `reactionXp.js` and `voiceXp.js` to utilize it. Introduces a new `/level-roles` slash command for managing milestone role rewards and implements role addition/removal logic within `checkMilestoneRoles`. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 931d9dcd-12a8-492b-a86e-6812e31babb0 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/yTaZipL Replit-Helium-Checkpoint-Created: true --- aethex-bot/commands/level-roles.js | 256 +++++++++++++++++++++++++++++ aethex-bot/listeners/reactionXp.js | 20 ++- aethex-bot/listeners/voiceXp.js | 57 ++----- aethex-bot/listeners/xpTracker.js | 77 +++++++-- 4 files changed, 354 insertions(+), 56 deletions(-) create mode 100644 aethex-bot/commands/level-roles.js diff --git a/aethex-bot/commands/level-roles.js b/aethex-bot/commands/level-roles.js new file mode 100644 index 0000000..0404fad --- /dev/null +++ b/aethex-bot/commands/level-roles.js @@ -0,0 +1,256 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('level-roles') + .setDescription('Manage automatic role rewards for milestones') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(sub => + sub.setName('add') + .setDescription('Add a role reward for a milestone') + .addRoleOption(opt => + opt.setName('role') + .setDescription('The role to award') + .setRequired(true)) + .addStringOption(opt => + opt.setName('type') + .setDescription('Type of milestone') + .setRequired(true) + .addChoices( + { name: 'Level - Awarded at specific level', value: 'level' }, + { name: 'Prestige - Awarded at prestige level', value: 'prestige' }, + { name: 'Total XP - Awarded at XP milestone', value: 'total_xp' } + )) + .addIntegerOption(opt => + opt.setName('value') + .setDescription('Milestone value (level #, prestige #, or XP amount)') + .setRequired(true) + .setMinValue(1)) + .addBooleanOption(opt => + opt.setName('stack') + .setDescription('Keep previous milestone roles (true) or replace them (false)') + .setRequired(false))) + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove a role reward') + .addRoleOption(opt => + opt.setName('role') + .setDescription('The role to remove from rewards') + .setRequired(true))) + .addSubcommand(sub => + sub.setName('list') + .setDescription('View all configured role rewards')) + .addSubcommand(sub => + sub.setName('clear') + .setDescription('Clear all role rewards for a milestone type') + .addStringOption(opt => + opt.setName('type') + .setDescription('Type of milestone to clear') + .setRequired(true) + .addChoices( + { name: 'Level roles', value: 'level' }, + { name: 'Prestige roles', value: 'prestige' }, + { name: 'Total XP roles', value: 'total_xp' }, + { name: 'All roles', value: 'all' } + ))), + + async execute(interaction, client, supabase) { + if (!supabase) { + return interaction.reply({ + content: '❌ Database not configured. Level roles require Supabase.', + ephemeral: true + }); + } + + const guildId = interaction.guildId; + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'add': + return handleAdd(interaction, supabase, guildId); + case 'remove': + return handleRemove(interaction, supabase, guildId); + case 'list': + return handleList(interaction, supabase, guildId); + case 'clear': + return handleClear(interaction, supabase, guildId); + } + } +}; + +async function handleAdd(interaction, supabase, guildId) { + const role = interaction.options.getRole('role'); + const type = interaction.options.getString('type'); + const value = interaction.options.getInteger('value'); + const stack = interaction.options.getBoolean('stack') ?? true; + + if (role.managed) { + return interaction.reply({ + content: '❌ Cannot use managed roles (bot roles, integration roles) as rewards.', + ephemeral: true + }); + } + + const botMember = interaction.guild.members.me; + if (role.position >= botMember.roles.highest.position) { + return interaction.reply({ + content: '❌ I cannot assign this role. It is higher than or equal to my highest role.', + ephemeral: true + }); + } + + try { + const { error } = await supabase + .from('level_roles') + .upsert({ + guild_id: guildId, + role_id: role.id, + milestone_type: type, + milestone_value: value, + stack_roles: stack + }, { onConflict: 'guild_id,role_id' }); + + if (error) throw error; + + const typeNames = { + level: 'Level', + prestige: 'Prestige', + total_xp: 'Total XP' + }; + + const valueDisplay = type === 'total_xp' ? `${value.toLocaleString()} XP` : value; + + return interaction.reply({ + content: `✅ ${role} will be awarded at **${typeNames[type]} ${valueDisplay}**.\nRole stacking: ${stack ? 'Enabled (keep previous roles)' : 'Disabled (replace previous roles)'}`, + ephemeral: true + }); + } catch (error) { + console.error('Failed to add level role:', error.message); + return interaction.reply({ + content: '❌ Failed to add role reward. Please try again.', + ephemeral: true + }); + } +} + +async function handleRemove(interaction, supabase, guildId) { + const role = interaction.options.getRole('role'); + + try { + const { data, error } = await supabase + .from('level_roles') + .delete() + .eq('guild_id', guildId) + .eq('role_id', role.id) + .select(); + + if (error) throw error; + + if (!data || data.length === 0) { + return interaction.reply({ + content: `❌ ${role} is not configured as a role reward.`, + ephemeral: true + }); + } + + return interaction.reply({ + content: `✅ Removed ${role} from role rewards.`, + ephemeral: true + }); + } catch (error) { + console.error('Failed to remove level role:', error.message); + return interaction.reply({ + content: '❌ Failed to remove role reward. Please try again.', + ephemeral: true + }); + } +} + +async function handleList(interaction, supabase, guildId) { + try { + const { data: roles, error } = await supabase + .from('level_roles') + .select('*') + .eq('guild_id', guildId) + .order('milestone_type') + .order('milestone_value', { ascending: true }); + + if (error) throw error; + + if (!roles || roles.length === 0) { + return interaction.reply({ + content: '📋 No role rewards configured. Use `/level-roles add` to create some!', + ephemeral: true + }); + } + + const levelRoles = roles.filter(r => r.milestone_type === 'level'); + const prestigeRoles = roles.filter(r => r.milestone_type === 'prestige'); + const xpRoles = roles.filter(r => r.milestone_type === 'total_xp'); + + const formatRoles = (roleList, prefix) => { + if (roleList.length === 0) return 'None configured'; + return roleList.map(r => { + const stackIcon = r.stack_roles ? '📚' : '🔄'; + const value = r.milestone_type === 'total_xp' + ? `${r.milestone_value.toLocaleString()} XP` + : r.milestone_value; + return `${stackIcon} <@&${r.role_id}> → ${prefix}${value}`; + }).join('\n'); + }; + + const embed = new EmbedBuilder() + .setTitle('🏆 Role Rewards Configuration') + .setColor(0x5865F2) + .addFields( + { name: '📊 Level Roles', value: formatRoles(levelRoles, 'Level '), inline: false }, + { name: '⭐ Prestige Roles', value: formatRoles(prestigeRoles, 'Prestige '), inline: false }, + { name: '💎 Total XP Roles', value: formatRoles(xpRoles, ''), inline: false } + ) + .setFooter({ text: '📚 = Stack roles | 🔄 = Replace previous' }) + .setTimestamp(); + + return interaction.reply({ embeds: [embed] }); + } catch (error) { + console.error('Failed to list level roles:', error.message); + return interaction.reply({ + content: '❌ Failed to fetch role rewards. Please try again.', + ephemeral: true + }); + } +} + +async function handleClear(interaction, supabase, guildId) { + const type = interaction.options.getString('type'); + + try { + let query = supabase.from('level_roles').delete().eq('guild_id', guildId); + + if (type !== 'all') { + query = query.eq('milestone_type', type); + } + + const { data, error } = await query.select(); + + if (error) throw error; + + const count = data?.length || 0; + const typeNames = { + level: 'level', + prestige: 'prestige', + total_xp: 'total XP', + all: '' + }; + + return interaction.reply({ + content: `✅ Cleared **${count}** ${typeNames[type]} role reward(s).`, + ephemeral: true + }); + } catch (error) { + console.error('Failed to clear level roles:', error.message); + return interaction.reply({ + content: '❌ Failed to clear role rewards. Please try again.', + ephemeral: true + }); + } +} diff --git a/aethex-bot/listeners/reactionXp.js b/aethex-bot/listeners/reactionXp.js index 54456c1..354f747 100644 --- a/aethex-bot/listeners/reactionXp.js +++ b/aethex-bot/listeners/reactionXp.js @@ -1,3 +1,5 @@ +const { checkMilestoneRoles, calculateLevel } = require('./xpTracker'); + const reactionCooldowns = new Map(); const xpConfigCache = new Map(); const CACHE_TTL = 60000; @@ -51,12 +53,12 @@ module.exports = { const receiverMember = await reaction.message.guild.members.fetch(messageAuthor.id).catch(() => null); if (!giverOnCooldown && giverXp > 0) { - await grantXp(supabase, user.id, giverXp, client, giverMember, config); + await grantXp(supabase, user.id, giverXp, client, giverMember, config, guildId); reactionCooldowns.set(cooldownKeyGiver, now); } if (!receiverOnCooldown && receiverXp > 0) { - await grantXp(supabase, messageAuthor.id, receiverXp, client, receiverMember, config); + await grantXp(supabase, messageAuthor.id, receiverXp, client, receiverMember, config, guildId); reactionCooldowns.set(cooldownKeyReceiver, now); reactionCooldowns.set(cooldownKeyMessage, now); } @@ -66,7 +68,7 @@ module.exports = { } }; -async function grantXp(supabase, discordUserId, xpAmount, client, member, config) { +async function grantXp(supabase, discordUserId, xpAmount, client, member, config, guildId) { const { data: link, error: linkError } = await supabase .from('discord_links') .select('user_id') @@ -103,7 +105,6 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config finalXp = Math.floor(xpAmount * highestMultiplier); } - // Apply prestige bonus (+5% per prestige level) if (prestige > 0) { const prestigeBonus = 1 + (prestige * 0.05); finalXp = Math.floor(finalXp * prestigeBonus); @@ -113,6 +114,9 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config const newXp = currentXp + finalXp; const totalEarned = (profile.total_xp_earned || currentXp) + finalXp; + const oldLevel = calculateLevel(currentXp, config?.level_curve); + const newLevel = calculateLevel(newXp, config?.level_curve); + const { error: updateError } = await supabase .from('user_profiles') .update({ xp: newXp, total_xp_earned: totalEarned }) @@ -123,6 +127,14 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config if (client.trackXP) { client.trackXP(finalXp); } + + if (member && guildId) { + await checkMilestoneRoles(member, { + level: newLevel, + prestige: prestige, + totalXp: totalEarned + }, supabase, guildId, newLevel <= oldLevel); + } } async function getXpConfig(supabase, guildId) { diff --git a/aethex-bot/listeners/voiceXp.js b/aethex-bot/listeners/voiceXp.js index 923b745..b58f370 100644 --- a/aethex-bot/listeners/voiceXp.js +++ b/aethex-bot/listeners/voiceXp.js @@ -1,3 +1,5 @@ +const { checkMilestoneRoles, calculateLevel } = require('./xpTracker'); + const voiceSessions = new Map(); const voiceConfigCache = new Map(); const CACHE_TTL = 60000; @@ -197,20 +199,26 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m client.trackXP(xpGain); } - if (newLevel > oldLevel && member) { - const serverConfig = client.serverConfigs?.get(guildId); - const levelUpChannelId = serverConfig?.level_up_channel; + if (member) { + if (newLevel > oldLevel) { + const serverConfig = client.serverConfigs?.get(guildId); + const levelUpChannelId = serverConfig?.level_up_channel; - const levelUpMessage = `🎉 Congratulations ${member}! You reached **Level ${newLevel}** from voice chat activity!`; + const levelUpMessage = `🎉 Congratulations ${member}! You reached **Level ${newLevel}** from voice chat activity!`; - if (levelUpChannelId) { - const channel = await client.channels.fetch(levelUpChannelId).catch(() => null); - if (channel) { - await channel.send(levelUpMessage); + if (levelUpChannelId) { + const channel = await client.channels.fetch(levelUpChannelId).catch(() => null); + if (channel) { + await channel.send(levelUpMessage); + } } } - await checkLevelRoles(member, newLevel, supabase, guildId); + await checkMilestoneRoles(member, { + level: newLevel, + prestige: prestige, + totalXp: totalEarned + }, supabase, guildId, newLevel <= oldLevel); } } catch (error) { @@ -261,34 +269,3 @@ function getDefaultConfig() { }; } -function calculateLevel(xp, curve = 'normal') { - const bases = { - easy: 50, - normal: 100, - hard: 200 - }; - const base = bases[curve] || 100; - return Math.floor(Math.sqrt(xp / base)); -} - -async function checkLevelRoles(member, level, supabase, guildId) { - if (!member || !supabase) return; - - try { - const { data: levelRoles, error } = await supabase - .from('level_roles') - .select('role_id, level_required') - .eq('guild_id', guildId) - .lte('level_required', level) - .order('level_required', { ascending: true }); - - if (error || !levelRoles || levelRoles.length === 0) return; - - for (const lr of levelRoles) { - if (!member.roles.cache.has(lr.role_id)) { - await member.roles.add(lr.role_id).catch(() => {}); - } - } - } catch (e) { - } -} diff --git a/aethex-bot/listeners/xpTracker.js b/aethex-bot/listeners/xpTracker.js index 5dec896..fffe3a8 100644 --- a/aethex-bot/listeners/xpTracker.js +++ b/aethex-bot/listeners/xpTracker.js @@ -105,7 +105,17 @@ module.exports = { if (newLevel > oldLevel) { await sendLevelUpAnnouncement(message, newLevel, newXp, config, client); - await checkLevelRoles(message.member, newLevel, supabase, guildId); + await checkMilestoneRoles(message.member, { + level: newLevel, + prestige: prestige, + totalXp: totalEarned + }, supabase, guildId); + } else { + await checkMilestoneRoles(message.member, { + level: newLevel, + prestige: prestige, + totalXp: totalEarned + }, supabase, guildId, true); } } catch (error) { @@ -216,28 +226,71 @@ function calculateLevel(xp, curve = 'normal') { return Math.floor(Math.sqrt(xp / base)); } -async function checkLevelRoles(member, level, supabase, guildId) { +async function checkMilestoneRoles(member, milestones, supabase, guildId, xpOnly = false) { if (!member || !supabase) return; try { - const { data: levelRoles, error } = await supabase + const { data: allRoles, error } = await supabase .from('level_roles') - .select('role_id, level_required') - .eq('guild_id', guildId) - .lte('level_required', level) - .order('level_required', { ascending: true }); + .select('*') + .eq('guild_id', guildId); - if (error || !levelRoles || levelRoles.length === 0) return; + if (error || !allRoles || allRoles.length === 0) return; - for (const lr of levelRoles) { - if (!member.roles.cache.has(lr.role_id)) { - await member.roles.add(lr.role_id).catch(() => {}); + const rolesToAdd = []; + const rolesToRemove = []; + + for (const roleConfig of allRoles) { + const { role_id, milestone_type, milestone_value, stack_roles } = roleConfig; + let qualifies = false; + + switch (milestone_type) { + case 'level': + if (!xpOnly) qualifies = milestones.level >= milestone_value; + break; + case 'prestige': + if (!xpOnly) qualifies = milestones.prestige >= milestone_value; + break; + case 'total_xp': + qualifies = milestones.totalXp >= milestone_value; + break; } + + if (qualifies && !member.roles.cache.has(role_id)) { + rolesToAdd.push({ role_id, milestone_type, milestone_value, stack_roles }); + } + } + + for (const roleToAdd of rolesToAdd) { + try { + await member.roles.add(roleToAdd.role_id); + + if (!roleToAdd.stack_roles) { + const sameTypeRoles = allRoles.filter(r => + r.milestone_type === roleToAdd.milestone_type && + r.milestone_value < roleToAdd.milestone_value && + member.roles.cache.has(r.role_id) + ); + + for (const oldRole of sameTypeRoles) { + if (!rolesToRemove.includes(oldRole.role_id)) { + rolesToRemove.push(oldRole.role_id); + } + } + } + } catch (e) { + // Role addition failed, don't remove old roles + } + } + + for (const roleId of rolesToRemove) { + await member.roles.remove(roleId).catch(() => {}); } } catch (e) { // Table may not exist yet - silently ignore } } -// Export calculateLevel for use in other commands +// Export functions for use in other commands module.exports.calculateLevel = calculateLevel; +module.exports.checkMilestoneRoles = checkMilestoneRoles;