-
Notifications
You must be signed in to change notification settings - Fork 2
feat: voice channel activity tracking β join/leave/move, leaderboard, export #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eec5b4f
ca0a499
24b16fa
75de5f7
f8c5a3d
2c3b149
8edf27b
24c3bb5
451d042
92f57da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| `); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| /** @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`); | ||
| }; | ||
| 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
|
||
| } | ||
|
|
||
| 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.', | ||
| }); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
voiceconfig block uses different indentation than the rest ofconfig.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.