Skip to content

feat: voice channel activity tracking — join/leave/move, leaderboard, export#212

Merged
BillChirico merged 10 commits intomainfrom
feat/issue-53
Mar 2, 2026
Merged

feat: voice channel activity tracking — join/leave/move, leaderboard, export#212
BillChirico merged 10 commits intomainfrom
feat/issue-53

Conversation

@BillChirico
Copy link
Copy Markdown
Collaborator

Closes #135

Summary

Implements voice channel activity tracking for engagement metrics.

Features

🎙️ Join/Leave/Move Tracking

  • voiceStateUpdate handler fires on every voice state change
  • Sessions opened on join, closed on leave, replaced on move
  • Bots and disabled guilds are skipped
  • 5-minute periodic flush so in-progress sessions survive crashes

⏱️ Time-in-Voice Calculation

  • In-memory activeSessions Map for hot-path tracking
  • voice_sessions table stores: guild_id, user_id, channel_id, joined_at, left_at, duration_seconds
  • Partial durations flushed every 5 minutes (not lost on crash)

🏆 Voice Leaderboard

  • /voice leaderboard [period] — top 10 by voice time (week/month/all-time)
  • Displays: rank medal, display name, formatted duration, session count

📊 User Stats

  • /voice stats [user] — total voice time, sessions, favourite channel
  • Works for any guild member

📥 Data Export

  • /voice export [period] — moderator-only CSV download
  • Up to 5000 sessions, filtered by period

Files Changed

File Change
migrations/004_voice_sessions.cjs New migration — voice_sessions table + indexes
src/modules/voice.js Core tracking module (400 lines)
src/commands/voice.js /voice slash command with 3 subcommands
src/modules/events.js Wire voiceStateUpdate handler
src/index.js Wire startVoiceFlush/stopVoiceFlush into lifecycle
src/api/utils/configAllowlist.js Add voice to safe config keys
config.json Add voice defaults (enabled: false, xpPerMinute: 2, dailyXpCap: 120)
tests/modules/voice.test.js 29 tests covering all paths

Config

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

Enable per-guild via /config set voice.enabled true.

Tests

✓ tests/modules/voice.test.js (29 tests)

Pre-existing failures (17) on main unrelated to this PR.

Notes

  • GuildVoiceStates intent was already enabled ✅
  • Config API allowlist updated so voice settings can be saved via dashboard ✅
  • Feature is opt-in (voice.enabled: false by default) ✅

@BillChirico BillChirico added the type: feature New feature label Mar 2, 2026
Copilot AI review requested due to automatic review settings March 2, 2026 04:30
@BillChirico BillChirico added the type: feature New feature label Mar 2, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 2, 2026

Warning

Rate limit exceeded

@BillChirico has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 35 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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.

📥 Commits

Reviewing files that changed from the base of the PR and between abfb03c and 92f57da.

📒 Files selected for processing (9)
  • config.json
  • migrations/004_voice_sessions.cjs
  • src/api/utils/configAllowlist.js
  • src/api/utils/configValidation.js
  • src/commands/voice.js
  • src/index.js
  • src/modules/events.js
  • src/modules/voice.js
  • tests/modules/voice.test.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/issue-53

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 2, 2026

Greptile Summary

Implements voice channel activity tracking with dual-state architecture: in-memory Map for hot-path performance and PostgreSQL for persistence. The implementation correctly handles join/leave/move events with proper session lifecycle management.

Architecture highlights:

  • DB-first writes (lines 81-85, 109-122 in voice.js) ensure consistency by updating database before in-memory state
  • Unique partial index (migration line 39-42) prevents duplicate open sessions per user/guild
  • 5-minute periodic flush (lines 320-345) writes partial durations to survive crashes without closing sessions
  • Graceful degradation: close operations work even when feature is disabled (lines 155-171)

Implementation quality:

  • All database queries use parameterized statements
  • Proper error handling with try/catch and .catch() chains
  • Winston logger used throughout (no console methods)
  • Comprehensive test coverage (29 tests)
  • ESM modules with correct .cjs extension for migration

Known limitation (acknowledged):
Crash recovery not implemented — if the bot restarts while users are in voice, orphaned DB sessions accumulate. The unique constraint prevents new sessions from opening for affected users until manual cleanup. Developer acknowledged this as acceptable trade-off.

Confidence Score: 4/5

  • Safe to merge with one known limitation around crash recovery that has been acknowledged
  • Strong implementation with proper error handling, parameterized queries, and test coverage. The crash recovery limitation is a known trade-off that won't cause data corruption, only orphaned sessions requiring manual cleanup. All other aspects follow project conventions correctly.
  • No files require special attention — the crash recovery limitation in src/modules/voice.js has been acknowledged and documented in previous review threads

Important Files Changed

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]
Loading

Last reviewed commit: 92f57da

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

9 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_sessions persistence + periodic flushing of active (in-memory) sessions
  • Wires a voiceStateUpdate event handler and lifecycle start/stop for the periodic flusher
  • Adds /voice leaderboard|stats|export and 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
coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 2, 2026
Copilot AI review requested due to automatic review settings March 2, 2026 10:02
@BillChirico BillChirico merged commit 2791afb into main Mar 2, 2026
10 of 12 checks passed
@BillChirico BillChirico deleted the feat/issue-53 branch March 2, 2026 10:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +95 to +123
* @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],
);

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.

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.

Suggested change
* @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],
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +182
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 });
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.

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.

Suggested change
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),
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +89
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 });
}
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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +318 to +344
* @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 }),
);
}
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.

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.

Suggested change
* @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 });
}

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +190
// 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.',
});
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.
Comment on lines +292 to +298
},
"voice": {
"enabled": false,
"xpPerMinute": 2,
"dailyXpCap": 120,
"logChannel": null
}
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.
BillChirico added a commit that referenced this pull request Mar 2, 2026
… 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>
BillChirico added a commit that referenced this pull request Mar 2, 2026
* 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 & → &amp; escape first in escapePromptDelimiters() to prevent
  HTML entity bypass attacks (e.g. &lt;/messages-to-evaluate&gt;)
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: voice channel tracking — join/leave logs, time stats, temp channels

2 participants