Add voice channel XP tracking and configuration options

Introduce voice XP tracking with interval-based awarding, configurable XP amounts, cooldowns, and enable/disable toggles via slash commands.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: a6df7c57-9848-41f4-b6cc-ef4096c91cf9
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:13:14 +00:00
parent f96043dc21
commit 49f6c9fe13
4 changed files with 393 additions and 4 deletions

View file

@ -50,6 +50,7 @@ const client = new Client({
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildVoiceStates,
],
});
@ -679,7 +680,7 @@ if (fs.existsSync(sentinelPath)) {
// =============================================================================
const listenersPath = path.join(__dirname, "listeners");
const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js'];
const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js', 'voiceXp.js'];
for (const file of generalListenerFiles) {
const filePath = path.join(listenersPath, file);
if (fs.existsSync(filePath)) {

View file

@ -101,6 +101,31 @@ module.exports = {
.addBooleanOption(opt =>
opt.setName('enabled')
.setDescription('Enable or disable reaction XP')
.setRequired(true)))
.addSubcommand(sub =>
sub.setName('voice-xp')
.setDescription('Set XP earned per minute in voice channels')
.addIntegerOption(opt =>
opt.setName('amount')
.setDescription('XP per minute (1-20)')
.setRequired(true)
.setMinValue(1)
.setMaxValue(20)))
.addSubcommand(sub =>
sub.setName('voice-cooldown')
.setDescription('Set cooldown between voice XP grants')
.addIntegerOption(opt =>
opt.setName('seconds')
.setDescription('Cooldown in seconds (30-300)')
.setRequired(true)
.setMinValue(30)
.setMaxValue(300)))
.addSubcommand(sub =>
sub.setName('voice-toggle')
.setDescription('Enable or disable voice XP')
.addBooleanOption(opt =>
opt.setName('enabled')
.setDescription('Enable or disable voice XP')
.setRequired(true))),
async execute(interaction, client, supabase) {
@ -138,6 +163,12 @@ module.exports = {
return handleReactionCooldown(interaction, supabase, guildId, config);
case 'reaction-toggle':
return handleReactionToggle(interaction, supabase, guildId, config);
case 'voice-xp':
return handleVoiceXp(interaction, supabase, guildId, config);
case 'voice-cooldown':
return handleVoiceCooldown(interaction, supabase, guildId, config);
case 'voice-toggle':
return handleVoiceToggle(interaction, supabase, guildId, config);
}
}
};
@ -165,7 +196,10 @@ async function getXpConfig(supabase, guildId) {
reaction_xp_enabled: true,
reaction_xp_received: 3,
reaction_xp_given: 1,
reaction_cooldown: 30
reaction_cooldown: 30,
voice_xp_enabled: true,
voice_xp: 2,
voice_cooldown: 60
};
}
@ -183,7 +217,10 @@ async function getXpConfig(supabase, guildId) {
reaction_xp_enabled: true,
reaction_xp_received: 3,
reaction_xp_given: 1,
reaction_cooldown: 30
reaction_cooldown: 30,
voice_xp_enabled: true,
voice_xp: 2,
voice_cooldown: 60
};
}
}
@ -204,6 +241,9 @@ async function saveXpConfig(supabase, guildId, config) {
reaction_xp_received: config.reaction_xp_received,
reaction_xp_given: config.reaction_xp_given,
reaction_cooldown: config.reaction_cooldown,
voice_xp_enabled: config.voice_xp_enabled,
voice_xp: config.voice_xp,
voice_cooldown: config.voice_cooldown,
updated_at: new Date().toISOString()
});
@ -238,6 +278,10 @@ async function handleView(interaction, config) {
const reactionGiven = config.reaction_xp_given ?? 1;
const reactionCooldown = config.reaction_cooldown ?? 30;
const voiceEnabled = config.voice_xp_enabled !== false;
const voiceXp = config.voice_xp ?? 2;
const voiceCooldown = config.voice_cooldown ?? 60;
const embed = new EmbedBuilder()
.setTitle('⚙️ XP Settings')
.setColor(config.xp_enabled ? 0x00ff88 : 0xff4444)
@ -251,7 +295,10 @@ async function handleView(interaction, config) {
{ 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 }
{ name: '⏳ Reaction Cooldown', value: `${reactionCooldown}s`, inline: true },
{ name: '🎤 Voice XP', value: voiceEnabled ? '✅ Enabled' : '❌ Disabled', inline: true },
{ name: '🔊 XP per Minute', value: `${voiceXp} XP`, inline: true },
{ name: '⏰ Voice Cooldown', value: `${voiceCooldown}s`, inline: true }
)
.setFooter({ text: 'Use /xp-settings subcommands to modify' })
.setTimestamp();
@ -493,3 +540,59 @@ async function handleReactionToggle(interaction, supabase, guildId, config) {
ephemeral: true
});
}
async function handleVoiceXp(interaction, supabase, guildId, config) {
const amount = interaction.options.getInteger('amount');
config.voice_xp = amount;
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: `✅ Voice XP set to **${amount} XP** per minute.`,
ephemeral: true
});
}
async function handleVoiceCooldown(interaction, supabase, guildId, config) {
const seconds = interaction.options.getInteger('seconds');
config.voice_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: `✅ Voice XP cooldown set to **${seconds} seconds**.`,
ephemeral: true
});
}
async function handleVoiceToggle(interaction, supabase, guildId, config) {
const enabled = interaction.options.getBoolean('enabled');
config.voice_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
? '✅ Voice XP is now **enabled**.'
: '❌ Voice XP is now **disabled**.',
ephemeral: true
});
}

View file

@ -0,0 +1,280 @@
const voiceSessions = new Map();
const voiceConfigCache = new Map();
const CACHE_TTL = 60000;
const XP_INTERVAL = 60000;
let xpInterval = null;
module.exports = {
name: 'voiceStateUpdate',
async execute(oldState, newState, client, supabase) {
if (!supabase) return;
const userId = newState.member?.id || oldState.member?.id;
if (!userId) return;
const member = newState.member || oldState.member;
if (member?.user?.bot) return;
const guildId = newState.guild?.id || oldState.guild?.id;
if (!guildId) return;
const wasInVoice = oldState.channelId !== null;
const isInVoice = newState.channelId !== null;
const now = Date.now();
const key = `${guildId}:${userId}`;
if (!wasInVoice && isInVoice) {
const config = await getVoiceConfig(supabase, guildId);
if (!config.voice_xp_enabled) return;
if (isAfkChannel(newState.channelId, newState.guild)) return;
voiceSessions.set(key, {
joinTime: now,
lastAwardTime: now,
guildId,
userId
});
startXpInterval(client, supabase);
}
else if (wasInVoice && !isInVoice) {
const session = voiceSessions.get(key);
if (session) {
await awardRemainingXp(supabase, client, session, member, now);
voiceSessions.delete(key);
}
}
else if (wasInVoice && isInVoice && oldState.channelId !== newState.channelId) {
const session = voiceSessions.get(key);
if (isAfkChannel(newState.channelId, newState.guild)) {
if (session) {
await awardRemainingXp(supabase, client, session, member, now);
voiceSessions.delete(key);
}
} else if (isAfkChannel(oldState.channelId, oldState.guild)) {
const config = await getVoiceConfig(supabase, guildId);
if (config.voice_xp_enabled) {
voiceSessions.set(key, {
joinTime: now,
lastAwardTime: now,
guildId,
userId
});
}
}
}
}
};
function startXpInterval(client, supabase) {
if (xpInterval) return;
xpInterval = setInterval(async () => {
if (voiceSessions.size === 0) {
clearInterval(xpInterval);
xpInterval = null;
return;
}
const now = Date.now();
for (const [key, session] of voiceSessions.entries()) {
const { guildId, userId, lastAwardTime } = session;
try {
const guild = client.guilds.cache.get(guildId);
if (!guild) continue;
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) continue;
const voiceState = member.voice;
if (!voiceState?.channelId) {
voiceSessions.delete(key);
continue;
}
if (isAfkChannel(voiceState.channelId, guild)) continue;
const config = await getVoiceConfig(supabase, guildId);
if (!config.voice_xp_enabled) continue;
const cooldownMs = (config.voice_cooldown || 60) * 1000;
const timeSinceLastAward = now - lastAwardTime;
if (timeSinceLastAward < cooldownMs) continue;
const minutesToAward = Math.floor(timeSinceLastAward / 60000);
if (minutesToAward < 1) continue;
await grantVoiceXp(supabase, client, guildId, userId, config, member, minutesToAward);
session.lastAwardTime += minutesToAward * 60000;
} catch (error) {
console.error('Voice XP interval error:', error.message);
}
}
}, XP_INTERVAL);
}
async function awardRemainingXp(supabase, client, session, member, now) {
const { guildId, userId, lastAwardTime } = session;
const config = await getVoiceConfig(supabase, guildId);
if (!config.voice_xp_enabled) return;
const timeSinceLastAward = now - lastAwardTime;
const minutesToAward = Math.floor(timeSinceLastAward / 60000);
if (minutesToAward < 1) return;
await grantVoiceXp(supabase, client, guildId, userId, config, member, minutesToAward);
}
async function grantVoiceXp(supabase, client, guildId, userId, config, member, minutes = 1) {
try {
const { data: link, error: linkError } = await supabase
.from('discord_links')
.select('user_id')
.eq('discord_id', userId)
.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;
let xpGain = (config.voice_xp || 2) * minutes;
const multiplierRoles = config.multiplier_roles || [];
let highestMultiplier = 1;
for (const mr of multiplierRoles) {
if (member?.roles?.cache?.has(mr.role_id)) {
highestMultiplier = Math.max(highestMultiplier, mr.multiplier);
}
}
xpGain = Math.floor(xpGain * highestMultiplier);
const currentXp = profile.xp || 0;
const newXp = currentXp + xpGain;
const oldLevel = calculateLevel(currentXp, config.level_curve);
const newLevel = calculateLevel(newXp, config.level_curve);
const { error: updateError } = await supabase
.from('user_profiles')
.update({ xp: newXp })
.eq('id', link.user_id);
if (updateError) return;
if (client.trackXP) {
client.trackXP(xpGain);
}
if (newLevel > oldLevel && member) {
const serverConfig = client.serverConfigs?.get(guildId);
const levelUpChannelId = serverConfig?.level_up_channel;
const levelUpMessage = `🎉 Congratulations ${member}! You reached **Level ${newLevel}** from voice chat activity!`;
if (levelUpChannelId) {
const channel = await client.channels.fetch(levelUpChannelId).catch(() => null);
if (channel) {
await channel.send(levelUpMessage);
}
}
await checkLevelRoles(member, newLevel, supabase, guildId);
}
} catch (error) {
console.error('Voice XP grant error:', error.message);
}
}
function isAfkChannel(channelId, guild) {
return guild?.afkChannelId === channelId;
}
async function getVoiceConfig(supabase, guildId) {
const now = Date.now();
const cached = voiceConfigCache.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();
voiceConfigCache.set(guildId, { config: defaults, timestamp: now });
return defaults;
}
const config = data || getDefaultConfig();
voiceConfigCache.set(guildId, { config, timestamp: now });
return config;
} catch (e) {
return getDefaultConfig();
}
}
function getDefaultConfig() {
return {
voice_xp: 2,
voice_cooldown: 60,
voice_xp_enabled: true,
multiplier_roles: [],
level_curve: 'normal'
};
}
function calculateLevel(xp, curve = 'normal') {
const bases = {
easy: 50,
normal: 100,
hard: 200
};
const base = bases[curve] || 100;
return Math.floor(Math.sqrt(xp / base));
}
async function checkLevelRoles(member, level, supabase, guildId) {
if (!member || !supabase) return;
try {
const { data: levelRoles, error } = await supabase
.from('level_roles')
.select('role_id, level_required')
.eq('guild_id', guildId)
.lte('level_required', level)
.order('level_required', { ascending: true });
if (error || !levelRoles || levelRoles.length === 0) return;
for (const lr of levelRoles) {
if (!member.roles.cache.has(lr.role_id)) {
await member.roles.add(lr.role_id).catch(() => {});
}
}
} catch (e) {
}
}

View file

@ -80,6 +80,7 @@ aethex-bot/
│ ├── goodbye.js # Rich goodbye messages
│ ├── xpTracker.js # XP tracking on messages
│ ├── reactionXp.js # XP tracking for reactions
│ ├── voiceXp.js # XP tracking for voice channels
│ └── sentinel/
│ ├── antiNuke.js # Channel delete monitor
│ ├── roleDelete.js # Role delete monitor
@ -173,6 +174,7 @@ XP is earned across all platforms and stored in a single profile:
- **Discord Messages**: +5 XP per message (60s cooldown, configurable)
- **Reaction XP**: +3 XP for receiving reactions, +1 XP for giving (30s cooldown, configurable)
- **Voice Chat XP**: +2 XP per minute in voice channels (60s cooldown, configurable)
- **Daily Claims**: +50 XP base + streak bonus (up to +100)
- **Platform Activity**: Posts, likes, comments on AeThex sites
@ -184,6 +186,9 @@ Level formula: `level = floor(sqrt(xp / base))` where base is 50 (easy), 100 (no
- `reaction-xp` - Set XP for giving/receiving reactions
- `reaction-cooldown` - Set reaction XP cooldown (5-120s)
- `reaction-toggle` - Enable/disable reaction XP
- `voice-xp` - Set XP per minute in voice channels (1-20)
- `voice-cooldown` - Set voice XP cooldown (30-300s)
- `voice-toggle` - Enable/disable voice XP
- `multiplier-role` - Add role-based XP multipliers
- `bonus-channel` - Add channel-based XP bonuses
- `level-curve` - Set leveling difficulty (easy/normal/hard)