Implement a new achievement system for tracking user progress and rewards

Adds a comprehensive achievement system to the bot, including new triggers for various user actions, the ability to create, manage, and view achievements, and integration with existing XP and leveling systems. This also involves updating user statistics tracking to support achievement triggers.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 28bc7e36-c36d-4b62-b518-bcc2c649398e
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
This commit is contained in:
sirpiglr 2025-12-08 21:47:11 +00:00
parent 89de99044d
commit 4a39363dfd
9 changed files with 863 additions and 11 deletions

View file

@ -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,

View file

@ -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 [];
}
};

View file

@ -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) {

View file

@ -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.' });

View file

@ -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

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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;

View file

@ -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.