feat: AI conversations viewer with search and replay#121
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review infoConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro 📒 Files selected for processing (16)
📝 WalkthroughSummary by CodeRabbitRelease NotesNew Features
Tests
WalkthroughIntroduces a conversation viewer feature for the bot dashboard, including a database migration for flagged messages, bot API endpoints for listing and retrieving conversations with stats and flagging capabilities, web API proxy routes, and a complete frontend UI with conversation list, detail replay, and flagging workflow. Changes
Possibly related PRs
✨ 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 |
|
Claude finished @BillChirico's task in 2m 57s —— View job Review of PR #121 — 3 issues found
🟡 Warning (3)
Documentation
AI prompt to fix all issues |
|
Note Unit test generation is a public access feature. Expect some limitations and changes as we gather feedback and continue to improve it. Generating unit tests... This may take up to 20 minutes. |
There was a problem hiding this comment.
Review Summary — 6 issues found
🔴 Critical (1)
- ILIKE search not escaped (
src/api/routes/conversations.js:167) — User search input passed directly to ILIKE without escaping%,_,\wildcards. The codebase hasescapeIlike()insrc/utils/logQuery.js— extract and reuse it.
🟡 Warning (4)
- Unbounded query in list endpoint (
src/api/routes/conversations.js:204-210) — Fetches ALL messages into memory for in-memory grouping. Will OOM on large guilds. - Unbounded query in stats endpoint (
src/api/routes/conversations.js:278-284) — Same full-table scan issue, 5 DB queries per request. - Unbounded query in detail endpoint (
src/api/routes/conversations.js:426-432) — Loads every message in a channel to find one conversation. Use a time-window around the anchor. - Unused variable
_totalChars(src/api/routes/conversations.js:450) — Computed but never used. Biome should flag this.
🔵 Nitpick (1)
- Duplicate sidebar icon (
web/src/components/layout/sidebar.tsx:42) — "Conversations" uses sameMessageSquareicon as "AI Chat".
Missing Documentation
- AGENTS.md Key Files table not updated with the new
src/api/routes/conversations.jsentry (per project conventions).
|
| Filename | Overview |
|---|---|
| migrations/012_flagged_messages.cjs | creates flagged_messages table with proper indexes and FK constraint, follows migration format correctly |
| src/api/routes/conversations.js | comprehensive conversation routes with grouping, search, stats, and flagging - performance concern about loading 10k messages for grouping already noted |
| tests/api/routes/conversations.test.js | 38 comprehensive tests covering grouping logic, CRUD, auth, and error handling |
| web/src/app/dashboard/conversations/page.tsx | conversation list page with search, filters, pagination - has hardcoded locale in formatDate |
| web/src/components/dashboard/conversation-replay.tsx | chat-style replay component with flag dialog and timestamp formatting - has hardcoded locale |
Sequence Diagram
sequenceDiagram
participant User
participant Dashboard
participant NextProxy as Next.js API Proxy
participant Express as Express API
participant DB as PostgreSQL
User->>Dashboard: Browse /dashboard/conversations
Dashboard->>NextProxy: GET /api/guilds/{id}/conversations?search=...
NextProxy->>Express: GET /guilds/{id}/conversations (with x-api-secret)
Express->>DB: SELECT messages WHERE guild_id=$1 LIMIT 10000
DB-->>Express: message rows
Express->>Express: groupMessagesIntoConversations (15min gap)
Express->>Express: paginate grouped conversations
Express-->>NextProxy: {conversations, total, page}
NextProxy-->>Dashboard: JSON response
Dashboard->>User: Display conversation list
User->>Dashboard: Click conversation
Dashboard->>NextProxy: GET /api/guilds/{id}/conversations/{convId}
NextProxy->>Express: GET /guilds/{id}/conversations/{convId}
Express->>DB: SELECT anchor message + 2hr window
DB-->>Express: message rows
Express->>Express: group and find target conversation
Express->>DB: SELECT flags for message_ids
DB-->>Express: flag statuses
Express-->>NextProxy: {messages, channelId, duration, tokenEstimate}
NextProxy-->>Dashboard: JSON response
Dashboard->>User: Display chat replay
User->>Dashboard: Flag problematic response
Dashboard->>NextProxy: POST /api/guilds/{id}/conversations/{convId}/flag
NextProxy->>Express: POST with {messageId, reason, notes}
Express->>DB: INSERT INTO flagged_messages
DB-->>Express: flag created
Express-->>NextProxy: {flagId, status}
NextProxy-->>Dashboard: success
Dashboard->>User: Show flagged indicator
Last reviewed commit: b63d639
There was a problem hiding this comment.
Pull request overview
This PR implements a full AI conversations viewer for the dashboard as described in issue #34. It adds backend routes for browsing, searching, flagging, and getting stats on AI conversations, along with a React dashboard UI for conversation list and chat-style replay, plus new proxy API routes in the Next.js web app.
Changes:
- New backend Express route module (
src/api/routes/conversations.js) with 5 endpoints: list, detail, stats, flags, and flag creation; mounted insrc/api/index.js - New dashboard pages (
conversations/page.tsx,conversations/[conversationId]/page.tsx) and a sharedConversationReplaycomponent with flag dialog; sidebar updated with new nav entry - New database migration (
012_flagged_messages.cjs) for theflagged_messagestable and new UI primitive components (textarea.tsx,label.tsx)
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
src/api/routes/conversations.js |
Core backend routes for conversation CRUD, search, grouping, flags, and stats |
src/api/index.js |
Mounts the new conversations router |
web/src/app/dashboard/conversations/page.tsx |
Conversation list UI with search, channel filter, and pagination |
web/src/app/dashboard/conversations/[conversationId]/page.tsx |
Conversation detail / replay page |
web/src/components/dashboard/conversation-replay.tsx |
Chat-style replay component with flag dialog |
web/src/components/layout/sidebar.tsx |
Adds Conversations nav entry |
web/src/app/api/guilds/[guildId]/conversations/route.ts |
Next.js proxy for conversation list |
web/src/app/api/guilds/[guildId]/conversations/[conversationId]/route.ts |
Next.js proxy for conversation detail |
web/src/app/api/guilds/[guildId]/conversations/[conversationId]/flag/route.ts |
Next.js proxy for flag POST |
web/src/app/api/guilds/[guildId]/conversations/flags/route.ts |
Next.js proxy for flags list |
web/src/app/api/guilds/[guildId]/conversations/stats/route.ts |
Next.js proxy for stats |
migrations/012_flagged_messages.cjs |
Creates flagged_messages table and indexes |
web/src/components/ui/textarea.tsx |
New Textarea UI primitive |
web/src/components/ui/label.tsx |
New Label UI primitive |
tests/api/routes/conversations.test.js |
38 tests covering routes and grouping logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…stead of full channel scan
There was a problem hiding this comment.
Review Summary — 3 issues found
🔴 Critical (1)
- Stray
$in JSX renders literal$in UI (web/src/app/dashboard/conversations/[conversationId]/page.tsx:84) —Channel ${data.channelId.slice(-4)}is JSX, not a template literal. The$renders as visible text. Should beChannel {data.channelId.slice(-4)}.
🟡 Warning (2)
- List endpoint silently drops newest conversations (
src/api/routes/conversations.js:210-214) —ORDER BY created_at ASC LIMIT 5000fetches the oldest 5000 rows. For active guilds exceeding 5000 messages in 30 days, the most recent conversations are unreachable. Change toDESCand reverse in-memory. - Duplicate
escapeIlikeinlogQuery.js(src/utils/logQuery.js:13-14) — Identical to the new sharedsrc/utils/escapeIlike.js. Import the shared utility instead of maintaining two copies.
Documentation
- AGENTS.md Key Files table not updated with
src/api/routes/conversations.js(per project conventions in AGENTS.md § Documentation).
AI prompt to fix all issues
Fix the following issues in the volvox-bot repo on branch feat/ai-conversations:
1. web/src/app/dashboard/conversations/[conversationId]/page.tsx line 84:
The line reads: Channel ${data.channelId.slice(-4)} · {data.messages.length} messages
The $ is a stray character in JSX (not inside template literals).
Change it to: Channel {data.channelId.slice(-4)} · {data.messages.length} messages
2. src/api/routes/conversations.js lines 210-215:
Change `ORDER BY created_at ASC` to `ORDER BY created_at DESC` in the LIMIT 5000 query.
Then reverse the rows before grouping:
const allConversations = groupMessagesIntoConversations(result.rows.reverse());
This ensures the most recent conversations (most relevant) are returned first
when there are more than 5000 messages in the time window.
3. src/utils/logQuery.js lines 13-14:
Remove the local `escapeIlike` function definition:
function escapeIlike(str) {
return str.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}
And add an import at the top of the file (after the existing imports):
import { escapeIlike } from './escapeIlike.js';
4. AGENTS.md Key Files table:
Add a new row after the `src/api/routes/guilds.js` entry:
| `src/api/routes/conversations.js` | Conversation REST API endpoints (list, detail, stats, flags) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Review Summary — 3 issues found
🟡 Warning (3)
-
List endpoint returns oldest messages, drops newest (
src/api/routes/conversations.js:210-219) —ORDER BY created_at ASC LIMIT 10000fetches the oldest 10,000 rows. For guilds exceeding this within the 30-day window, the most recent conversations are silently unreachable and the UI total count is wrong. Change toDESCand reverse before grouping. -
Duplicate
escapeIlikeinlogQuery.js(src/utils/escapeIlike.js) — This PR correctly extractedescapeIlikeinto a shared utility, butsrc/utils/logQuery.js:13-14still has its own local copy. Two independent implementations of the same function. -
Raw Discord snowflake in detail page (
web/src/app/dashboard/conversations/[conversationId]/page.tsx:84) — Showsdata.channelId.slice(-4)(last 4 digits of a snowflake) instead of a human-readable channel name. The list endpoint already resolveschannelNamebut the detail endpoint doesn't return it.
Documentation
- AGENTS.md Key Files table not updated — Per project conventions (AGENTS.md § Documentation),
src/api/routes/conversations.jsshould be added as a key file.
AI prompt to fix all issues
Fix the following issues in the volvox-bot repo on branch feat/ai-conversations:
1. src/api/routes/conversations.js lines 210-219:
Change `ORDER BY created_at ASC` to `ORDER BY created_at DESC` in the LIMIT 10000 query.
Then reverse the rows before grouping:
const allConversations = groupMessagesIntoConversations(result.rows.reverse());
This ensures the most recent conversations (most relevant) are returned first
when there are more than 10,000 messages in the 30-day time window.
2. src/utils/logQuery.js lines 13-15:
Remove the local `escapeIlike` function definition:
function escapeIlike(str) {
return str.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}
And add an import at the top of the file (after the existing imports on line 9):
import { escapeIlike } from './escapeIlike.js';
3. src/api/routes/conversations.js GET /:conversationId handler (around line 495-500):
Add channelName to the response JSON by resolving via req.guild:
const channelName = req.guild?.channels?.cache?.get(anchor.channel_id)?.name || anchor.channel_id;
Then include it in the response:
res.json({
messages: enrichedMessages,
channelId: anchor.channel_id,
channelName,
...
});
4. web/src/app/dashboard/conversations/[conversationId]/page.tsx:
Update the ConversationDetailResponse interface to include channelName?: string
Update line 84 to display channelName instead of raw channelId:
Channel {data.channelName || data.channelId.slice(-4)} · {data.messages.length} messages
5. AGENTS.md Key Files table:
Add a new row after the `src/api/routes/guilds.js` entry:
| `src/api/routes/conversations.js` | Conversation REST API endpoints (list, detail, stats, flags) |
- Date validation fallback: restructure from-filter logic so the 30-day default window applies whenever no valid 'from' param is provided, including when the param is present but an invalid date (previously left an unbounded query in that case) - Extract parsePagination: export the function from guilds.js and import it in conversations.js instead of duplicating the implementation - ORDER BY DESC: fetch rows newest-first then reverse before grouping so the 10k row cap retains the most recent conversations instead of silently dropping them in favour of the oldest records
- Add tests/utils/escapeIlike.test.js with 16 tests covering: - Plain strings (no-op cases) - Percent sign (%) escaping - Underscore (_) escaping - Backslash (\) escaping - Combinations of all three special chars - Remove inline escapeIlike() copy from logQuery.js (lines 13-15) - Import shared escapeIlike from ./escapeIlike.js instead Addresses PR #121 review comment: utility function was duplicated between logQuery.js and escapeIlike.js.
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.
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.
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.
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.
* 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Bill <[email protected]> Co-authored-by: Bill Chirico <[email protected]>
* feat: add role_menu_templates migration (#135) * feat: add roleMenuTemplates module with built-ins, CRUD, and share (#135) * feat: add /rolemenu command with template CRUD, apply, share (#135) * feat: seed built-in role menu templates on startup (#135) * test: add roleMenuTemplates tests — 36 passing (#135) * test: add /rolemenu command tests — 19 passing (#135) * fix: typo hasModeatorPerms → hasModeratorPerms * 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 * Fix: unterminated string in rolemenu.js * Fix: lint issues and formatting * fix: deterministic template lookup and correct roleId precedence - Add ORDER BY to getTemplateByName for deterministic results - Fix roleId precedence to preserve existing roleIds during merge - Truncate Discord embed field values to 1024 chars * fix: test assertion matches comment intent The test expected template roleId to win, but the comment said existing should take precedence. Fixed assertion to match documented behavior. * fix: filter empty roleIds and only enable when valid options exist - Filter out options with empty roleIds before saving - Only enable role menu for non-built-in templates with valid options - Add user-facing note when options are filtered * chore: remove unused _MAX_DESCRIPTION_LEN constant * fix: case-insensitive unique index for template names Use LOWER(name) in unique index to match case-insensitive queries and prevent duplicate templates differing only by case. * fix(roleMenuTemplates): add type validation for roleId and description - validateTemplateOptions now validates that optional roleId and description fields are strings when present - Update JSDoc @see reference from issue #135 (voice tracking) to issue #216 (role menu templates) - Update ON CONFLICT clause to use constraint name for consistency with the new LOWER(name) index --------- Co-authored-by: Bill <[email protected]> Co-authored-by: Bill Chirico <[email protected]>
Summary
Dashboard page to browse, search, and replay all AI conversations the bot has had.
Backend (
src/api/routes/conversations.js)/:id/conversations— List conversations (grouped by channel + 15min window), with search, user/channel filters, pagination/:id/conversations/:conversationId— Conversation detail with all messages/:id/conversations/stats— Token usage, cost estimates, conversation counts/:id/conversations/flags— List flagged messages/:id/conversations/:conversationId/flag— Flag a problematic response with notesDashboard UI
Migration
012_flagged_messages.cjs— flagged_messages table for tracking flagged responsesProxy Routes
Tests
Closes #34