From 49f6c9fe130160aafb5c083fc51c31cd21775a2c Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Mon, 8 Dec 2025 21:13:14 +0000 Subject: [PATCH] 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 --- aethex-bot/bot.js | 3 +- aethex-bot/commands/xp-settings.js | 109 ++++++++++- aethex-bot/listeners/voiceXp.js | 280 +++++++++++++++++++++++++++++ replit.md | 5 + 4 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 aethex-bot/listeners/voiceXp.js diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 5f9206f..0515b36 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -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)) { diff --git a/aethex-bot/commands/xp-settings.js b/aethex-bot/commands/xp-settings.js index 24f9131..ce8fe90 100644 --- a/aethex-bot/commands/xp-settings.js +++ b/aethex-bot/commands/xp-settings.js @@ -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 + }); +} diff --git a/aethex-bot/listeners/voiceXp.js b/aethex-bot/listeners/voiceXp.js new file mode 100644 index 0000000..e4dfa0b --- /dev/null +++ b/aethex-bot/listeners/voiceXp.js @@ -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) { + } +} diff --git a/replit.md b/replit.md index 7db4079..a1c1c1a 100644 --- a/replit.md +++ b/replit.md @@ -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)