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:
parent
78647044bb
commit
f96043dc21
4 changed files with 278 additions and 7 deletions
|
|
@ -49,6 +49,7 @@ const client = new Client({
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
GatewayIntentBits.DirectMessages,
|
GatewayIntentBits.DirectMessages,
|
||||||
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -678,7 +679,7 @@ if (fs.existsSync(sentinelPath)) {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const listenersPath = path.join(__dirname, "listeners");
|
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) {
|
for (const file of generalListenerFiles) {
|
||||||
const filePath = path.join(listenersPath, file);
|
const filePath = path.join(listenersPath, file);
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,38 @@ module.exports = {
|
||||||
{ name: 'Easy - Faster leveling', value: 'easy' },
|
{ name: 'Easy - Faster leveling', value: 'easy' },
|
||||||
{ name: 'Normal - Standard leveling', value: 'normal' },
|
{ name: 'Normal - Standard leveling', value: 'normal' },
|
||||||
{ name: 'Hard - Slower leveling', value: 'hard' }
|
{ 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) {
|
async execute(interaction, client, supabase) {
|
||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
|
|
@ -101,6 +132,12 @@ module.exports = {
|
||||||
return handleToggle(interaction, supabase, guildId, config);
|
return handleToggle(interaction, supabase, guildId, config);
|
||||||
case 'level-curve':
|
case 'level-curve':
|
||||||
return handleLevelCurve(interaction, supabase, guildId, config);
|
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: [],
|
multiplier_roles: [],
|
||||||
bonus_channels: [],
|
bonus_channels: [],
|
||||||
level_curve: 'normal',
|
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: [],
|
multiplier_roles: [],
|
||||||
bonus_channels: [],
|
bonus_channels: [],
|
||||||
level_curve: 'normal',
|
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,
|
bonus_channels: config.bonus_channels,
|
||||||
level_curve: config.level_curve,
|
level_curve: config.level_curve,
|
||||||
xp_enabled: config.xp_enabled,
|
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()
|
updated_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,6 +233,11 @@ async function handleView(interaction, config) {
|
||||||
hard: 'Hard (200 XP per level²)'
|
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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('⚙️ XP Settings')
|
.setTitle('⚙️ XP Settings')
|
||||||
.setColor(config.xp_enabled ? 0x00ff88 : 0xff4444)
|
.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: '⏱️ Cooldown', value: `${config.message_cooldown}s`, inline: true },
|
||||||
{ name: '📈 Level Curve', value: curveInfo[config.level_curve] || 'Normal', inline: false },
|
{ name: '📈 Level Curve', value: curveInfo[config.level_curve] || 'Normal', inline: false },
|
||||||
{ name: '🎭 Multiplier Roles', value: rolesText, 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' })
|
.setFooter({ text: 'Use /xp-settings subcommands to modify' })
|
||||||
.setTimestamp();
|
.setTimestamp();
|
||||||
|
|
@ -376,3 +434,62 @@ async function handleLevelCurve(interaction, supabase, guildId, config) {
|
||||||
ephemeral: true
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
140
aethex-bot/listeners/reactionXp.js
Normal file
140
aethex-bot/listeners/reactionXp.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
17
replit.md
17
replit.md
|
|
@ -79,6 +79,7 @@ aethex-bot/
|
||||||
│ ├── welcome.js # Rich welcome messages + auto-role
|
│ ├── welcome.js # Rich welcome messages + auto-role
|
||||||
│ ├── goodbye.js # Rich goodbye messages
|
│ ├── goodbye.js # Rich goodbye messages
|
||||||
│ ├── xpTracker.js # XP tracking on messages
|
│ ├── xpTracker.js # XP tracking on messages
|
||||||
|
│ ├── reactionXp.js # XP tracking for reactions
|
||||||
│ └── sentinel/
|
│ └── sentinel/
|
||||||
│ ├── antiNuke.js # Channel delete monitor
|
│ ├── antiNuke.js # Channel delete monitor
|
||||||
│ ├── roleDelete.js # Role 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:
|
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)
|
- **Daily Claims**: +50 XP base + streak bonus (up to +100)
|
||||||
- **Platform Activity**: Posts, likes, comments on AeThex sites
|
- **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
|
## Supabase Tables Required
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue