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:
parent
f96043dc21
commit
49f6c9fe13
4 changed files with 393 additions and 4 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
280
aethex-bot/listeners/voiceXp.js
Normal file
280
aethex-bot/listeners/voiceXp.js
Normal 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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue