Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,5 +289,11 @@
],
"defaultDurationMinutes": 30,
"maxDurationMinutes": 1440
}
},
"voice": {
"enabled": false,
"xpPerMinute": 2,
"dailyXpCap": 120,
"logChannel": null
}
Comment on lines +292 to +298
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new voice config block uses different indentation than the rest of config.json, which makes the file formatting inconsistent and harder to diff going forward. Please reformat these lines to match the existing indentation style used throughout the file.

Suggested change
},
"voice": {
"enabled": false,
"xpPerMinute": 2,
"dailyXpCap": 120,
"logChannel": null
}
},
"voice": {
"enabled": false,
"xpPerMinute": 2,
"dailyXpCap": 120,
"logChannel": null
}

Copilot uses AI. Check for mistakes.
}
51 changes: 51 additions & 0 deletions migrations/004_voice_sessions.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* 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)
`);

// 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 UNIQUE INDEX IF NOT EXISTS idx_voice_sessions_open_unique
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_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`);
};
1 change: 1 addition & 0 deletions src/api/utils/configAllowlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const SAFE_CONFIG_KEYS = new Set([
'afk',
'reputation',
'engagement',
'voice',
'github',
'challenges',
'review',
Expand Down
9 changes: 9 additions & 0 deletions src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ export const CONFIG_SCHEMA = {
allowedRoles: { type: 'array' },
},
},
voice: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
xpPerMinute: { type: 'number' },
dailyXpCap: { type: 'number' },
logChannel: { type: 'string', nullable: true },
},
},
};

/**
Expand Down
226 changes: 226 additions & 0 deletions src/commands/voice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/**
* 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.',
});
Comment on lines +186 to +190
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The export subcommand is documented as β€œmoderators only”, but the permission check only allows members with Discord’s Manage Server permission. Elsewhere in the codebase, moderator-gated commands use isModerator(member, config) (supports bot owner + configured moderator/admin roles), e.g. src/utils/permissions.js and src/commands/announce.js. Consider switching this check to the shared helper so the authorization model is consistent across commands.

Copilot uses AI. Check for mistakes.
}

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.',
});
}
}
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -467,6 +469,7 @@ async function startup() {
startTempbanScheduler(client);
startScheduler(client);
startGithubFeed(client);
startVoiceFlush();
}

// Load commands and login
Expand Down
15 changes: 15 additions & 0 deletions src/modules/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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,
Expand Down Expand Up @@ -772,6 +773,7 @@ export function registerEventHandlers(client, config, healthMonitor) {
registerTicketCloseButtonHandler(client);
registerReminderButtonHandler(client);
registerWelcomeOnboardingHandlers(client);
registerVoiceStateHandler(client);
registerErrorHandlers(client);
}

Expand Down Expand Up @@ -897,3 +899,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 });
});
});
}
Loading
Loading