From eec5b4f25655abb2eb948cfd5c99f2e9632138a1 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:21:09 -0500 Subject: [PATCH 1/9] feat: add voice_sessions migration (#135) --- migrations/004_voice_sessions.cjs | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 migrations/004_voice_sessions.cjs diff --git a/migrations/004_voice_sessions.cjs b/migrations/004_voice_sessions.cjs new file mode 100644 index 000000000..1e31bce3b --- /dev/null +++ b/migrations/004_voice_sessions.cjs @@ -0,0 +1,49 @@ +/** + * Migration 004: Voice Sessions Table + * + * Tracks voice channel activity for engagement metrics. + * Records join/leave/move events with duration. + * Gated behind voice.enabled in config (opt-in per guild). + */ + +'use strict'; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS voice_sessions ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + left_at TIMESTAMPTZ, + duration_seconds INTEGER, + CONSTRAINT chk_duration_nonneg CHECK (duration_seconds IS NULL OR duration_seconds >= 0) + ) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_voice_sessions_guild_user + ON voice_sessions(guild_id, user_id) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_voice_sessions_guild_joined + ON voice_sessions(guild_id, joined_at) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_voice_sessions_open + ON voice_sessions(guild_id, user_id) + WHERE left_at IS NULL + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_open`); + pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_guild_joined`); + pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_guild_user`); + pgm.sql(`DROP TABLE IF EXISTS voice_sessions`); +}; From ca0a499c9bae8d9321c0f7b02ceb05ef0553a9bb Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:21:57 -0500 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20add=20voice=20tracking=20module=20?= =?UTF-8?q?=E2=80=94=20join/leave/move/flush/leaderboard=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/voice.js | 400 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 src/modules/voice.js diff --git a/src/modules/voice.js b/src/modules/voice.js new file mode 100644 index 000000000..3b1a6b805 --- /dev/null +++ b/src/modules/voice.js @@ -0,0 +1,400 @@ +/** + * Voice Channel Activity Tracking Module + * + * Tracks join/leave/move events, calculates time spent in voice, + * and provides leaderboard data for most active voice users. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/135 + */ + +import { getPool } from '../db.js'; +import { error as logError, info } from '../logger.js'; +import { getConfig } from './config.js'; + +/** + * In-memory map of active voice sessions. + * Key: `${guildId}:${userId}` → { channelId, joinedAt (Date) } + * + * This is the source of truth for open sessions. Periodically flushed + * to DB so data is not lost on crash. + * + * @type {Map} + */ +const activeSessions = new Map(); + +/** Periodic flush interval handle */ +let flushInterval = null; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Build the session key from guild and user IDs. + * + * @param {string} guildId + * @param {string} userId + * @returns {string} + */ +function sessionKey(guildId, userId) { + return `${guildId}:${userId}`; +} + +/** + * Resolve voice config for a guild with defaults. + * + * @param {string} guildId + * @returns {object} + */ +function getVoiceConfig(guildId) { + const cfg = getConfig(guildId); + return { + enabled: false, + xpPerMinute: 2, + dailyXpCap: 120, + logChannel: null, + ...cfg?.voice, + }; +} + +// ─── Session management ─────────────────────────────────────────────────────── + +/** + * Open a new voice session for a user joining a channel. + * Inserts a pending row (left_at = NULL) into voice_sessions. + * + * @param {string} guildId + * @param {string} userId + * @param {string} channelId + * @returns {Promise} + */ +export async function openSession(guildId, userId, channelId) { + const key = sessionKey(guildId, userId); + + // Close any existing open session first (shouldn't happen, but be safe) + if (activeSessions.has(key)) { + await closeSession(guildId, userId); + } + + const joinedAt = new Date(); + activeSessions.set(key, { channelId, joinedAt }); + + try { + const pool = getPool(); + await pool.query( + `INSERT INTO voice_sessions (guild_id, user_id, channel_id, joined_at) + VALUES ($1, $2, $3, $4)`, + [guildId, userId, channelId, joinedAt.toISOString()], + ); + } catch (err) { + logError('Failed to insert voice session', { guildId, userId, channelId, error: err.message }); + throw err; + } +} + +/** + * Close an open voice session for a user leaving a channel. + * Updates the row with left_at and duration_seconds. + * + * @param {string} guildId + * @param {string} userId + * @returns {Promise} Duration in seconds, or null if no open session found. + */ +export async function closeSession(guildId, userId) { + const key = sessionKey(guildId, userId); + const session = activeSessions.get(key); + if (!session) return null; + + activeSessions.delete(key); + + const leftAt = new Date(); + const durationSeconds = Math.floor((leftAt.getTime() - session.joinedAt.getTime()) / 1000); + + try { + const pool = getPool(); + await pool.query( + `UPDATE voice_sessions + SET left_at = $1, duration_seconds = $2 + WHERE guild_id = $3 + AND user_id = $4 + AND channel_id = $5 + AND left_at IS NULL + ORDER BY joined_at DESC + LIMIT 1`, + [leftAt.toISOString(), durationSeconds, guildId, userId, session.channelId], + ); + } catch (err) { + logError('Failed to close voice session', { guildId, userId, error: err.message }); + throw err; + } + + return durationSeconds; +} + +// ─── voiceStateUpdate handler ───────────────────────────────────────────────── + +/** + * Handle a Discord voiceStateUpdate event. + * Covers join, leave, move, mute, deafen, and stream events. + * Only join/leave/move result in session changes. + * + * @param {import('discord.js').VoiceState} oldState + * @param {import('discord.js').VoiceState} newState + * @returns {Promise} + */ +export async function handleVoiceStateUpdate(oldState, newState) { + const guildId = newState.guild?.id ?? oldState.guild?.id; + const userId = newState.member?.user?.id ?? oldState.member?.user?.id; + + if (!guildId || !userId) return; + + // Skip bots + const isBot = newState.member?.user?.bot ?? oldState.member?.user?.bot; + if (isBot) return; + + const cfg = getVoiceConfig(guildId); + if (!cfg.enabled) return; + + const oldChannel = oldState.channelId; + const newChannel = newState.channelId; + + if (!oldChannel && newChannel) { + // User joined a voice channel + await openSession(guildId, userId, newChannel).catch((err) => + logError('openSession failed', { guildId, userId, error: err.message }), + ); + info('Voice join', { guildId, userId, channelId: newChannel }); + } else if (oldChannel && !newChannel) { + // User left all voice channels + await closeSession(guildId, userId).catch((err) => + logError('closeSession failed', { guildId, userId, error: err.message }), + ); + info('Voice leave', { guildId, userId, channelId: oldChannel }); + } else if (oldChannel && newChannel && oldChannel !== newChannel) { + // User moved between channels — close old session, open new + await closeSession(guildId, userId).catch((err) => + logError('closeSession(move) failed', { guildId, userId, error: err.message }), + ); + await openSession(guildId, userId, newChannel).catch((err) => + logError('openSession(move) failed', { guildId, userId, error: err.message }), + ); + info('Voice move', { guildId, userId, from: oldChannel, to: newChannel }); + } + // Mute/deafen/stream changes don't affect session tracking +} + +// ─── Leaderboard ────────────────────────────────────────────────────────────── + +/** + * Fetch voice time leaderboard for a guild. + * + * @param {string} guildId + * @param {object} [options] + * @param {number} [options.limit=10] - Max rows to return + * @param {'week'|'month'|'all'} [options.period='week'] - Time window + * @returns {Promise>} + */ +export async function getVoiceLeaderboard(guildId, { limit = 10, period = 'week' } = {}) { + const pool = getPool(); + + const windowSql = + period === 'week' + ? `AND joined_at >= NOW() - INTERVAL '7 days'` + : period === 'month' + ? `AND joined_at >= NOW() - INTERVAL '30 days'` + : ''; + + const { rows } = await pool.query( + `SELECT user_id, + SUM(COALESCE(duration_seconds, 0)) AS total_seconds, + COUNT(*) AS session_count + FROM voice_sessions + WHERE guild_id = $1 + AND left_at IS NOT NULL + ${windowSql} + GROUP BY user_id + ORDER BY total_seconds DESC + LIMIT $2`, + [guildId, limit], + ); + + return rows.map((r) => ({ + user_id: r.user_id, + total_seconds: Number(r.total_seconds), + session_count: Number(r.session_count), + })); +} + +// ─── User stats ─────────────────────────────────────────────────────────────── + +/** + * Fetch total voice time stats for a specific user. + * + * @param {string} guildId + * @param {string} userId + * @returns {Promise<{ total_seconds: number; session_count: number; favorite_channel: string|null }>} + */ +export async function getUserVoiceStats(guildId, userId) { + const pool = getPool(); + + const [totals, favChannel] = await Promise.all([ + pool.query( + `SELECT COALESCE(SUM(duration_seconds), 0) AS total_seconds, + COUNT(*) AS session_count + FROM voice_sessions + WHERE guild_id = $1 + AND user_id = $2 + AND left_at IS NOT NULL`, + [guildId, userId], + ), + pool.query( + `SELECT channel_id, SUM(duration_seconds) AS total + FROM voice_sessions + WHERE guild_id = $1 + AND user_id = $2 + AND left_at IS NOT NULL + GROUP BY channel_id + ORDER BY total DESC + LIMIT 1`, + [guildId, userId], + ), + ]); + + return { + total_seconds: Number(totals.rows[0]?.total_seconds ?? 0), + session_count: Number(totals.rows[0]?.session_count ?? 0), + favorite_channel: favChannel.rows[0]?.channel_id ?? null, + }; +} + +// ─── Export ─────────────────────────────────────────────────────────────────── + +/** + * Export raw voice session data for a guild. + * Returns sessions ordered by most recent first. + * + * @param {string} guildId + * @param {object} [options] + * @param {'week'|'month'|'all'} [options.period='all'] - Time window + * @param {number} [options.limit=1000] - Max rows + * @returns {Promise>} + */ +export async function exportVoiceSessions(guildId, { period = 'all', limit = 1000 } = {}) { + const pool = getPool(); + + const windowSql = + period === 'week' + ? `AND joined_at >= NOW() - INTERVAL '7 days'` + : period === 'month' + ? `AND joined_at >= NOW() - INTERVAL '30 days'` + : ''; + + const { rows } = await pool.query( + `SELECT id, user_id, channel_id, joined_at, left_at, duration_seconds + FROM voice_sessions + WHERE guild_id = $1 + AND left_at IS NOT NULL + ${windowSql} + ORDER BY joined_at DESC + LIMIT $2`, + [guildId, limit], + ); + + return rows; +} + +// ─── Periodic flush ─────────────────────────────────────────────────────────── + +/** + * Flush all in-memory open sessions to DB without closing them. + * This is a heartbeat so we don't lose data if the process crashes. + * + * @returns {Promise} + */ +export async function flushActiveSessions() { + if (activeSessions.size === 0) return; + + const pool = getPool(); + const now = new Date(); + + for (const [key, session] of activeSessions) { + const [guildId, userId] = key.split(':'); + const partialDuration = Math.floor((now.getTime() - session.joinedAt.getTime()) / 1000); + + // Update duration_seconds without closing (left_at stays NULL) + await pool + .query( + `UPDATE voice_sessions + SET duration_seconds = $1 + WHERE guild_id = $2 + AND user_id = $3 + AND channel_id = $4 + AND left_at IS NULL`, + [partialDuration, guildId, userId, session.channelId], + ) + .catch((err) => + logError('Failed to flush voice session', { guildId, userId, error: err.message }), + ); + } +} + +/** + * Start periodic flush of in-memory sessions (every 5 minutes). + * + * @returns {void} + */ +export function startVoiceFlush() { + if (flushInterval) return; + flushInterval = setInterval(() => { + flushActiveSessions().catch((err) => + logError('Voice session flush error', { error: err.message }), + ); + }, 5 * 60 * 1000); + flushInterval.unref(); +} + +/** + * Stop periodic flush. + * + * @returns {void} + */ +export function stopVoiceFlush() { + if (flushInterval) { + clearInterval(flushInterval); + flushInterval = null; + } +} + +/** + * Get current active session count (for testing/diagnostics). + * + * @returns {number} + */ +export function getActiveSessionCount() { + return activeSessions.size; +} + +/** + * Clear all in-memory sessions (for testing only). + * + * @returns {void} + */ +export function clearActiveSessions() { + activeSessions.clear(); +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +/** + * Format a duration in seconds to a human-readable string. + * e.g. 3661 → "1h 1m" + * + * @param {number} seconds + * @returns {string} + */ +export function formatDuration(seconds) { + if (seconds < 60) return `${seconds}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h === 0) return `${m}m`; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +} From 24b16fa52ef31bd81c7621b1254693f5de97c72e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:22:34 -0500 Subject: [PATCH 3/9] feat: wire voiceStateUpdate handler into event registration (#135) --- src/modules/events.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/modules/events.js b/src/modules/events.js index c5cbb5e5d..cfdaae6da 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -42,6 +42,7 @@ import { ROLE_MENU_SELECT_ID, RULES_ACCEPT_BUTTON_ID, } from './welcomeOnboarding.js'; +import { handleVoiceStateUpdate } from './voice.js'; /** @type {boolean} Guard against duplicate process-level handler registration */ let processHandlersRegistered = false; @@ -734,6 +735,7 @@ export function registerEventHandlers(client, config, healthMonitor) { registerTicketCloseButtonHandler(client); registerReminderButtonHandler(client); registerWelcomeOnboardingHandlers(client); + registerVoiceStateHandler(client); registerErrorHandlers(client); } @@ -859,3 +861,16 @@ export function registerTicketCloseButtonHandler(client) { } }); } + +/** + * Register the voiceStateUpdate handler for voice channel activity tracking. + * + * @param {Client} client - Discord client instance + */ +export function registerVoiceStateHandler(client) { + client.on(Events.VoiceStateUpdate, async (oldState, newState) => { + await handleVoiceStateUpdate(oldState, newState).catch((err) => { + logError('Voice state update handler error', { error: err.message }); + }); + }); +} From 75de5f795cfb44292847d58a8358f7abefc34b1a Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:23:16 -0500 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20add=20/voice=20command=20=E2=80=94?= =?UTF-8?q?=20leaderboard,=20stats,=20export=20subcommands=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/voice.js | 225 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/commands/voice.js diff --git a/src/commands/voice.js b/src/commands/voice.js new file mode 100644 index 000000000..e18ac424b --- /dev/null +++ b/src/commands/voice.js @@ -0,0 +1,225 @@ +/** + * Voice Command + * Leaderboard, stats, and export for voice channel activity. + * + * Subcommands: + * /voice leaderboard [period] — top users by voice time + * /voice stats [user] — detailed stats for yourself or another user + * /voice export [period] — export raw session data as CSV (mod-only) + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/135 + */ + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { + exportVoiceSessions, + formatDuration, + getUserVoiceStats, + getVoiceLeaderboard, +} from '../modules/voice.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('voice') + .setDescription('Voice channel activity tracking and stats') + .addSubcommand((sub) => + sub + .setName('leaderboard') + .setDescription('Top members by voice time') + .addStringOption((opt) => + opt + .setName('period') + .setDescription('Time period (default: week)') + .setRequired(false) + .addChoices( + { name: 'This week', value: 'week' }, + { name: 'This month', value: 'month' }, + { name: 'All time', value: 'all' }, + ), + ), + ) + .addSubcommand((sub) => + sub + .setName('stats') + .setDescription('Voice time stats for a member') + .addUserOption((opt) => + opt + .setName('user') + .setDescription('Member to look up (default: yourself)') + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('export') + .setDescription('Export voice session data as CSV (moderators only)') + .addStringOption((opt) => + opt + .setName('period') + .setDescription('Time period to export (default: all)') + .setRequired(false) + .addChoices( + { name: 'This week', value: 'week' }, + { name: 'This month', value: 'month' }, + { name: 'All time', value: 'all' }, + ), + ), + ); + +/** + * Execute the /voice command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply(); + + const cfg = getConfig(interaction.guildId); + if (!cfg?.voice?.enabled) { + return safeEditReply(interaction, { + content: '🔇 Voice tracking is not enabled on this server.', + }); + } + + const sub = interaction.options.getSubcommand(); + + if (sub === 'leaderboard') return handleLeaderboard(interaction); + if (sub === 'stats') return handleStats(interaction); + if (sub === 'export') return handleExport(interaction); +} + +// ─── /voice leaderboard ─────────────────────────────────────────────────────── + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleLeaderboard(interaction) { + const period = interaction.options.getString('period') ?? 'week'; + + try { + const rows = await getVoiceLeaderboard(interaction.guildId, { limit: 10, period }); + + if (rows.length === 0) { + return safeEditReply(interaction, { + content: '📭 No voice activity recorded yet.', + }); + } + + // Batch-fetch display names + const memberMap = new Map(); + try { + const members = await interaction.guild.members.fetch({ user: rows.map((r) => r.user_id) }); + for (const [id, member] of members) memberMap.set(id, member.displayName); + } catch { + // Fall back to mention format + } + + const periodLabel = period === 'week' ? 'This Week' : period === 'month' ? 'This Month' : 'All Time'; + + const lines = rows.map((row, i) => { + const displayName = memberMap.get(row.user_id) ?? `<@${row.user_id}>`; + const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `**${i + 1}.**`; + const time = formatDuration(row.total_seconds); + return `${medal} ${displayName} — ${time} (${row.session_count} session${row.session_count !== 1 ? 's' : ''})`; + }); + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`🎙️ Voice Leaderboard — ${periodLabel}`) + .setDescription(lines.join('\n')) + .setFooter({ text: `Top ${rows.length} members by voice time` }) + .setTimestamp(); + + return safeEditReply(interaction, { embeds: [embed] }); + } catch (err) { + logError('Voice leaderboard failed', { error: err.message, stack: err.stack }); + return safeEditReply(interaction, { + content: '❌ Something went wrong fetching the voice leaderboard.', + }); + } +} + +// ─── /voice stats ───────────────────────────────────────────────────────────── + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleStats(interaction) { + const targetUser = interaction.options.getUser('user') ?? interaction.user; + const guildId = interaction.guildId; + + try { + const stats = await getUserVoiceStats(guildId, targetUser.id); + + const totalTime = formatDuration(stats.total_seconds); + const favChannel = stats.favorite_channel ? `<#${stats.favorite_channel}>` : 'N/A'; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`🎙️ Voice Stats — ${targetUser.displayName ?? targetUser.username}`) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: 'Total Voice Time', value: totalTime, inline: true }, + { name: 'Sessions', value: String(stats.session_count), inline: true }, + { name: 'Favourite Channel', value: favChannel, inline: true }, + ) + .setTimestamp(); + + return safeEditReply(interaction, { embeds: [embed] }); + } catch (err) { + logError('Voice stats failed', { error: err.message, stack: err.stack }); + return safeEditReply(interaction, { + content: '❌ Something went wrong fetching voice stats.', + }); + } +} + +// ─── /voice export ──────────────────────────────────────────────────────────── + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleExport(interaction) { + // Require moderator permission (ManageGuild or Administrator) + if (!interaction.memberPermissions?.has('ManageGuild')) { + return safeEditReply(interaction, { + content: '❌ You need the **Manage Server** permission to export voice data.', + }); + } + + const period = interaction.options.getString('period') ?? 'all'; + + try { + const sessions = await exportVoiceSessions(interaction.guildId, { period, limit: 5000 }); + + if (sessions.length === 0) { + return safeEditReply(interaction, { content: '📭 No voice sessions found for that period.' }); + } + + // Build CSV + const csvLines = [ + 'id,user_id,channel_id,joined_at,left_at,duration_seconds', + ...sessions.map( + (s) => + `${s.id},${s.user_id},${s.channel_id},${s.joined_at?.toISOString() ?? ''},${s.left_at?.toISOString() ?? ''},${s.duration_seconds ?? ''}`, + ), + ]; + const csv = csvLines.join('\n'); + const buffer = Buffer.from(csv, 'utf-8'); + + const periodLabel = period === 'week' ? 'week' : period === 'month' ? 'month' : 'all-time'; + const filename = `voice-sessions-${interaction.guildId}-${periodLabel}.csv`; + + return safeEditReply(interaction, { + content: `📊 Voice session export — **${sessions.length}** sessions (${periodLabel})`, + files: [{ attachment: buffer, name: filename }], + }); + } catch (err) { + logError('Voice export failed', { error: err.message, stack: err.stack }); + return safeEditReply(interaction, { + content: '❌ Something went wrong exporting voice data.', + }); + } +} From f8c5a3dac272e10b8512d4b9f419435a09f0643a Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:23:56 -0500 Subject: [PATCH 5/9] feat: add voice config defaults to config.json (#135) --- config.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config.json b/config.json index c2340292b..eca6cca3f 100644 --- a/config.json +++ b/config.json @@ -281,5 +281,11 @@ "reminders": { "enabled": false, "maxPerUser": 25 + }, + "voice": { + "enabled": false, + "xpPerMinute": 2, + "dailyXpCap": 120, + "logChannel": null } -} +} \ No newline at end of file From 2c3b149dc9e545c48c2876e1e71f37844afcc16e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:25:01 -0500 Subject: [PATCH 6/9] feat: wire voice flush start/stop into bot lifecycle (#135) --- src/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.js b/src/index.js index 22197c30c..b5e89f321 100644 --- a/src/index.js +++ b/src/index.js @@ -51,6 +51,7 @@ import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderatio import { loadOptOuts } from './modules/optout.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; import { startTriage, stopTriage } from './modules/triage.js'; +import { startVoiceFlush, stopVoiceFlush } from './modules/voice.js'; import { closeRedisClient as closeRedis, initRedis } from './redis.js'; import { pruneOldLogs } from './transports/postgres.js'; import { stopCacheCleanup } from './utils/cache.js'; @@ -277,6 +278,7 @@ async function gracefulShutdown(signal) { stopTempbanScheduler(); stopScheduler(); stopGithubFeed(); + stopVoiceFlush(); // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) try { @@ -467,6 +469,7 @@ async function startup() { startTempbanScheduler(client); startScheduler(client); startGithubFeed(client); + startVoiceFlush(); } // Load commands and login From 8edf27b8e48087a9f82ddbde7f3a170a2f91cc97 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:25:39 -0500 Subject: [PATCH 7/9] feat: add voice to config API allowlist (#135) --- src/api/utils/configAllowlist.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index 18534917b..600ba6305 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -22,6 +22,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'afk', 'reputation', 'engagement', + 'voice', 'github', 'challenges', 'review', From 24c3bb58470befdd8908b1b068966ddfd2155a9e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:29:38 -0500 Subject: [PATCH 8/9] fix: SQL UPDATE subquery for closeSession, fix import order (#135) --- src/commands/voice.js | 3 +- src/modules/events.js | 2 +- src/modules/voice.js | 30 ++- tests/modules/voice.test.js | 423 ++++++++++++++++++++++++++++++++++++ 4 files changed, 444 insertions(+), 14 deletions(-) create mode 100644 tests/modules/voice.test.js diff --git a/src/commands/voice.js b/src/commands/voice.js index e18ac424b..a9e112e24 100644 --- a/src/commands/voice.js +++ b/src/commands/voice.js @@ -116,7 +116,8 @@ async function handleLeaderboard(interaction) { // Fall back to mention format } - const periodLabel = period === 'week' ? 'This Week' : period === 'month' ? 'This Month' : 'All Time'; + const periodLabel = + period === 'week' ? 'This Week' : period === 'month' ? 'This Month' : 'All Time'; const lines = rows.map((row, i) => { const displayName = memberMap.get(row.user_id) ?? `<@${row.user_id}>`; diff --git a/src/modules/events.js b/src/modules/events.js index cfdaae6da..3b9a8f316 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -35,6 +35,7 @@ import { isSpam, sendSpamAlert } from './spam.js'; import { handleReactionAdd, handleReactionRemove } from './starboard.js'; import { closeTicket, getTicketConfig, openTicket } from './ticketHandler.js'; import { accumulateMessage, evaluateNow } from './triage.js'; +import { handleVoiceStateUpdate } from './voice.js'; import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; import { handleRoleMenuSelection, @@ -42,7 +43,6 @@ import { ROLE_MENU_SELECT_ID, RULES_ACCEPT_BUTTON_ID, } from './welcomeOnboarding.js'; -import { handleVoiceStateUpdate } from './voice.js'; /** @type {boolean} Guard against duplicate process-level handler registration */ let processHandlersRegistered = false; diff --git a/src/modules/voice.js b/src/modules/voice.js index 3b1a6b805..8f161b757 100644 --- a/src/modules/voice.js +++ b/src/modules/voice.js @@ -8,7 +8,7 @@ */ import { getPool } from '../db.js'; -import { error as logError, info } from '../logger.js'; +import { info, error as logError } from '../logger.js'; import { getConfig } from './config.js'; /** @@ -113,12 +113,15 @@ export async function closeSession(guildId, userId) { await pool.query( `UPDATE voice_sessions SET left_at = $1, duration_seconds = $2 - WHERE guild_id = $3 - AND user_id = $4 - AND channel_id = $5 - AND left_at IS NULL - ORDER BY joined_at DESC - LIMIT 1`, + WHERE id = ( + SELECT id FROM voice_sessions + WHERE guild_id = $3 + AND user_id = $4 + AND channel_id = $5 + AND left_at IS NULL + ORDER BY joined_at DESC + LIMIT 1 + )`, [leftAt.toISOString(), durationSeconds, guildId, userId, session.channelId], ); } catch (err) { @@ -343,11 +346,14 @@ export async function flushActiveSessions() { */ export function startVoiceFlush() { if (flushInterval) return; - flushInterval = setInterval(() => { - flushActiveSessions().catch((err) => - logError('Voice session flush error', { error: err.message }), - ); - }, 5 * 60 * 1000); + flushInterval = setInterval( + () => { + flushActiveSessions().catch((err) => + logError('Voice session flush error', { error: err.message }), + ); + }, + 5 * 60 * 1000, + ); flushInterval.unref(); } diff --git a/tests/modules/voice.test.js b/tests/modules/voice.test.js new file mode 100644 index 000000000..c6d96d2af --- /dev/null +++ b/tests/modules/voice.test.js @@ -0,0 +1,423 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ voice: { enabled: true } }), +})); + +import { getPool } from '../../src/db.js'; +import { getConfig } from '../../src/modules/config.js'; +import { + clearActiveSessions, + closeSession, + exportVoiceSessions, + flushActiveSessions, + formatDuration, + getActiveSessionCount, + getUserVoiceStats, + getVoiceLeaderboard, + handleVoiceStateUpdate, + openSession, + startVoiceFlush, + stopVoiceFlush, +} from '../../src/modules/voice.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makePool({ queryResult = { rows: [], rowCount: 1 } } = {}) { + return { + query: vi.fn().mockResolvedValue(queryResult), + }; +} + +function makeVoiceState({ guildId, userId, channelId, isBot = false } = {}) { + return { + channelId: channelId ?? null, + guild: { id: guildId ?? 'guild1' }, + member: { + user: { id: userId ?? 'user1', bot: isBot }, + }, + }; +} + +// ─── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + clearActiveSessions(); + getConfig.mockReturnValue({ voice: { enabled: true } }); +}); + +afterEach(() => { + stopVoiceFlush(); + clearActiveSessions(); +}); + +// ─── formatDuration ─────────────────────────────────────────────────────────── + +describe('formatDuration', () => { + it('formats seconds under 1 minute', () => { + expect(formatDuration(45)).toBe('45s'); + }); + + it('formats minutes only', () => { + expect(formatDuration(90)).toBe('1m'); + expect(formatDuration(3599)).toBe('59m'); + }); + + it('formats hours only (no remainder minutes)', () => { + expect(formatDuration(7200)).toBe('2h'); + }); + + it('formats hours and minutes', () => { + expect(formatDuration(3661)).toBe('1h 1m'); + }); + + it('handles 0 seconds', () => { + expect(formatDuration(0)).toBe('0s'); + }); +}); + +// ─── openSession ────────────────────────────────────────────────────────────── + +describe('openSession', () => { + it('inserts a row and stores in-memory session', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + await openSession('g1', 'u1', 'ch1'); + + expect(pool.query).toHaveBeenCalledTimes(1); + const [sql, params] = pool.query.mock.calls[0]; + expect(sql).toMatch(/INSERT INTO voice_sessions/); + expect(params).toEqual(expect.arrayContaining(['g1', 'u1', 'ch1'])); + expect(getActiveSessionCount()).toBe(1); + }); + + it('closes existing session before opening a new one', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + await openSession('g1', 'u1', 'ch1'); + await openSession('g1', 'u1', 'ch2'); + + // First call: INSERT (open ch1) + // Second call: UPDATE (close ch1) + INSERT (open ch2) + expect(pool.query).toHaveBeenCalledTimes(3); + expect(getActiveSessionCount()).toBe(1); + }); + + it('throws and propagates DB errors', async () => { + const pool = { query: vi.fn().mockRejectedValue(new Error('DB error')) }; + getPool.mockReturnValue(pool); + + await expect(openSession('g1', 'u1', 'ch1')).rejects.toThrow('DB error'); + }); +}); + +// ─── closeSession ───────────────────────────────────────────────────────────── + +describe('closeSession', () => { + it('returns null if no open session exists', async () => { + const result = await closeSession('g1', 'u1'); + expect(result).toBeNull(); + }); + + it('closes session and returns duration', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + await openSession('g1', 'u1', 'ch1'); + + // Simulate 10 seconds elapsed + vi.useFakeTimers(); + vi.advanceTimersByTime(10_000); + + const duration = await closeSession('g1', 'u1'); + + vi.useRealTimers(); + + expect(duration).toBeGreaterThanOrEqual(0); + expect(getActiveSessionCount()).toBe(0); + // UPDATE call should include duration + const updateCall = pool.query.mock.calls.find((c) => c[0].includes('UPDATE')); + expect(updateCall).toBeDefined(); + }); +}); + +// ─── handleVoiceStateUpdate ─────────────────────────────────────────────────── + +describe('handleVoiceStateUpdate', () => { + it('opens a session when user joins a channel', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + const old = makeVoiceState({ channelId: null }); + const next = makeVoiceState({ channelId: 'ch1' }); + + await handleVoiceStateUpdate(old, next); + + expect(getActiveSessionCount()).toBe(1); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO voice_sessions'), + expect.arrayContaining(['guild1', 'user1', 'ch1']), + ); + }); + + it('closes a session when user leaves all channels', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + // First join + await handleVoiceStateUpdate( + makeVoiceState({ channelId: null }), + makeVoiceState({ channelId: 'ch1' }), + ); + expect(getActiveSessionCount()).toBe(1); + + // Then leave + await handleVoiceStateUpdate( + makeVoiceState({ channelId: 'ch1' }), + makeVoiceState({ channelId: null }), + ); + expect(getActiveSessionCount()).toBe(0); + }); + + it('moves session when user switches channels', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + // Join ch1 + await handleVoiceStateUpdate( + makeVoiceState({ channelId: null }), + makeVoiceState({ channelId: 'ch1' }), + ); + + // Move to ch2 + await handleVoiceStateUpdate( + makeVoiceState({ channelId: 'ch1' }), + makeVoiceState({ channelId: 'ch2' }), + ); + + expect(getActiveSessionCount()).toBe(1); + // Last open session should be for ch2 + // We can verify by checking if the last INSERT used ch2 + const inserts = pool.query.mock.calls.filter((c) => c[0].includes('INSERT')); + const lastInsert = inserts[inserts.length - 1]; + expect(lastInsert[1]).toContain('ch2'); + }); + + it('ignores bot users', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + const old = makeVoiceState({ channelId: null, isBot: true }); + const next = makeVoiceState({ channelId: 'ch1', isBot: true }); + + await handleVoiceStateUpdate(old, next); + + expect(pool.query).not.toHaveBeenCalled(); + expect(getActiveSessionCount()).toBe(0); + }); + + it('ignores events when voice is disabled', async () => { + getConfig.mockReturnValue({ voice: { enabled: false } }); + const pool = makePool(); + getPool.mockReturnValue(pool); + + const old = makeVoiceState({ channelId: null }); + const next = makeVoiceState({ channelId: 'ch1' }); + + await handleVoiceStateUpdate(old, next); + + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('ignores mute/deafen changes (no channel change)', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + const state = makeVoiceState({ channelId: 'ch1' }); + // Both old and new have same channel → mute/deafen change + await handleVoiceStateUpdate(state, state); + + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing if guild or user id is missing', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + const badState = { channelId: 'ch1', guild: null, member: null }; + await handleVoiceStateUpdate(badState, badState); + + expect(pool.query).not.toHaveBeenCalled(); + }); +}); + +// ─── getVoiceLeaderboard ────────────────────────────────────────────────────── + +describe('getVoiceLeaderboard', () => { + it('returns leaderboard rows with correct shape', async () => { + const pool = makePool({ + queryResult: { + rows: [ + { user_id: 'u1', total_seconds: '3600', session_count: '5' }, + { user_id: 'u2', total_seconds: '1800', session_count: '2' }, + ], + }, + }); + getPool.mockReturnValue(pool); + + const rows = await getVoiceLeaderboard('g1', { limit: 10, period: 'week' }); + + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ user_id: 'u1', total_seconds: 3600, session_count: 5 }); + expect(rows[1]).toEqual({ user_id: 'u2', total_seconds: 1800, session_count: 2 }); + }); + + it('uses monthly window when period=month', async () => { + const pool = makePool({ queryResult: { rows: [] } }); + getPool.mockReturnValue(pool); + + await getVoiceLeaderboard('g1', { period: 'month' }); + + const [sql] = pool.query.mock.calls[0]; + expect(sql).toMatch(/30 days/); + }); + + it('omits window clause when period=all', async () => { + const pool = makePool({ queryResult: { rows: [] } }); + getPool.mockReturnValue(pool); + + await getVoiceLeaderboard('g1', { period: 'all' }); + + const [sql] = pool.query.mock.calls[0]; + expect(sql).not.toMatch(/INTERVAL/); + }); +}); + +// ─── getUserVoiceStats ──────────────────────────────────────────────────────── + +describe('getUserVoiceStats', () => { + it('returns zero stats when no sessions exist', async () => { + const pool = { + query: vi + .fn() + .mockResolvedValueOnce({ rows: [{ total_seconds: '0', session_count: '0' }] }) + .mockResolvedValueOnce({ rows: [] }), + }; + getPool.mockReturnValue(pool); + + const stats = await getUserVoiceStats('g1', 'u1'); + + expect(stats).toEqual({ total_seconds: 0, session_count: 0, favorite_channel: null }); + }); + + it('returns correct stats when sessions exist', async () => { + const pool = { + query: vi + .fn() + .mockResolvedValueOnce({ rows: [{ total_seconds: '7200', session_count: '3' }] }) + .mockResolvedValueOnce({ rows: [{ channel_id: 'ch42', total: '7200' }] }), + }; + getPool.mockReturnValue(pool); + + const stats = await getUserVoiceStats('g1', 'u1'); + + expect(stats).toEqual({ total_seconds: 7200, session_count: 3, favorite_channel: 'ch42' }); + }); +}); + +// ─── exportVoiceSessions ───────────────────────────────────────────────────── + +describe('exportVoiceSessions', () => { + it('returns session rows', async () => { + const mockRows = [ + { + id: 1, + user_id: 'u1', + channel_id: 'ch1', + joined_at: new Date(), + left_at: new Date(), + duration_seconds: 60, + }, + ]; + const pool = makePool({ queryResult: { rows: mockRows } }); + getPool.mockReturnValue(pool); + + const result = await exportVoiceSessions('g1', { period: 'all', limit: 100 }); + + expect(result).toEqual(mockRows); + }); + + it('applies weekly window filter', async () => { + const pool = makePool({ queryResult: { rows: [] } }); + getPool.mockReturnValue(pool); + + await exportVoiceSessions('g1', { period: 'week' }); + + const [sql] = pool.query.mock.calls[0]; + expect(sql).toMatch(/7 days/); + }); +}); + +// ─── flushActiveSessions ───────────────────────────────────────────────────── + +describe('flushActiveSessions', () => { + it('does nothing when no active sessions', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + await flushActiveSessions(); + + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('updates duration without closing (left_at stays NULL)', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + + await openSession('g1', 'u1', 'ch1'); + pool.query.mockClear(); // clear the INSERT call + + await flushActiveSessions(); + + expect(pool.query).toHaveBeenCalledTimes(1); + const [sql] = pool.query.mock.calls[0]; + expect(sql).toMatch(/UPDATE voice_sessions/); + expect(sql).toMatch(/left_at IS NULL/); + expect(getActiveSessionCount()).toBe(1); // still open + }); +}); + +// ─── startVoiceFlush / stopVoiceFlush ───────────────────────────────────────── + +describe('startVoiceFlush / stopVoiceFlush', () => { + it('starts the flush interval without throwing', () => { + expect(() => startVoiceFlush()).not.toThrow(); + stopVoiceFlush(); + }); + + it('is idempotent — calling start twice is safe', () => { + startVoiceFlush(); + expect(() => startVoiceFlush()).not.toThrow(); + stopVoiceFlush(); + }); + + it('stopping without starting is safe', () => { + expect(() => stopVoiceFlush()).not.toThrow(); + }); +}); From 451d0423a4b3e5a0fa6a1402eb9f6555f4d03594 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Mon, 2 Mar 2026 00:40:40 -0500 Subject: [PATCH 9/9] fix(voice): resolve race conditions and missing config schema - Fix openSession: update in-memory state only AFTER DB INSERT succeeds - Fix closeSession: delete from in-memory state only AFTER DB UPDATE succeeds - Fix: allow closeSession on leave/move even when feature is disabled - Fix migration: add UNIQUE constraint to partial index to prevent duplicates - Fix: move 'Voice join' log to after openSession succeeds - Add voice config to CONFIG_SCHEMA for validation --- migrations/004_voice_sessions.cjs | 6 +- src/api/utils/configValidation.js | 9 +++ src/modules/voice.js | 95 ++++++++++++++++--------------- 3 files changed, 63 insertions(+), 47 deletions(-) diff --git a/migrations/004_voice_sessions.cjs b/migrations/004_voice_sessions.cjs index 1e31bce3b..b862a11cd 100644 --- a/migrations/004_voice_sessions.cjs +++ b/migrations/004_voice_sessions.cjs @@ -33,8 +33,10 @@ exports.up = (pgm) => { ON voice_sessions(guild_id, joined_at) `); + // Unique partial index prevents duplicate open sessions for the same (guild_id, user_id) + // This ensures crash recovery cannot leave multiple open rows per user per guild pgm.sql(` - CREATE INDEX IF NOT EXISTS idx_voice_sessions_open + CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_sessions_open_unique ON voice_sessions(guild_id, user_id) WHERE left_at IS NULL `); @@ -42,7 +44,7 @@ exports.up = (pgm) => { /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ exports.down = (pgm) => { - pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_open`); + pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_open_unique`); pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_guild_joined`); pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_guild_user`); pgm.sql(`DROP TABLE IF EXISTS voice_sessions`); diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index bef5ceeae..201f95848 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -166,6 +166,15 @@ export const CONFIG_SCHEMA = { maxPerUser: { type: 'number' }, }, }, + voice: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + xpPerMinute: { type: 'number' }, + dailyXpCap: { type: 'number' }, + logChannel: { type: 'string', nullable: true }, + }, + }, }; /** diff --git a/src/modules/voice.js b/src/modules/voice.js index 8f161b757..5c9cd8231 100644 --- a/src/modules/voice.js +++ b/src/modules/voice.js @@ -75,19 +75,17 @@ export async function openSession(guildId, userId, channelId) { } const joinedAt = new Date(); - activeSessions.set(key, { channelId, joinedAt }); - try { - const pool = getPool(); - await pool.query( - `INSERT INTO voice_sessions (guild_id, user_id, channel_id, joined_at) - VALUES ($1, $2, $3, $4)`, - [guildId, userId, channelId, joinedAt.toISOString()], - ); - } catch (err) { - logError('Failed to insert voice session', { guildId, userId, channelId, error: err.message }); - throw err; - } + // Insert to DB first - only update in-memory state after DB succeeds + const pool = getPool(); + await pool.query( + `INSERT INTO voice_sessions (guild_id, user_id, channel_id, joined_at) + VALUES ($1, $2, $3, $4)`, + [guildId, userId, channelId, joinedAt.toISOString()], + ); + + // DB INSERT succeeded - now safe to update in-memory state + activeSessions.set(key, { channelId, joinedAt }); } /** @@ -103,31 +101,28 @@ export async function closeSession(guildId, userId) { const session = activeSessions.get(key); if (!session) return null; - activeSessions.delete(key); - const leftAt = new Date(); const durationSeconds = Math.floor((leftAt.getTime() - session.joinedAt.getTime()) / 1000); - try { - const pool = getPool(); - await pool.query( - `UPDATE voice_sessions - SET left_at = $1, duration_seconds = $2 - WHERE id = ( - SELECT id FROM voice_sessions - WHERE guild_id = $3 - AND user_id = $4 - AND channel_id = $5 - AND left_at IS NULL - ORDER BY joined_at DESC - LIMIT 1 - )`, - [leftAt.toISOString(), durationSeconds, guildId, userId, session.channelId], - ); - } catch (err) { - logError('Failed to close voice session', { guildId, userId, error: err.message }); - throw err; - } + // Update DB first - only delete from in-memory state after DB succeeds + const pool = getPool(); + await pool.query( + `UPDATE voice_sessions + SET left_at = $1, duration_seconds = $2 + WHERE id = ( + SELECT id FROM voice_sessions + WHERE guild_id = $3 + AND user_id = $4 + AND channel_id = $5 + AND left_at IS NULL + ORDER BY joined_at DESC + LIMIT 1 + )`, + [leftAt.toISOString(), durationSeconds, guildId, userId, session.channelId], + ); + + // DB UPDATE succeeded - now safe to delete from in-memory state + activeSessions.delete(key); return durationSeconds; } @@ -154,32 +149,42 @@ export async function handleVoiceStateUpdate(oldState, newState) { if (isBot) return; const cfg = getVoiceConfig(guildId); - if (!cfg.enabled) return; - const oldChannel = oldState.channelId; const newChannel = newState.channelId; - if (!oldChannel && newChannel) { - // User joined a voice channel - await openSession(guildId, userId, newChannel).catch((err) => - logError('openSession failed', { guildId, userId, error: err.message }), - ); - info('Voice join', { guildId, userId, channelId: newChannel }); - } else if (oldChannel && !newChannel) { + // Always allow leave/move paths to close existing sessions, even when disabled + // This prevents orphaned in-memory sessions if the feature is toggled off mid-session + if (oldChannel && !newChannel) { // User left all voice channels await closeSession(guildId, userId).catch((err) => logError('closeSession failed', { guildId, userId, error: err.message }), ); info('Voice leave', { guildId, userId, channelId: oldChannel }); + return; } else if (oldChannel && newChannel && oldChannel !== newChannel) { - // User moved between channels — close old session, open new + // User moved between channels — close old session await closeSession(guildId, userId).catch((err) => logError('closeSession(move) failed', { guildId, userId, error: err.message }), ); + info('Voice move', { guildId, userId, from: oldChannel, to: newChannel }); + // Fall through to potentially open new session if enabled + } + + // Early return for join/open paths when disabled + if (!cfg.enabled) return; + + if (!oldChannel && newChannel) { + // User joined a voice channel + await openSession(guildId, userId, newChannel).catch((err) => + logError('openSession failed', { guildId, userId, error: err.message }), + ); + // Log success only after openSession resolves + info('Voice join', { guildId, userId, channelId: newChannel }); + } else if (oldChannel && newChannel && oldChannel !== newChannel) { + // User moved between channels — open new session (close already handled above) await openSession(guildId, userId, newChannel).catch((err) => logError('openSession(move) failed', { guildId, userId, error: err.message }), ); - info('Voice move', { guildId, userId, from: oldChannel, to: newChannel }); } // Mute/deafen/stream changes don't affect session tracking }