feat: voice channel activity tracking — join/leave/move, leaderboard, export#212
feat: voice channel activity tracking — join/leave/move, leaderboard, export#212BillChirico merged 10 commits intomainfrom
Conversation
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (9)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| migrations/004_voice_sessions.cjs | Creates voice_sessions table with proper indexes and unique constraint to prevent duplicate open sessions |
| src/modules/voice.js | Core tracking module with in-memory session Map, DB persistence, periodic flush, and leaderboard/stats functions — crash recovery for orphaned sessions acknowledged as limitation |
| src/commands/voice.js | Three subcommands (leaderboard, stats, export) with proper permission checks and error handling |
| src/index.js | Wires startVoiceFlush and stopVoiceFlush into bot lifecycle startup/shutdown |
| src/modules/events.js | Registers voiceStateUpdate handler with error handling wrapper |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Discord voiceStateUpdate Event] --> B{User Action}
B -->|Join Channel| C[handleVoiceStateUpdate]
B -->|Leave Channel| C
B -->|Move Channel| C
B -->|Mute/Deafen| C
C --> D{Bot or No Guild?}
D -->|Yes| E[Skip]
D -->|No| F{Event Type?}
F -->|Leave| G[closeSession]
F -->|Move| H[closeSession then openSession]
F -->|Join| I{voice.enabled?}
F -->|Mute/Deafen| E
I -->|Yes| J[openSession]
I -->|No| E
G --> K[Update DB: set left_at, duration]
G --> L[Delete from activeSessions Map]
J --> M[Insert DB: new row with left_at=NULL]
J --> N[Add to activeSessions Map]
H --> K
H --> L
H --> O{voice.enabled?}
O -->|Yes| M
O -->|Yes| N
O -->|No| E
P[5-min Flush Timer] --> Q[flushActiveSessions]
Q --> R[For each active session]
R --> S[Update DB: partial duration]
R --> T[Keep in activeSessions Map]
U[voice leaderboard command] --> V[Query DB: SUM duration WHERE left_at NOT NULL]
W[voice stats command] --> X[Query DB: total time and favorite channel]
Y[voice export command] --> Z[Query DB: raw sessions as CSV]
Last reviewed commit: 92f57da
There was a problem hiding this comment.
Pull request overview
Adds first-class voice activity tracking to the bot, persisting voice join/leave/move sessions to Postgres and exposing voice stats via a new /voice command suite.
Changes:
- Introduces
voice_sessionspersistence + periodic flushing of active (in-memory) sessions - Wires a
voiceStateUpdateevent handler and lifecycle start/stop for the periodic flusher - Adds
/voice leaderboard|stats|exportand accompanying tests
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
migrations/004_voice_sessions.cjs |
Creates voice_sessions table and indexes for querying voice activity |
src/modules/voice.js |
Implements session open/close, periodic flush, leaderboard/stats/export queries |
src/modules/events.js |
Registers voiceStateUpdate listener that delegates to the voice module |
src/index.js |
Starts/stops the voice flush interval during app lifecycle |
src/commands/voice.js |
Adds /voice slash command with leaderboard/stats/export subcommands |
src/api/utils/configAllowlist.js |
Allowlists voice for dashboard/API config writes |
config.json |
Adds default voice config section |
tests/modules/voice.test.js |
Adds unit tests for voice module behaviors |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- 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
…on and configValidation.js
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * @param {string} guildId | ||
| * @param {string} userId | ||
| * @returns {Promise<number|null>} 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; | ||
|
|
||
| const leftAt = new Date(); | ||
| const durationSeconds = Math.floor((leftAt.getTime() - session.joinedAt.getTime()) / 1000); | ||
|
|
||
| // 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], | ||
| ); | ||
|
|
There was a problem hiding this comment.
closeSession() bails out when the in-memory activeSessions map doesn't contain an entry. After a process restart/crash, users can still be in voice but activeSessions will be empty, so subsequent leave/move events won't close the existing left_at IS NULL row in voice_sessions (and the UNIQUE partial index will then cause future joins to fail). Consider adding crash-recovery (e.g., hydrate activeSessions from voice_sessions WHERE left_at IS NULL on startup) and/or a DB fallback path in closeSession() when no in-memory session is found.
| * @param {string} guildId | |
| * @param {string} userId | |
| * @returns {Promise<number|null>} 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; | |
| const leftAt = new Date(); | |
| const durationSeconds = Math.floor((leftAt.getTime() - session.joinedAt.getTime()) / 1000); | |
| // 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], | |
| ); | |
| * This function is resilient to process restarts: if no in-memory session | |
| * is found, it falls back to locating an open session in the database | |
| * (left_at IS NULL) for the given guild/user and closes it. | |
| * | |
| * @param {string} guildId | |
| * @param {string} userId | |
| * @returns {Promise<number|null>} Duration in seconds, or null if no open session found. | |
| */ | |
| export async function closeSession(guildId, userId) { | |
| const key = sessionKey(guildId, userId); | |
| let session = activeSessions.get(key); | |
| const leftAt = new Date(); | |
| let durationSeconds = null; | |
| // Update DB first - only delete from in-memory state after DB succeeds | |
| const pool = getPool(); | |
| if (session) { | |
| // Normal path: we have an in-memory session with a precise joinedAt. | |
| durationSeconds = Math.floor( | |
| (leftAt.getTime() - session.joinedAt.getTime()) / 1000, | |
| ); | |
| 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], | |
| ); | |
| } else { | |
| // Fallback path: no in-memory session (e.g., after process restart). | |
| // Look for the most recent open session in the database and close it. | |
| const { rows } = await pool.query( | |
| `SELECT id, channel_id, joined_at | |
| FROM voice_sessions | |
| WHERE guild_id = $1 | |
| AND user_id = $2 | |
| AND left_at IS NULL | |
| ORDER BY joined_at DESC | |
| LIMIT 1`, | |
| [guildId, userId], | |
| ); | |
| if (rows.length === 0) { | |
| // No open session exists in DB either. | |
| return null; | |
| } | |
| const openSession = rows[0]; | |
| const joinedAt = new Date(openSession.joined_at); | |
| durationSeconds = Math.floor( | |
| (leftAt.getTime() - joinedAt.getTime()) / 1000, | |
| ); | |
| await pool.query( | |
| `UPDATE voice_sessions | |
| SET left_at = $1, duration_seconds = $2 | |
| WHERE id = $3`, | |
| [leftAt.toISOString(), durationSeconds, openSession.id], | |
| ); | |
| } |
| 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 }); |
There was a problem hiding this comment.
In the join path, openSession() errors are caught and swallowed, but info('Voice join', ...) is still logged unconditionally afterward. This can produce misleading logs (join recorded even though the DB insert failed). Log the join only when openSession() succeeds, or include a success/error flag in the log event.
| 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 }); | |
| try { | |
| await openSession(guildId, userId, newChannel); | |
| // Log successful join only after openSession resolves without throwing | |
| info('Voice join', { | |
| guildId, | |
| userId, | |
| channelId: newChannel, | |
| success: true, | |
| }); | |
| } catch (err) { | |
| // Preserve existing error logging behavior for failed session opens | |
| logError('openSession failed', { | |
| guildId, | |
| userId, | |
| error: err?.message ?? String(err), | |
| }); | |
| // Also record the join event with explicit failure status for log consumers | |
| info('Voice join', { | |
| guildId, | |
| userId, | |
| channelId: newChannel, | |
| success: false, | |
| error: err?.message ?? String(err), | |
| }); | |
| } |
| 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(); | ||
|
|
||
| // 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 }); | ||
| } |
There was a problem hiding this comment.
openSession() only adds the session to activeSessions after the INSERT completes. Because voiceStateUpdate handlers are async and Discord.js does not serialize/await listeners, multiple events for the same user can overlap (e.g., join immediately followed by move/leave). In that window, closeSession() may see no in-memory session and do nothing, leaving an open DB row and/or causing later INSERTs to hit the UNIQUE open-session constraint. Consider serializing operations per (guildId,userId) (mutex/queue) or using a DB-first approach (e.g., INSERT ... ON CONFLICT with the partial unique index) to make join/move/leave idempotent under concurrency.
| * @returns {Promise<void>} | ||
| */ | ||
| 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 }), | ||
| ); | ||
| } |
There was a problem hiding this comment.
flushActiveSessions() performs one awaited UPDATE per active session in a tight loop. With many concurrent voice users, this becomes an N-queries-per-flush pattern and can extend flush duration (and potentially overlap intervals). Consider batching updates into a single query (e.g., VALUES/UNNEST + UPDATE ... FROM) or running updates with a bounded concurrency limit.
| * @returns {Promise<void>} | |
| */ | |
| 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 }), | |
| ); | |
| } | |
| * The implementation performs a single batched UPDATE to avoid issuing | |
| * one awaited query per active session, which can become costly with | |
| * many concurrent voice users. | |
| * | |
| * @returns {Promise<void>} | |
| */ | |
| export async function flushActiveSessions() { | |
| if (activeSessions.size === 0) { | |
| return; | |
| } | |
| const pool = getPool(); | |
| const now = new Date(); | |
| /** | |
| * Collect parameter values and VALUES placeholders for a single | |
| * batched UPDATE statement. | |
| */ | |
| const valuesPlaceholders = []; | |
| const queryParams = []; | |
| let paramIndex = 1; | |
| for (const [key, session] of activeSessions) { | |
| const [guildId, userId] = key.split(':'); | |
| const partialDuration = Math.floor( | |
| (now.getTime() - session.joinedAt.getTime()) / 1000, | |
| ); | |
| // Each row contributes: (duration_seconds, guild_id, user_id, channel_id) | |
| valuesPlaceholders.push( | |
| `($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3})`, | |
| ); | |
| queryParams.push(partialDuration, guildId, userId, session.channelId); | |
| paramIndex += 4; | |
| } | |
| // Safety check: if, for some reason, there are no sessions, nothing to do. | |
| if (valuesPlaceholders.length === 0) { | |
| return; | |
| } | |
| const sql = ` | |
| UPDATE voice_sessions AS vs | |
| SET duration_seconds = data.duration_seconds | |
| FROM ( | |
| VALUES | |
| ${valuesPlaceholders.join(',\n ')} | |
| ) AS data(duration_seconds, guild_id, user_id, channel_id) | |
| WHERE vs.guild_id = data.guild_id | |
| AND vs.user_id = data.user_id | |
| AND vs.channel_id = data.channel_id | |
| AND vs.left_at IS NULL; | |
| `; | |
| try { | |
| await pool.query(sql, queryParams); | |
| } catch (err) { | |
| // Log once for the batch; individual failures are not distinguishable here. | |
| logError('Failed to flush voice sessions batch', { error: err.message }); | |
| } |
| // 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.', | ||
| }); |
There was a problem hiding this comment.
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.
| }, | ||
| "voice": { | ||
| "enabled": false, | ||
| "xpPerMinute": 2, | ||
| "dailyXpCap": 120, | ||
| "logChannel": null | ||
| } |
There was a problem hiding this comment.
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.
| }, | |
| "voice": { | |
| "enabled": false, | |
| "xpPerMinute": 2, | |
| "dailyXpCap": 120, | |
| "logChannel": null | |
| } | |
| }, | |
| "voice": { | |
| "enabled": false, | |
| "xpPerMinute": 2, | |
| "dailyXpCap": 120, | |
| "logChannel": null | |
| } |
… export (#212) * feat: add voice_sessions migration (#135) * feat: add voice tracking module — join/leave/move/flush/leaderboard (#135) * feat: wire voiceStateUpdate handler into event registration (#135) * feat: add /voice command — leaderboard, stats, export subcommands (#135) * feat: add voice config defaults to config.json (#135) * feat: wire voice flush start/stop into bot lifecycle (#135) * feat: add voice to config API allowlist (#135) * fix: SQL UPDATE subquery for closeSession, fix import order (#135) * 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 --------- Co-authored-by: Bill <bill@example.com>
* security: escape user content in triage prompt delimiters (#164) Add escapePromptDelimiters() to HTML-encode < and > in user-supplied message content before it is inserted between XML-style section tags in the LLM prompt. Without escaping, a crafted message containing the literal text `</messages-to-evaluate>` could break out of the user-content section and inject attacker-controlled instructions into the prompt structure. Changes: - Add escapePromptDelimiters(text) utility exported from triage-prompt.js - Apply escape to m.content and m.replyTo.content in buildConversationText() - Add 13 new tests covering the escape function and injection scenarios Closes #164 * security: escape & chars and author fields in prompt delimiters * fix(security): escape & in prompt delimiters and escape author fields - Add & → & escape first in escapePromptDelimiters() to prevent HTML entity bypass attacks (e.g. </messages-to-evaluate>) - Also escape m.author and m.replyTo.author since Discord display names are user-controlled and can contain < / > characters Addresses review feedback on PR #204. * fix: guard replyTo.content before .slice() to handle null/undefined * perf: SQL-based conversation pagination + missing DB indexes (#221) Fixes three performance bottlenecks identified in code review of recently merged features (PR #121 conversations viewer, PR #190 AI feedback). ## Changes ### migrations/004_performance_indexes.cjs (new) Four new indexes targeting hot query paths: - idx_ai_feedback_guild_created (guild_id, created_at DESC) getFeedbackTrend() and getRecentFeedback() filtered by guild_id AND created_at but only had a single-column guild_id index, forcing a full guild scan + sort on every trend/recent call. - idx_conversations_content_trgm (GIN, pg_trgm) content ILIKE '%...%' search was a sequential scan. GIN/trgm index reduces this from O(n) to O(log n * trigram matches). Requires pg_trgm extension (added idempotently). - idx_conversations_guild_created (guild_id, created_at DESC) Default 30-day listing query filters guild_id + created_at. The existing 3-column (guild_id, channel_id, created_at) composite is suboptimal when channel_id is not in the predicate. - idx_flagged_messages_guild_message (guild_id, message_id) Conversation detail + flag endpoints query flagged_messages by guild_id AND message_id = ANY(...). Existing index only covers (guild_id, status). ### src/api/routes/conversations.js **GET / — Replace in-memory pagination with SQL CTE grouping** Before: fetched up to 10,000 message rows into Node memory, grouped them in JavaScript (O(n) time + memory), then sliced for pagination. Every page request loaded the full 10k row dataset. After: single SQL query using window functions (LAG + SUM OVER) to identify conversation boundaries and aggregate summaries directly. COUNT(*) OVER() provides total count without a second query. Pagination happens at the DB with LIMIT/OFFSET on summary rows. Memory overhead is now proportional to page size (default 25), not total conversation volume. Removed now-unused buildConversationSummary() helper (logic inlined into the SQL-side aggregation). **POST /:conversationId/flag — Parallel verification queries** Before: msgCheck and anchorCheck ran sequentially (~2× RTT). After: both run in parallel via Promise.all (1× RTT for verification). ### tests/api/routes/conversations.test.js Updated 'should return paginated conversations' test to mock the new SQL CTE response shape (pre-aggregated summary rows) instead of raw message rows. All 41 conversation tests pass. * feat: channel-level quiet mode via bot mention (#173) (#213) * feat: quiet mode per-channel via bot mention (#173) - Add quietMode.js module with Redis+memory storage - Parse duration from natural language (30m, 1 hour, etc.) - Permission gated via config.quietMode.allowedRoles - Commands: quiet, unquiet, status - Suppress AI responses during quiet mode in events.js - Add quietMode section to config.json (disabled by default) - Add quietMode to configAllowlist.js for dashboard editing * test: add quiet mode tests (41 tests, all passing) * style: fix biome formatting in quietMode.js, events.js, and test * fix(web): fix ai-feedback-stats TypeScript and formatting errors * fix: gate quiet mode checks on enabled flag, validate TTL, honor maxDurationMinutes config - events.js: Wrap isQuietMode() calls in guildConfig.quietMode?.enabled check to avoid unnecessary Redis lookups and prevent stale records from suppressing AI responses when the feature is disabled (PRRT_kwDORICdSM5xdbmp, PRRT_kwDORICdSM5xdbmx) - quietMode.js: Add TTL validation in setQuiet() to guard against 0, negative, or NaN values that would error in Redis (PRRT_kwDORICdSM5xdbm3) - quietMode.js: Update parseDurationFromContent() to accept config parameter and honor guildConfig.quietMode.maxDurationMinutes. Also clamp defaultSeconds to the effective max (PRRT_kwDORICdSM5xdbm_) - configValidation.js: Add quietMode schema entry with enabled, maxDurationMinutes, and allowedRoles properties (PRRT_kwDORICdSM5xdbnH) * style: fix biome formatting in quietMode.js and ai-feedback-stats.tsx * feat: audit log improvements — CSV/JSON export and real-time WebSocket stream (#215) * feat: audit log improvements — CSV/JSON export, real-time WebSocket stream - Add GET /:id/audit-log/export endpoint (CSV and JSON, up to 10k rows) - Add /ws/audit-log WebSocket server for real-time audit entry broadcast - Refactor buildFilters() shared helper to eliminate duplication - Hook broadcastAuditEntry() into insertAuditEntry (RETURNING id+created_at) - Wire setupAuditStream/stopAuditStream into startServer/stopServer lifecycle - Add escapeCsvValue/rowsToCsv helpers with full test coverage - 30 route tests + 17 WebSocket stream tests, all green Closes #136 * fix: PR #215 review feedback - audit stream fixes - ws.ping() crash: guard with readyState check + try/catch to avoid crashing heartbeat interval when socket not OPEN - stopAuditStream race: make setupAuditStream async and await stopAuditStream() to prevent concurrent WebSocketServer creation - Query param array coercion: add typeof === 'string' checks for startDate/endDate to handle Express string|string[]|undefined - CSV CRLF quoting: add \r to RFC 4180 special-char check for proper Windows line ending handling - Test timeouts: make AUTH_TIMEOUT_MS configurable via AUDIT_STREAM_AUTH_TIMEOUT_MS env var, use 100ms in tests * feat: voice channel activity tracking — join/leave/move, leaderboard, export (#212) * feat: add voice_sessions migration (#135) * feat: add voice tracking module — join/leave/move/flush/leaderboard (#135) * feat: wire voiceStateUpdate handler into event registration (#135) * feat: add /voice command — leaderboard, stats, export subcommands (#135) * feat: add voice config defaults to config.json (#135) * feat: wire voice flush start/stop into bot lifecycle (#135) * feat: add voice to config API allowlist (#135) * fix: SQL UPDATE subquery for closeSession, fix import order (#135) * 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 --------- Co-authored-by: Bill <bill@example.com> * feat(dashboard): auto-save config with 500ms debounce (#199) * feat(dashboard): replace manual save with auto-save (500ms debounce) - Remove 'Save Changes' button; saving now fires automatically 500ms after the last config change (no changes → no network call) - Add saveStatus state ('idle' | 'saving' | 'saved' | 'error') with AutoSaveStatus component showing spinner, check, or error+retry - Add isLoadingConfigRef guard so the initial fetchConfig load never triggers a spurious PATCH - Ctrl+S still works: clears debounce timer and saves immediately - Keep 'beforeunload' warning for validation errors that block save - Replace yellow unsaved-changes banner with a destructive validation error banner (only shown when save is actually blocked) - Error state shows 'Save failed' + 'Retry' button for user recovery Closes #189 * test(dashboard): add auto-save tests for ConfigEditor - No PATCH on initial config load - Validation error banner suppresses auto-save - 'Saving...' spinner visible while PATCH in-flight - 'Save failed' + Retry button on PATCH error * fix(dashboard): prevent fetchConfig from overwriting saveStatus after successful save Add skipSaveStatusReset parameter to fetchConfig so that post-save reloads preserve the 'saved' status indicator instead of immediately resetting to 'idle'. * test(dashboard): use fake timers, restore vi.stubGlobal, fix assertions, add idle/saved coverage - Replace real setTimeout delays with vi.useFakeTimers() + vi.advanceTimersByTimeAsync() for deterministic, fast debounce tests - Add afterEach cleanup: vi.unstubAllGlobals() + vi.useRealTimers() - Replace toBeTruthy() with toBeInTheDocument() for Testing Library queries - Add idle state test (no status indicator shown after load) - Add saved state test (shows 'Saved' after successful save) - Update file-level comment to list all four states --------- Co-authored-by: Bill Chirico <bill@volvox.gg> * feat: Reaction role menus (#162) (#205) * feat: reaction role menus - core module, command, event hooks, migration Implements issue #162: reaction role menus. - Add migration 004 creating reaction_role_menus and reaction_role_entries tables - Add src/modules/reactionRoles.js with DB helpers, embed builder, event handlers - Add src/commands/reactionrole.js with /reactionrole create|add|remove|delete|list - Wire handleReactionRoleAdd/Remove into registerReactionHandlers in events.js Roles are granted on reaction add and revoked on reaction remove. All mappings persist in PostgreSQL across bot restarts. * test: reaction role menus - 40 tests covering module and command - tests/modules/reactionRoles.test.js: resolveEmojiString, buildReactionRoleEmbed, all DB helpers, handleReactionRoleAdd, handleReactionRoleRemove - tests/commands/reactionrole.test.js: all 5 subcommands (create, add, remove, delete, list) including error paths and guild ownership checks - Fix biome lint: import sort order + unused import removal * fix: remove unused import in reactionrole command --------- Co-authored-by: Bill Chirico <bill@volvox.gg> * fix(security): validate GitHub owner/repo format before gh CLI call (#198) * fix(security): validate GitHub owner/repo format before gh CLI call Prevents API path traversal by validating owner/repo segments against a strict allowlist regex before interpolating them into the gh CLI invocation. Adds: - VALID_GH_NAME regex (/^[a-zA-Z0-9._-]+$/) - isValidGhRepo() helper (exported for testing) - Guard in fetchRepoEvents() — returns [] and warns on invalid input - Strengthened guard in pollGuildFeed() split logic Fixes #160 * test(security): add validation tests for GitHub owner/repo format Covers isValidGhRepo(), VALID_GH_NAME regex, and fetchRepoEvents() validation guard introduced in fix for #160. 19 new tests verify: - Valid alphanumeric/dot/hyphen/underscore names pass - Path traversal (../../etc/passwd) is rejected at both entry points - Slashes, empty strings, non-strings, spaces all rejected - Shell metacharacters (; && $()) blocked - gh CLI is NOT invoked when validation fails - warn() fires with the invalid values (observable audit trail) - Valid owner/repo still reach gh CLI unchanged * fix(security): reject pure-dot owner/repo names to prevent path traversal * test(githubFeed): add tests for pure-dot path traversal bypass --------- Co-authored-by: Bill Chirico <bill@volvox.gg> --------- Co-authored-by: Bill <bill@example.com> Co-authored-by: Bill Chirico <bill@volvox.gg>
Closes #135
Summary
Implements voice channel activity tracking for engagement metrics.
Features
🎙️ Join/Leave/Move Tracking
voiceStateUpdatehandler fires on every voice state change⏱️ Time-in-Voice Calculation
activeSessionsMap for hot-path trackingvoice_sessionstable stores:guild_id,user_id,channel_id,joined_at,left_at,duration_seconds🏆 Voice Leaderboard
/voice leaderboard [period]— top 10 by voice time (week/month/all-time)📊 User Stats
/voice stats [user]— total voice time, sessions, favourite channel📥 Data Export
/voice export [period]— moderator-only CSV downloadFiles Changed
migrations/004_voice_sessions.cjsvoice_sessionstable + indexessrc/modules/voice.jssrc/commands/voice.js/voiceslash command with 3 subcommandssrc/modules/events.jsvoiceStateUpdatehandlersrc/index.jsstartVoiceFlush/stopVoiceFlushinto lifecyclesrc/api/utils/configAllowlist.jsvoiceto safe config keysconfig.jsonvoicedefaults (enabled: false,xpPerMinute: 2,dailyXpCap: 120)tests/modules/voice.test.jsConfig
{ "voice": { "enabled": false, "xpPerMinute": 2, "dailyXpCap": 120, "logChannel": null } }Enable per-guild via
/config set voice.enabled true.Tests
Pre-existing failures (17) on main unrelated to this PR.
Notes
GuildVoiceStatesintent was already enabled ✅voice.enabled: falseby default) ✅