From f96043dc2141abe4d8722d1a295b26f682f4e3dd Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Mon, 8 Dec 2025 21:05:23 +0000 Subject: [PATCH] Add reaction-based XP rewards to the bot and update documentation Introduce a new listener for message reaction events to grant XP to users, alongside updates to the XP settings command, database schema, and documentation to support this feature. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 03f44698-7f39-483b-b5d1-803ddc2519c6 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/ZjyNKqu Replit-Helium-Checkpoint-Created: true --- aethex-bot/bot.js | 3 +- aethex-bot/commands/xp-settings.js | 125 +++++++++++++++++++++++++- aethex-bot/listeners/reactionXp.js | 140 +++++++++++++++++++++++++++++ replit.md | 17 +++- 4 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 aethex-bot/listeners/reactionXp.js diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 917d71f..5f9206f 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -49,6 +49,7 @@ const client = new Client({ GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMessageReactions, ], }); @@ -678,7 +679,7 @@ if (fs.existsSync(sentinelPath)) { // ============================================================================= const listenersPath = path.join(__dirname, "listeners"); -const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js']; +const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js']; for (const file of generalListenerFiles) { const filePath = path.join(listenersPath, file); if (fs.existsSync(filePath)) { diff --git a/aethex-bot/commands/xp-settings.js b/aethex-bot/commands/xp-settings.js index 08a3b0d..24f9131 100644 --- a/aethex-bot/commands/xp-settings.js +++ b/aethex-bot/commands/xp-settings.js @@ -70,7 +70,38 @@ module.exports = { { name: 'Easy - Faster leveling', value: 'easy' }, { name: 'Normal - Standard leveling', value: 'normal' }, { name: 'Hard - Slower leveling', value: 'hard' } - ))), + ))) + .addSubcommand(sub => + sub.setName('reaction-xp') + .setDescription('Configure XP for reactions') + .addIntegerOption(opt => + opt.setName('received') + .setDescription('XP for receiving a reaction (0-20)') + .setRequired(true) + .setMinValue(0) + .setMaxValue(20)) + .addIntegerOption(opt => + opt.setName('given') + .setDescription('XP for giving a reaction (0-10)') + .setRequired(true) + .setMinValue(0) + .setMaxValue(10))) + .addSubcommand(sub => + sub.setName('reaction-cooldown') + .setDescription('Set cooldown between reaction XP gains') + .addIntegerOption(opt => + opt.setName('seconds') + .setDescription('Cooldown in seconds (5-120)') + .setRequired(true) + .setMinValue(5) + .setMaxValue(120))) + .addSubcommand(sub => + sub.setName('reaction-toggle') + .setDescription('Enable or disable reaction XP') + .addBooleanOption(opt => + opt.setName('enabled') + .setDescription('Enable or disable reaction XP') + .setRequired(true))), async execute(interaction, client, supabase) { if (!supabase) { @@ -101,6 +132,12 @@ module.exports = { return handleToggle(interaction, supabase, guildId, config); case 'level-curve': return handleLevelCurve(interaction, supabase, guildId, config); + case 'reaction-xp': + return handleReactionXp(interaction, supabase, guildId, config); + case 'reaction-cooldown': + return handleReactionCooldown(interaction, supabase, guildId, config); + case 'reaction-toggle': + return handleReactionToggle(interaction, supabase, guildId, config); } } }; @@ -124,7 +161,11 @@ async function getXpConfig(supabase, guildId) { multiplier_roles: [], bonus_channels: [], level_curve: 'normal', - xp_enabled: true + xp_enabled: true, + reaction_xp_enabled: true, + reaction_xp_received: 3, + reaction_xp_given: 1, + reaction_cooldown: 30 }; } @@ -138,7 +179,11 @@ async function getXpConfig(supabase, guildId) { multiplier_roles: [], bonus_channels: [], level_curve: 'normal', - xp_enabled: true + xp_enabled: true, + reaction_xp_enabled: true, + reaction_xp_received: 3, + reaction_xp_given: 1, + reaction_cooldown: 30 }; } } @@ -155,6 +200,10 @@ async function saveXpConfig(supabase, guildId, config) { bonus_channels: config.bonus_channels, level_curve: config.level_curve, xp_enabled: config.xp_enabled, + reaction_xp_enabled: config.reaction_xp_enabled, + reaction_xp_received: config.reaction_xp_received, + reaction_xp_given: config.reaction_xp_given, + reaction_cooldown: config.reaction_cooldown, updated_at: new Date().toISOString() }); @@ -184,6 +233,11 @@ async function handleView(interaction, config) { hard: 'Hard (200 XP per level²)' }; + const reactionEnabled = config.reaction_xp_enabled !== false; + const reactionReceived = config.reaction_xp_received ?? 3; + const reactionGiven = config.reaction_xp_given ?? 1; + const reactionCooldown = config.reaction_cooldown ?? 30; + const embed = new EmbedBuilder() .setTitle('⚙️ XP Settings') .setColor(config.xp_enabled ? 0x00ff88 : 0xff4444) @@ -193,7 +247,11 @@ async function handleView(interaction, config) { { name: '⏱️ Cooldown', value: `${config.message_cooldown}s`, inline: true }, { name: '📈 Level Curve', value: curveInfo[config.level_curve] || 'Normal', inline: false }, { name: '🎭 Multiplier Roles', value: rolesText, inline: false }, - { name: '📢 Bonus Channels', value: channelsText, inline: false } + { name: '📢 Bonus Channels', value: channelsText, inline: false }, + { name: '😀 Reaction XP', value: reactionEnabled ? '✅ Enabled' : '❌ Disabled', inline: true }, + { name: '📥 Receive Reaction', value: `${reactionReceived} XP`, inline: true }, + { name: '📤 Give Reaction', value: `${reactionGiven} XP`, inline: true }, + { name: '⏳ Reaction Cooldown', value: `${reactionCooldown}s`, inline: true } ) .setFooter({ text: 'Use /xp-settings subcommands to modify' }) .setTimestamp(); @@ -376,3 +434,62 @@ async function handleLevelCurve(interaction, supabase, guildId, config) { ephemeral: true }); } + +async function handleReactionXp(interaction, supabase, guildId, config) { + const received = interaction.options.getInteger('received'); + const given = interaction.options.getInteger('given'); + + config.reaction_xp_received = received; + config.reaction_xp_given = given; + + const saved = await saveXpConfig(supabase, guildId, config); + if (!saved) { + return interaction.reply({ + content: '❌ Failed to save settings. Please try again.', + ephemeral: true + }); + } + + return interaction.reply({ + content: `✅ Reaction XP set: **${received} XP** for receiving, **${given} XP** for giving.`, + ephemeral: true + }); +} + +async function handleReactionCooldown(interaction, supabase, guildId, config) { + const seconds = interaction.options.getInteger('seconds'); + config.reaction_cooldown = seconds; + + const saved = await saveXpConfig(supabase, guildId, config); + if (!saved) { + return interaction.reply({ + content: '❌ Failed to save settings. Please try again.', + ephemeral: true + }); + } + + return interaction.reply({ + content: `✅ Reaction XP cooldown set to **${seconds} seconds**.`, + ephemeral: true + }); +} + +async function handleReactionToggle(interaction, supabase, guildId, config) { + const enabled = interaction.options.getBoolean('enabled'); + config.reaction_xp_enabled = enabled; + + const saved = await saveXpConfig(supabase, guildId, config); + if (!saved) { + return interaction.reply({ + content: '❌ Failed to save settings. Please try again.', + ephemeral: true + }); + } + + return interaction.reply({ + content: enabled + ? '✅ Reaction XP is now **enabled**.' + : '❌ Reaction XP is now **disabled**.', + ephemeral: true + }); +} diff --git a/aethex-bot/listeners/reactionXp.js b/aethex-bot/listeners/reactionXp.js new file mode 100644 index 0000000..cb48847 --- /dev/null +++ b/aethex-bot/listeners/reactionXp.js @@ -0,0 +1,140 @@ +const reactionCooldowns = new Map(); +const xpConfigCache = new Map(); +const CACHE_TTL = 60000; + +module.exports = { + name: 'messageReactionAdd', + + async execute(reaction, user, client, supabase) { + if (!supabase) return; + if (user.bot) return; + if (!reaction.message.guild) return; + + if (reaction.partial) { + try { + await reaction.fetch(); + } catch (error) { + console.error('Failed to fetch reaction:', error); + return; + } + } + + const messageAuthor = reaction.message.author; + if (!messageAuthor || messageAuthor.bot) return; + if (messageAuthor.id === user.id) return; + + const guildId = reaction.message.guild.id; + const now = Date.now(); + + const config = await getXpConfig(supabase, guildId); + + if (!config.xp_enabled) return; + if (!config.reaction_xp_enabled) return; + + const receiverXp = config.reaction_xp_received || 3; + const giverXp = config.reaction_xp_given || 1; + const cooldownMs = (config.reaction_cooldown || 30) * 1000; + + const cooldownKeyGiver = `reaction:giver:${guildId}:${user.id}`; + const cooldownKeyReceiver = `reaction:receiver:${guildId}:${messageAuthor.id}`; + const cooldownKeyMessage = `reaction:message:${guildId}:${reaction.message.id}`; + + const lastGiverXp = reactionCooldowns.get(cooldownKeyGiver) || 0; + const lastReceiverXp = reactionCooldowns.get(cooldownKeyReceiver) || 0; + const lastMessageXp = reactionCooldowns.get(cooldownKeyMessage) || 0; + + const giverOnCooldown = now - lastGiverXp < cooldownMs; + const receiverOnCooldown = now - lastReceiverXp < cooldownMs || now - lastMessageXp < cooldownMs; + + try { + if (!giverOnCooldown && giverXp > 0) { + await grantXp(supabase, user.id, giverXp, client); + reactionCooldowns.set(cooldownKeyGiver, now); + } + + if (!receiverOnCooldown && receiverXp > 0) { + await grantXp(supabase, messageAuthor.id, receiverXp, client); + reactionCooldowns.set(cooldownKeyReceiver, now); + reactionCooldowns.set(cooldownKeyMessage, now); + } + } catch (error) { + console.error('Reaction XP tracking error:', error.message); + } + } +}; + +async function grantXp(supabase, discordUserId, xpAmount, client) { + const { data: link, error: linkError } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', discordUserId) + .maybeSingle(); + + if (linkError || !link) return; + + const { data: profile, error: profileError } = await supabase + .from('user_profiles') + .select('xp') + .eq('id', link.user_id) + .maybeSingle(); + + if (profileError || !profile) return; + + const currentXp = profile.xp || 0; + const newXp = currentXp + xpAmount; + + const { error: updateError } = await supabase + .from('user_profiles') + .update({ xp: newXp }) + .eq('id', link.user_id); + + if (updateError) return; + + if (client.trackXP) { + client.trackXP(xpAmount); + } +} + +async function getXpConfig(supabase, guildId) { + const now = Date.now(); + const cached = xpConfigCache.get(guildId); + + if (cached && (now - cached.timestamp < CACHE_TTL)) { + return cached.config; + } + + try { + const { data, error } = await supabase + .from('xp_config') + .select('*') + .eq('guild_id', guildId) + .maybeSingle(); + + if (error) { + const defaults = getDefaultConfig(); + xpConfigCache.set(guildId, { config: defaults, timestamp: now }); + return defaults; + } + + const config = data || getDefaultConfig(); + xpConfigCache.set(guildId, { config, timestamp: now }); + return config; + } catch (e) { + return getDefaultConfig(); + } +} + +function getDefaultConfig() { + return { + message_xp: 5, + message_cooldown: 60, + multiplier_roles: [], + bonus_channels: [], + level_curve: 'normal', + xp_enabled: true, + reaction_xp_enabled: true, + reaction_xp_received: 3, + reaction_xp_given: 1, + reaction_cooldown: 30 + }; +} diff --git a/replit.md b/replit.md index 59ee086..7db4079 100644 --- a/replit.md +++ b/replit.md @@ -79,6 +79,7 @@ aethex-bot/ │ ├── welcome.js # Rich welcome messages + auto-role │ ├── goodbye.js # Rich goodbye messages │ ├── xpTracker.js # XP tracking on messages +│ ├── reactionXp.js # XP tracking for reactions │ └── sentinel/ │ ├── antiNuke.js # Channel delete monitor │ ├── roleDelete.js # Role delete monitor @@ -170,11 +171,23 @@ aethex-bot/ XP is earned across all platforms and stored in a single profile: -- **Discord Messages**: +5 XP per message (60s cooldown) +- **Discord Messages**: +5 XP per message (60s cooldown, configurable) +- **Reaction XP**: +3 XP for receiving reactions, +1 XP for giving (30s cooldown, configurable) - **Daily Claims**: +50 XP base + streak bonus (up to +100) - **Platform Activity**: Posts, likes, comments on AeThex sites -Level formula: `level = floor(sqrt(xp / 100))` +Level formula: `level = floor(sqrt(xp / base))` where base is 50 (easy), 100 (normal), or 200 (hard) + +### XP Configuration (/xp-settings) +- `message-xp` - Set XP per message (1-50) +- `cooldown` - Set message XP cooldown (10-300s) +- `reaction-xp` - Set XP for giving/receiving reactions +- `reaction-cooldown` - Set reaction XP cooldown (5-120s) +- `reaction-toggle` - Enable/disable reaction XP +- `multiplier-role` - Add role-based XP multipliers +- `bonus-channel` - Add channel-based XP bonuses +- `level-curve` - Set leveling difficulty (easy/normal/hard) +- `toggle` - Enable/disable XP system ## Supabase Tables Required