diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 0515b36..96e1e45 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -69,6 +69,42 @@ if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE) { console.log("Supabase not configured - community features will be limited"); } +// Achievement tracking for command usage +async function trackCommandForAchievements(discordUserId, guildId, member, supabaseClient, discordClient) { + try { + const { data: link } = await supabaseClient + .from('discord_links') + .select('user_id') + .eq('discord_id', discordUserId) + .maybeSingle(); + + if (!link) return; + + const { updateUserStats, getUserStats, calculateLevel } = require('./listeners/xpTracker'); + const { checkAchievements } = require('./commands/achievements'); + + await updateUserStats(supabaseClient, link.user_id, guildId, { commandsUsed: 1 }); + + const { data: profile } = await supabaseClient + .from('user_profiles') + .select('xp, prestige_level, total_xp_earned, daily_streak') + .eq('id', link.user_id) + .maybeSingle(); + + if (profile) { + const stats = await getUserStats(supabaseClient, link.user_id, guildId); + stats.level = calculateLevel(profile.xp || 0, 'normal'); + stats.prestige = profile.prestige_level || 0; + stats.totalXp = profile.total_xp_earned || profile.xp || 0; + stats.dailyStreak = profile.daily_streak || 0; + + await checkAchievements(link.user_id, member, stats, supabaseClient, guildId, discordClient); + } + } catch (e) { + // Silent fail for achievement tracking + } +} + // ============================================================================= // COMMAND LOGGING SYSTEM (Supabase-based) // ============================================================================= @@ -761,6 +797,11 @@ client.on("interactionCreate", async (interaction) => { trackCommand(interaction.commandName); resetDailyAnalytics(); + // Track command usage for achievements + if (supabase && interaction.guildId) { + trackCommandForAchievements(interaction.user.id, interaction.guildId, interaction.member, supabase, client).catch(() => {}); + } + const activityData = { command: interaction.commandName, user: interaction.user.tag, diff --git a/aethex-bot/commands/achievements.js b/aethex-bot/commands/achievements.js new file mode 100644 index 0000000..d0652e7 --- /dev/null +++ b/aethex-bot/commands/achievements.js @@ -0,0 +1,616 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); + +const TRIGGER_TYPES = { + level: { name: 'Reach Level', description: 'Triggers when user reaches specified level' }, + prestige: { name: 'Reach Prestige', description: 'Triggers when user reaches prestige level' }, + total_xp: { name: 'Total XP Earned', description: 'Triggers when total XP reaches value' }, + messages: { name: 'Messages Sent', description: 'Triggers after sending X messages' }, + reactions_given: { name: 'Reactions Given', description: 'Triggers after giving X reactions' }, + reactions_received: { name: 'Reactions Received', description: 'Triggers after receiving X reactions' }, + voice_minutes: { name: 'Voice Minutes', description: 'Triggers after X minutes in voice' }, + daily_streak: { name: 'Daily Streak', description: 'Triggers at X day claim streak' }, + commands_used: { name: 'Commands Used', description: 'Triggers after using X commands' } +}; + +module.exports = { + data: new SlashCommandBuilder() + .setName('achievements') + .setDescription('Manage server achievements and badges') + .addSubcommand(sub => + sub.setName('create') + .setDescription('Create a new achievement') + .addStringOption(opt => + opt.setName('name') + .setDescription('Achievement name') + .setRequired(true) + .setMaxLength(100)) + .addStringOption(opt => + opt.setName('trigger') + .setDescription('What triggers this achievement') + .setRequired(true) + .addChoices( + { name: 'Reach Level', value: 'level' }, + { name: 'Reach Prestige', value: 'prestige' }, + { name: 'Total XP Earned', value: 'total_xp' }, + { name: 'Messages Sent', value: 'messages' }, + { name: 'Reactions Given', value: 'reactions_given' }, + { name: 'Reactions Received', value: 'reactions_received' }, + { name: 'Voice Minutes', value: 'voice_minutes' }, + { name: 'Daily Streak', value: 'daily_streak' }, + { name: 'Commands Used', value: 'commands_used' } + )) + .addIntegerOption(opt => + opt.setName('value') + .setDescription('Trigger value (e.g., level 10, 1000 XP)') + .setRequired(true) + .setMinValue(1)) + .addStringOption(opt => + opt.setName('description') + .setDescription('Achievement description') + .setMaxLength(200)) + .addStringOption(opt => + opt.setName('icon') + .setDescription('Emoji icon for the achievement') + .setMaxLength(50)) + .addIntegerOption(opt => + opt.setName('reward_xp') + .setDescription('XP reward for earning this achievement') + .setMinValue(0) + .setMaxValue(10000)) + .addRoleOption(opt => + opt.setName('reward_role') + .setDescription('Role to award when achievement is earned')) + .addBooleanOption(opt => + opt.setName('hidden') + .setDescription('Hide this achievement until earned'))) + .addSubcommand(sub => + sub.setName('delete') + .setDescription('Delete an achievement') + .addStringOption(opt => + opt.setName('name') + .setDescription('Achievement name to delete') + .setRequired(true) + .setAutocomplete(true))) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all server achievements')) + .addSubcommand(sub => + sub.setName('view') + .setDescription('View your earned achievements') + .addUserOption(opt => + opt.setName('user') + .setDescription('User to view achievements for'))) + .addSubcommand(sub => + sub.setName('grant') + .setDescription('Manually grant an achievement to a user') + .addUserOption(opt => + opt.setName('user') + .setDescription('User to grant achievement to') + .setRequired(true)) + .addStringOption(opt => + opt.setName('name') + .setDescription('Achievement name') + .setRequired(true) + .setAutocomplete(true))) + .addSubcommand(sub => + sub.setName('revoke') + .setDescription('Revoke an achievement from a user') + .addUserOption(opt => + opt.setName('user') + .setDescription('User to revoke achievement from') + .setRequired(true)) + .addStringOption(opt => + opt.setName('name') + .setDescription('Achievement name') + .setRequired(true) + .setAutocomplete(true))), + + async autocomplete(interaction, supabase) { + if (!supabase) return; + + const focused = interaction.options.getFocused().toLowerCase(); + const guildId = interaction.guildId; + + try { + const { data: achievements } = await supabase + .from('achievements') + .select('name') + .eq('guild_id', guildId); + + const filtered = (achievements || []) + .filter(a => a.name.toLowerCase().includes(focused)) + .slice(0, 25) + .map(a => ({ name: a.name, value: a.name })); + + await interaction.respond(filtered); + } catch (e) { + await interaction.respond([]); + } + }, + + async execute(interaction, client, supabase) { + if (!supabase) { + return interaction.reply({ + content: 'Database not configured. Achievements require Supabase.', + ephemeral: true + }); + } + + const guildId = interaction.guildId; + const sub = interaction.options.getSubcommand(); + + switch (sub) { + case 'create': + return handleCreate(interaction, supabase, guildId); + case 'delete': + return handleDelete(interaction, supabase, guildId); + case 'list': + return handleList(interaction, supabase, guildId); + case 'view': + return handleView(interaction, supabase, guildId); + case 'grant': + return handleGrant(interaction, supabase, guildId, client); + case 'revoke': + return handleRevoke(interaction, supabase, guildId); + } + } +}; + +async function handleCreate(interaction, supabase, guildId) { + if (!interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to create achievements.', ephemeral: true }); + } + + const name = interaction.options.getString('name'); + const trigger = interaction.options.getString('trigger'); + const value = interaction.options.getInteger('value'); + const description = interaction.options.getString('description') || `${TRIGGER_TYPES[trigger].name}: ${value}`; + const icon = interaction.options.getString('icon') || '🏆'; + const rewardXp = interaction.options.getInteger('reward_xp') || 0; + const rewardRole = interaction.options.getRole('reward_role'); + const hidden = interaction.options.getBoolean('hidden') || false; + + try { + const { error } = await supabase + .from('achievements') + .insert({ + guild_id: guildId, + name, + description, + icon, + trigger_type: trigger, + trigger_value: value, + reward_xp: rewardXp, + reward_role_id: rewardRole?.id || null, + hidden + }); + + if (error) { + if (error.code === '23505') { + return interaction.reply({ content: `An achievement named "${name}" already exists.`, ephemeral: true }); + } + throw error; + } + + const embed = new EmbedBuilder() + .setColor(0x10b981) + .setTitle('Achievement Created') + .addFields( + { name: 'Name', value: `${icon} ${name}`, inline: true }, + { name: 'Trigger', value: `${TRIGGER_TYPES[trigger].name}: ${value.toLocaleString()}`, inline: true }, + { name: 'Description', value: description }, + { name: 'Rewards', value: [ + rewardXp > 0 ? `+${rewardXp.toLocaleString()} XP` : null, + rewardRole ? `Role: ${rewardRole}` : null, + hidden ? '(Hidden achievement)' : null + ].filter(Boolean).join('\n') || 'None' } + ) + .setTimestamp(); + + return interaction.reply({ embeds: [embed] }); + } catch (error) { + console.error('Achievement create error:', error); + return interaction.reply({ content: 'Failed to create achievement.', ephemeral: true }); + } +} + +async function handleDelete(interaction, supabase, guildId) { + if (!interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to delete achievements.', ephemeral: true }); + } + + const name = interaction.options.getString('name'); + + try { + const { data, error } = await supabase + .from('achievements') + .delete() + .eq('guild_id', guildId) + .eq('name', name) + .select(); + + if (error) throw error; + + if (!data || data.length === 0) { + return interaction.reply({ content: `Achievement "${name}" not found.`, ephemeral: true }); + } + + return interaction.reply({ content: `Achievement "${name}" has been deleted.`, ephemeral: true }); + } catch (error) { + console.error('Achievement delete error:', error); + return interaction.reply({ content: 'Failed to delete achievement.', ephemeral: true }); + } +} + +async function handleList(interaction, supabase, guildId) { + try { + const { data: achievements, error } = await supabase + .from('achievements') + .select('*') + .eq('guild_id', guildId) + .order('trigger_type') + .order('trigger_value', { ascending: true }); + + if (error) throw error; + + if (!achievements || achievements.length === 0) { + return interaction.reply({ + content: 'No achievements configured. Use `/achievements create` to add some!', + ephemeral: true + }); + } + + const grouped = {}; + for (const ach of achievements) { + const type = ach.trigger_type; + if (!grouped[type]) grouped[type] = []; + grouped[type].push(ach); + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Server Achievements') + .setDescription(`${achievements.length} achievement(s) configured`) + .setTimestamp(); + + for (const [type, achs] of Object.entries(grouped)) { + const typeInfo = TRIGGER_TYPES[type] || { name: type }; + const lines = achs.map(a => { + const hiddenTag = a.hidden ? ' (Hidden)' : ''; + const rewards = []; + if (a.reward_xp > 0) rewards.push(`+${a.reward_xp} XP`); + if (a.reward_role_id) rewards.push(`<@&${a.reward_role_id}>`); + const rewardStr = rewards.length > 0 ? ` | ${rewards.join(', ')}` : ''; + return `${a.icon} **${a.name}**${hiddenTag} - ${a.trigger_value.toLocaleString()}${rewardStr}`; + }); + + embed.addFields({ + name: typeInfo.name, + value: lines.join('\n') || 'None', + inline: false + }); + } + + return interaction.reply({ embeds: [embed] }); + } catch (error) { + console.error('Achievement list error:', error); + return interaction.reply({ content: 'Failed to list achievements.', ephemeral: true }); + } +} + +async function handleView(interaction, supabase, guildId) { + const target = interaction.options.getUser('user') || interaction.user; + await interaction.deferReply(); + + try { + const { data: link } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', target.id) + .maybeSingle(); + + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription(`${target.id === interaction.user.id ? 'You are' : `${target.tag} is`} not linked to AeThex.`) + ] + }); + } + + const [achievementsResult, earnedResult] = await Promise.all([ + supabase.from('achievements').select('*').eq('guild_id', guildId), + supabase.from('user_achievements') + .select('achievement_id, earned_at') + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + ]); + + const allAchievements = achievementsResult.data || []; + const earnedIds = new Set((earnedResult.data || []).map(e => e.achievement_id)); + const earnedMap = Object.fromEntries((earnedResult.data || []).map(e => [e.achievement_id, e.earned_at])); + + const earned = allAchievements.filter(a => earnedIds.has(a.id)); + const available = allAchievements.filter(a => !earnedIds.has(a.id) && !a.hidden); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle(`${target.username}'s Achievements`) + .setThumbnail(target.displayAvatarURL()) + .setTimestamp(); + + if (earned.length > 0) { + const earnedLines = earned.map(a => { + const date = earnedMap[a.id] ? new Date(earnedMap[a.id]).toLocaleDateString() : ''; + return `${a.icon} **${a.name}** - ${a.description} (${date})`; + }); + embed.addFields({ name: `Earned (${earned.length})`, value: earnedLines.join('\n').slice(0, 1024) }); + } else { + embed.addFields({ name: 'Earned', value: 'No achievements earned yet!' }); + } + + if (available.length > 0) { + const availableLines = available.slice(0, 10).map(a => { + const typeInfo = TRIGGER_TYPES[a.trigger_type] || { name: a.trigger_type }; + return `🔒 **${a.name}** - ${typeInfo.name}: ${a.trigger_value.toLocaleString()}`; + }); + if (available.length > 10) { + availableLines.push(`... and ${available.length - 10} more`); + } + embed.addFields({ name: `Available (${available.length})`, value: availableLines.join('\n').slice(0, 1024) }); + } + + const hiddenCount = allAchievements.filter(a => a.hidden && !earnedIds.has(a.id)).length; + if (hiddenCount > 0) { + embed.setFooter({ text: `${hiddenCount} hidden achievement(s) to discover` }); + } + + return interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Achievement view error:', error); + return interaction.editReply({ content: 'Failed to fetch achievements.' }); + } +} + +async function handleGrant(interaction, supabase, guildId, client) { + if (!interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to grant achievements.', ephemeral: true }); + } + + const target = interaction.options.getUser('user'); + const name = interaction.options.getString('name'); + + try { + const { data: link } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', target.id) + .maybeSingle(); + + if (!link) { + return interaction.reply({ content: `${target.tag} is not linked to AeThex.`, ephemeral: true }); + } + + const { data: achievement } = await supabase + .from('achievements') + .select('*') + .eq('guild_id', guildId) + .eq('name', name) + .maybeSingle(); + + if (!achievement) { + return interaction.reply({ content: `Achievement "${name}" not found.`, ephemeral: true }); + } + + const { error } = await supabase + .from('user_achievements') + .upsert({ + user_id: link.user_id, + guild_id: guildId, + achievement_id: achievement.id, + notified: true + }, { onConflict: 'user_id,guild_id,achievement_id' }); + + if (error) throw error; + + if (achievement.reward_role_id) { + const member = await interaction.guild.members.fetch(target.id).catch(() => null); + if (member) { + await member.roles.add(achievement.reward_role_id).catch(() => {}); + } + } + + if (achievement.reward_xp > 0) { + await supabase + .from('user_profiles') + .update({ xp: supabase.rpc('increment_xp', { user_id: link.user_id, amount: achievement.reward_xp }) }) + .eq('id', link.user_id); + } + + return interaction.reply({ + content: `${achievement.icon} Granted **${achievement.name}** to ${target}!`, + ephemeral: true + }); + } catch (error) { + console.error('Achievement grant error:', error); + return interaction.reply({ content: 'Failed to grant achievement.', ephemeral: true }); + } +} + +async function handleRevoke(interaction, supabase, guildId) { + if (!interaction.member.permissions.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to revoke achievements.', ephemeral: true }); + } + + const target = interaction.options.getUser('user'); + const name = interaction.options.getString('name'); + + try { + const { data: link } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', target.id) + .maybeSingle(); + + if (!link) { + return interaction.reply({ content: `${target.tag} is not linked to AeThex.`, ephemeral: true }); + } + + const { data: achievement } = await supabase + .from('achievements') + .select('id, name, icon, reward_role_id') + .eq('guild_id', guildId) + .eq('name', name) + .maybeSingle(); + + if (!achievement) { + return interaction.reply({ content: `Achievement "${name}" not found.`, ephemeral: true }); + } + + const { data, error } = await supabase + .from('user_achievements') + .delete() + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .eq('achievement_id', achievement.id) + .select(); + + if (error) throw error; + + if (!data || data.length === 0) { + return interaction.reply({ content: `${target.tag} doesn't have this achievement.`, ephemeral: true }); + } + + if (achievement.reward_role_id) { + const member = await interaction.guild.members.fetch(target.id).catch(() => null); + if (member) { + await member.roles.remove(achievement.reward_role_id).catch(() => {}); + } + } + + return interaction.reply({ + content: `Revoked **${achievement.name}** from ${target}.`, + ephemeral: true + }); + } catch (error) { + console.error('Achievement revoke error:', error); + return interaction.reply({ content: 'Failed to revoke achievement.', ephemeral: true }); + } +} + +module.exports.checkAchievements = async function(userId, discordMember, stats, supabase, guildId, client) { + if (!supabase || !userId || !guildId) return; + + try { + const { data: achievements } = await supabase + .from('achievements') + .select('*') + .eq('guild_id', guildId); + + if (!achievements || achievements.length === 0) return; + + const { data: earned } = await supabase + .from('user_achievements') + .select('achievement_id') + .eq('user_id', userId) + .eq('guild_id', guildId); + + const earnedIds = new Set((earned || []).map(e => e.achievement_id)); + const newlyEarned = []; + + for (const ach of achievements) { + if (earnedIds.has(ach.id)) continue; + + let qualifies = false; + const val = ach.trigger_value; + + switch (ach.trigger_type) { + case 'level': + qualifies = (stats.level || 0) >= val; + break; + case 'prestige': + qualifies = (stats.prestige || 0) >= val; + break; + case 'total_xp': + qualifies = (stats.totalXp || 0) >= val; + break; + case 'messages': + qualifies = (stats.messages || 0) >= val; + break; + case 'reactions_given': + qualifies = (stats.reactionsGiven || 0) >= val; + break; + case 'reactions_received': + qualifies = (stats.reactionsReceived || 0) >= val; + break; + case 'voice_minutes': + qualifies = (stats.voiceMinutes || 0) >= val; + break; + case 'daily_streak': + qualifies = (stats.dailyStreak || 0) >= val; + break; + case 'commands_used': + qualifies = (stats.commandsUsed || 0) >= val; + break; + } + + if (qualifies) { + newlyEarned.push(ach); + } + } + + for (const ach of newlyEarned) { + await supabase.from('user_achievements').insert({ + user_id: userId, + guild_id: guildId, + achievement_id: ach.id, + notified: true + }).catch(() => {}); + + if (ach.reward_role_id && discordMember) { + await discordMember.roles.add(ach.reward_role_id).catch(() => {}); + } + + if (ach.reward_xp > 0) { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, total_xp_earned') + .eq('id', userId) + .maybeSingle(); + + if (profile) { + await supabase + .from('user_profiles') + .update({ + xp: (profile.xp || 0) + ach.reward_xp, + total_xp_earned: (profile.total_xp_earned || 0) + ach.reward_xp + }) + .eq('id', userId); + } + } + + if (discordMember && client) { + try { + await discordMember.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x10b981) + .setTitle('Achievement Unlocked!') + .setDescription(`${ach.icon} **${ach.name}**\n${ach.description}`) + .addFields( + ach.reward_xp > 0 ? { name: 'Reward', value: `+${ach.reward_xp} XP`, inline: true } : [] + ) + .setTimestamp() + ] + }).catch(() => {}); + } catch (e) {} + } + } + + return newlyEarned; + } catch (error) { + console.error('Achievement check error:', error); + return []; + } +}; diff --git a/aethex-bot/commands/badges.js b/aethex-bot/commands/badges.js index 731fa87..38986ae 100644 --- a/aethex-bot/commands/badges.js +++ b/aethex-bot/commands/badges.js @@ -15,6 +15,25 @@ const BADGE_INFO = { 'bug_hunter': { emoji: '🐛', name: 'Bug Hunter', description: 'Reported a valid bug' }, }; +async function getServerAchievements(supabase, guildId, userId) { + try { + const [achievementsResult, earnedResult] = await Promise.all([ + supabase.from('achievements').select('*').eq('guild_id', guildId), + supabase.from('user_achievements').select('achievement_id').eq('user_id', userId).eq('guild_id', guildId) + ]); + + const allAchievements = achievementsResult.data || []; + const earnedIds = new Set((earnedResult.data || []).map(e => e.achievement_id)); + + return { + earned: allAchievements.filter(a => earnedIds.has(a.id)), + available: allAchievements.filter(a => !earnedIds.has(a.id) && !a.hidden) + }; + } catch (e) { + return { earned: [], available: [] }; + } +} + module.exports = { data: new SlashCommandBuilder() .setName('badges') @@ -116,6 +135,20 @@ module.exports = { embed.addFields({ name: 'Locked Badges', value: lockedDisplay }); } + // Add server achievements + const serverAchievements = await getServerAchievements(supabase, interaction.guildId, link.user_id); + + if (serverAchievements.earned.length > 0) { + const serverBadgeDisplay = serverAchievements.earned.map(ach => + `${ach.icon} **${ach.name}**\n${ach.description}` + ).join('\n\n').slice(0, 1024); + + embed.addFields({ + name: `Server Achievements (${serverAchievements.earned.length})`, + value: serverBadgeDisplay + }); + } + await interaction.editReply({ embeds: [embed] }); } catch (error) { diff --git a/aethex-bot/commands/daily.js b/aethex-bot/commands/daily.js index a275a55..5830ea9 100644 --- a/aethex-bot/commands/daily.js +++ b/aethex-bot/commands/daily.js @@ -1,4 +1,6 @@ const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); +const { checkAchievements } = require('./achievements'); +const { getUserStats, calculateLevel } = require('../listeners/xpTracker'); const DAILY_XP = 50; const STREAK_BONUS = 10; @@ -122,6 +124,16 @@ module.exports = { await interaction.editReply({ embeds: [embed] }); + // Check achievements with updated stats + const guildId = interaction.guildId; + const stats = await getUserStats(supabase, link.user_id, guildId); + stats.level = newLevel; + stats.prestige = prestige; + stats.totalXp = totalEarned; + stats.dailyStreak = streak; + + await checkAchievements(link.user_id, interaction.member, stats, supabase, guildId, client); + } catch (error) { console.error('Daily error:', error); await interaction.editReply({ content: 'Failed to claim daily reward.' }); diff --git a/aethex-bot/docs/MANUAL.md b/aethex-bot/docs/MANUAL.md index d66b314..11934f4 100644 --- a/aethex-bot/docs/MANUAL.md +++ b/aethex-bot/docs/MANUAL.md @@ -271,6 +271,44 @@ Level = floor(sqrt(XP / 100)) Admins can set automatic role rewards at certain levels. When you reach the required level, you automatically receive the role! +### Achievements + +Create custom server achievements with various triggers: + +``` +/achievements create [name] [trigger] [value] +``` + +**Available Triggers:** +| Trigger | Description | +|---------|-------------| +| Level | Reach a specific level | +| Prestige | Reach a prestige level | +| Total XP | Earn total XP amount | +| Messages | Send X messages | +| Reactions Given | Give X reactions | +| Reactions Received | Receive X reactions | +| Voice Minutes | Spend X minutes in voice | +| Daily Streak | Achieve X day streak | +| Commands Used | Use X commands | + +**Achievement Options:** +- **Icon**: Custom emoji for the achievement +- **Description**: Custom description +- **Reward XP**: XP bonus when earned +- **Reward Role**: Role awarded when earned +- **Hidden**: Hide until earned (secret achievements) + +**Commands:** +``` +/achievements create [name] [trigger] [value] # Create achievement +/achievements delete [name] # Delete achievement +/achievements list # View all server achievements +/achievements view [@user] # View earned achievements +/achievements grant @user [name] # Manually grant achievement +/achievements revoke @user [name] # Revoke achievement +``` + --- ## Moderation Tools diff --git a/aethex-bot/listeners/reactionXp.js b/aethex-bot/listeners/reactionXp.js index 354f747..8b96fee 100644 --- a/aethex-bot/listeners/reactionXp.js +++ b/aethex-bot/listeners/reactionXp.js @@ -1,4 +1,5 @@ -const { checkMilestoneRoles, calculateLevel } = require('./xpTracker'); +const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats } = require('./xpTracker'); +const { checkAchievements } = require('../commands/achievements'); const reactionCooldowns = new Map(); const xpConfigCache = new Map(); @@ -53,12 +54,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, guildId); + await grantXp(supabase, user.id, giverXp, client, giverMember, config, guildId, 'giver'); reactionCooldowns.set(cooldownKeyGiver, now); } if (!receiverOnCooldown && receiverXp > 0) { - await grantXp(supabase, messageAuthor.id, receiverXp, client, receiverMember, config, guildId); + await grantXp(supabase, messageAuthor.id, receiverXp, client, receiverMember, config, guildId, 'receiver'); reactionCooldowns.set(cooldownKeyReceiver, now); reactionCooldowns.set(cooldownKeyMessage, now); } @@ -68,7 +69,7 @@ module.exports = { } }; -async function grantXp(supabase, discordUserId, xpAmount, client, member, config, guildId) { +async function grantXp(supabase, discordUserId, xpAmount, client, member, config, guildId, reactionType) { const { data: link, error: linkError } = await supabase .from('discord_links') .select('user_id') @@ -79,7 +80,7 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config const { data: profile, error: profileError } = await supabase .from('user_profiles') - .select('xp, prestige_level, total_xp_earned') + .select('xp, prestige_level, total_xp_earned, daily_streak') .eq('id', link.user_id) .maybeSingle(); @@ -128,12 +129,27 @@ async function grantXp(supabase, discordUserId, xpAmount, client, member, config client.trackXP(finalXp); } + // Track reaction stats + const statIncrement = reactionType === 'giver' + ? { reactionsGiven: 1 } + : { reactionsReceived: 1 }; + await updateUserStats(supabase, link.user_id, guildId, statIncrement); + if (member && guildId) { await checkMilestoneRoles(member, { level: newLevel, prestige: prestige, totalXp: totalEarned }, supabase, guildId, newLevel <= oldLevel); + + // Get updated stats and check achievements + const stats = await getUserStats(supabase, link.user_id, guildId); + stats.level = newLevel; + stats.prestige = prestige; + stats.totalXp = totalEarned; + stats.dailyStreak = profile.daily_streak || 0; + + await checkAchievements(link.user_id, member, stats, supabase, guildId, client); } } diff --git a/aethex-bot/listeners/voiceXp.js b/aethex-bot/listeners/voiceXp.js index b58f370..3a57670 100644 --- a/aethex-bot/listeners/voiceXp.js +++ b/aethex-bot/listeners/voiceXp.js @@ -1,4 +1,5 @@ -const { checkMilestoneRoles, calculateLevel } = require('./xpTracker'); +const { checkMilestoneRoles, calculateLevel, updateUserStats, getUserStats } = require('./xpTracker'); +const { checkAchievements } = require('../commands/achievements'); const voiceSessions = new Map(); const voiceConfigCache = new Map(); @@ -151,7 +152,7 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m const { data: profile, error: profileError } = await supabase .from('user_profiles') - .select('xp, prestige_level, total_xp_earned') + .select('xp, prestige_level, total_xp_earned, daily_streak') .eq('id', link.user_id) .maybeSingle(); @@ -168,14 +169,12 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m } } - // Server boosters get 1.5x XP bonus automatically if (member?.premiumSince) { highestMultiplier = Math.max(highestMultiplier, 1.5); } xpGain = Math.floor(xpGain * highestMultiplier); - // Apply prestige bonus (+5% per prestige level) if (prestige > 0) { const prestigeBonus = 1 + (prestige * 0.05); xpGain = Math.floor(xpGain * prestigeBonus); @@ -199,6 +198,9 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m client.trackXP(xpGain); } + // Track voice minutes for achievements + await updateUserStats(supabase, link.user_id, guildId, { voiceMinutes: minutes }); + if (member) { if (newLevel > oldLevel) { const serverConfig = client.serverConfigs?.get(guildId); @@ -219,6 +221,15 @@ async function grantVoiceXp(supabase, client, guildId, userId, config, member, m prestige: prestige, totalXp: totalEarned }, supabase, guildId, newLevel <= oldLevel); + + // Get updated stats and check achievements + const stats = await getUserStats(supabase, link.user_id, guildId); + stats.level = newLevel; + stats.prestige = prestige; + stats.totalXp = totalEarned; + stats.dailyStreak = profile.daily_streak || 0; + + await checkAchievements(link.user_id, member, stats, supabase, guildId, client); } } catch (error) { diff --git a/aethex-bot/listeners/xpTracker.js b/aethex-bot/listeners/xpTracker.js index fffe3a8..c5378f1 100644 --- a/aethex-bot/listeners/xpTracker.js +++ b/aethex-bot/listeners/xpTracker.js @@ -1,4 +1,5 @@ const { EmbedBuilder } = require('discord.js'); +const { checkAchievements } = require('../commands/achievements'); const xpCooldowns = new Map(); const xpConfigCache = new Map(); @@ -103,6 +104,23 @@ module.exports = { client.trackXP(xpGain); } + // Update user stats for achievements + await updateUserStats(supabase, link.user_id, guildId, { messages: 1 }); + + // Get updated stats for achievement checking + const stats = await getUserStats(supabase, link.user_id, guildId); + stats.level = newLevel; + stats.prestige = prestige; + stats.totalXp = totalEarned; + + // Get daily streak from profile + const { data: fullProfile } = await supabase + .from('user_profiles') + .select('daily_streak') + .eq('id', link.user_id) + .maybeSingle(); + stats.dailyStreak = fullProfile?.daily_streak || 0; + if (newLevel > oldLevel) { await sendLevelUpAnnouncement(message, newLevel, newXp, config, client); await checkMilestoneRoles(message.member, { @@ -118,6 +136,9 @@ module.exports = { }, supabase, guildId, true); } + // Check for achievements + await checkAchievements(link.user_id, message.member, stats, supabase, guildId, client); + } catch (error) { console.error('XP tracking error:', error.message); } @@ -291,6 +312,69 @@ async function checkMilestoneRoles(member, milestones, supabase, guildId, xpOnly } } +async function updateUserStats(supabase, userId, guildId, increments) { + try { + const { data: existing } = await supabase + .from('user_stats') + .select('*') + .eq('user_id', userId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (existing) { + const updates = { updated_at: new Date().toISOString() }; + if (increments.messages) updates.messages = (existing.messages || 0) + increments.messages; + if (increments.reactionsGiven) updates.reactions_given = (existing.reactions_given || 0) + increments.reactionsGiven; + if (increments.reactionsReceived) updates.reactions_received = (existing.reactions_received || 0) + increments.reactionsReceived; + if (increments.voiceMinutes) updates.voice_minutes = (existing.voice_minutes || 0) + increments.voiceMinutes; + if (increments.commandsUsed) updates.commands_used = (existing.commands_used || 0) + increments.commandsUsed; + + await supabase + .from('user_stats') + .update(updates) + .eq('user_id', userId) + .eq('guild_id', guildId); + } else { + await supabase + .from('user_stats') + .insert({ + user_id: userId, + guild_id: guildId, + messages: increments.messages || 0, + reactions_given: increments.reactionsGiven || 0, + reactions_received: increments.reactionsReceived || 0, + voice_minutes: increments.voiceMinutes || 0, + commands_used: increments.commandsUsed || 0 + }); + } + } catch (e) { + // Table may not exist - silently ignore + } +} + +async function getUserStats(supabase, userId, guildId) { + try { + const { data } = await supabase + .from('user_stats') + .select('*') + .eq('user_id', userId) + .eq('guild_id', guildId) + .maybeSingle(); + + return { + messages: data?.messages || 0, + reactionsGiven: data?.reactions_given || 0, + reactionsReceived: data?.reactions_received || 0, + voiceMinutes: data?.voice_minutes || 0, + commandsUsed: data?.commands_used || 0 + }; + } catch (e) { + return { messages: 0, reactionsGiven: 0, reactionsReceived: 0, voiceMinutes: 0, commandsUsed: 0 }; + } +} + // Export functions for use in other commands module.exports.calculateLevel = calculateLevel; module.exports.checkMilestoneRoles = checkMilestoneRoles; +module.exports.updateUserStats = updateUserStats; +module.exports.getUserStats = getUserStats; diff --git a/replit.md b/replit.md index 905046d..263ebd8 100644 --- a/replit.md +++ b/replit.md @@ -30,15 +30,16 @@ The bot is built on **Node.js 20** using the **discord.js v14** framework. It fo - **Web Dashboard**: A `dashboard.html` file in the `public/` directory is available for potential web-based interactions or monitoring. **Feature Specifications:** -- **38 Commands**: Covering community, leveling, moderation, utility, admin, cross-platform, and security functions. +- **40 Commands**: Covering community, leveling, moderation, utility, admin, cross-platform, and security functions. - **Rich Embeds**: Used extensively for welcome/goodbye messages, user profiles, server info, and announcements. - **Configurable Auto-moderation**: Settings for link, spam, badword, invite, and mention filtering with adjustable actions. - **Scheduled Messages**: Allows scheduling timed announcements with support for embeds. - **Giveaway System**: Automated creation, management, and rerolling of giveaways. +- **Achievement System**: Custom server achievements with various triggers (level, prestige, XP, messages, reactions, voice time, daily streak, commands used), XP rewards, role rewards, and hidden achievements. ## External Dependencies -- **Database**: Supabase (used for `server_config`, `warnings`, `mod_actions`, `level_roles`, `role_panels`, `giveaways`, `scheduled_messages`, `automod_config`, and `user_profiles` tables). +- **Database**: Supabase (used for `server_config`, `warnings`, `mod_actions`, `level_roles`, `role_panels`, `giveaways`, `scheduled_messages`, `automod_config`, `user_profiles`, `achievements`, `user_achievements`, and `user_stats` tables). - **Discord API**: `discord.js v14` for interacting with the Discord platform. - **AeThex.studio**: Integration for viewing user profiles. - **AeThex.foundation**: Integration for viewing user contributions. \ No newline at end of file