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
This commit is contained in:
sirpiglr 2025-12-08 21:05:23 +00:00
parent 78647044bb
commit f96043dc21
4 changed files with 278 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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