Skip to content

Commit 56a26ed

Browse files
author
Bill Chirico
committed
Merge remote-tracking branch 'origin/main' into fix/issue-188
2 parents e0a2b1b + bd0658f commit 56a26ed

96 files changed

Lines changed: 15567 additions & 279 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"!node_modules",
99
"!coverage",
1010
"!logs",
11-
"!data"
11+
"!data",
12+
"!feat-issue-164"
1213
]
1314
},
1415
"linter": {

config.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"debugFooter": true,
4343
"debugFooterLevel": "verbose",
4444
"moderationLogChannel": "1473219285651292201",
45-
"statusReactions": true
45+
"statusReactions": true,
46+
"dailyBudgetUsd": 10
4647
},
4748
"welcome": {
4849
"enabled": true,
@@ -281,5 +282,23 @@
281282
"reminders": {
282283
"enabled": false,
283284
"maxPerUser": 25
285+
},
286+
"aiAutoMod": {
287+
"enabled": false,
288+
"model": "claude-haiku-4-5",
289+
"thresholds": {
290+
"toxicity": 0.7,
291+
"spam": 0.8,
292+
"harassment": 0.7
293+
},
294+
"actions": {
295+
"toxicity": "flag",
296+
"spam": "delete",
297+
"harassment": "warn"
298+
},
299+
"timeoutDurationMs": 300000,
300+
"flagChannelId": null,
301+
"autoDelete": true,
302+
"exemptRoleIds": []
284303
}
285304
}

migrations/004_command_aliases.cjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Migration 004: Guild Command Aliases Table
3+
*
4+
* Stores per-guild command aliases created by guild admins.
5+
* Each alias maps a short custom name (e.g. "w") to an existing bot command (e.g. "warn").
6+
* discord_command_id stores the ID returned by Discord after registering the alias
7+
* as a guild-specific slash command, enabling clean removal later.
8+
*/
9+
10+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
11+
exports.up = (pgm) => {
12+
pgm.sql(`
13+
CREATE TABLE IF NOT EXISTS guild_command_aliases (
14+
id SERIAL PRIMARY KEY,
15+
guild_id TEXT NOT NULL,
16+
alias TEXT NOT NULL,
17+
target_command TEXT NOT NULL,
18+
discord_command_id TEXT,
19+
created_by TEXT NOT NULL,
20+
created_at TIMESTAMPTZ DEFAULT NOW(),
21+
UNIQUE(guild_id, alias)
22+
)
23+
`);
24+
25+
pgm.sql(`
26+
CREATE INDEX IF NOT EXISTS idx_guild_command_aliases_guild_id
27+
ON guild_command_aliases(guild_id)
28+
`);
29+
};
30+
31+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
32+
exports.down = (pgm) => {
33+
pgm.sql(`DROP INDEX IF EXISTS idx_guild_command_aliases_guild_id`);
34+
pgm.sql(`DROP TABLE IF EXISTS guild_command_aliases`);
35+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Migration 004: Performance Indexes
3+
*
4+
* Adds missing composite indexes and a pg_trgm GIN index to resolve:
5+
*
6+
* 1. ai_feedback trend queries — getFeedbackTrend() filters by guild_id AND
7+
* created_at but only had a single-column guild_id index, forcing a full
8+
* guild scan + sort for every trend call.
9+
*
10+
* 2. conversations ILIKE search — content ILIKE '%...%' is a seq-scan
11+
* without pg_trgm. Installing the extension + GIN index reduces search from
12+
* O(n) to O(log n * trigram matches).
13+
*
14+
* 3. conversations(guild_id, created_at) — The default 30-day listing query
15+
* (WHERE guild_id = $1 AND created_at >= $2 ORDER BY created_at DESC)
16+
* benefits from a dedicated 2-column index over the existing 3-column
17+
* (guild_id, channel_id, created_at) composite when channel_id is not filtered.
18+
*
19+
* 4. flagged_messages(guild_id, message_id) — POST /flag and the detail
20+
* endpoint both do WHERE guild_id = $1 AND message_id = ANY($2) which
21+
* the existing (guild_id, status) index cannot serve efficiently.
22+
*/
23+
24+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
25+
exports.up = (pgm) => {
26+
// ai_feedback: composite for trend + recent queries
27+
// getFeedbackTrend: WHERE guild_id = $1 AND created_at >= NOW() - INTERVAL ...
28+
// getRecentFeedback: WHERE guild_id = $1 ORDER BY created_at DESC LIMIT $2
29+
pgm.sql(`
30+
CREATE INDEX IF NOT EXISTS idx_ai_feedback_guild_created
31+
ON ai_feedback(guild_id, created_at DESC)
32+
`);
33+
34+
// conversations: pg_trgm for ILIKE searches
35+
// Enable the extension first (idempotent)
36+
pgm.sql(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
37+
38+
// GIN index over content column -- supports col ILIKE '%term%' and col ~ 'pattern'
39+
pgm.sql(`
40+
CREATE INDEX IF NOT EXISTS idx_conversations_content_trgm
41+
ON conversations USING gin(content gin_trgm_ops)
42+
`);
43+
44+
// conversations: (guild_id, created_at) for default 30-day listing
45+
// The existing idx_conversations_guild_channel_created covers (guild_id, channel_id, created_at)
46+
// but queries that filter only by guild_id + date range skip the channel_id column,
47+
// making this 2-column index cheaper to scan.
48+
pgm.sql(`
49+
CREATE INDEX IF NOT EXISTS idx_conversations_guild_created
50+
ON conversations(guild_id, created_at DESC)
51+
`);
52+
53+
// flagged_messages: (guild_id, message_id) for detail + flag endpoints
54+
// Used by:
55+
// GET /:conversationId -> WHERE guild_id = $1 AND message_id = ANY($2)
56+
// POST /:conversationId/flag -> msgCheck + anchorCheck in parallel
57+
pgm.sql(`
58+
CREATE INDEX IF NOT EXISTS idx_flagged_messages_guild_message
59+
ON flagged_messages(guild_id, message_id)
60+
`);
61+
};
62+
63+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
64+
exports.down = (pgm) => {
65+
pgm.sql(`DROP INDEX IF EXISTS idx_flagged_messages_guild_message`);
66+
pgm.sql(`DROP INDEX IF EXISTS idx_conversations_guild_created`);
67+
pgm.sql(`DROP INDEX IF EXISTS idx_conversations_content_trgm`);
68+
pgm.sql(`DROP INDEX IF EXISTS idx_ai_feedback_guild_created`);
69+
// Note: do NOT drop pg_trgm extension on down -- it may be used elsewhere.
70+
};

migrations/004_reaction_roles.cjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use strict';
2+
3+
/**
4+
* Migration: Reaction Role Menus
5+
*
6+
* Creates tables to persist reaction-role mappings across bot restarts.
7+
*
8+
* - reaction_role_menus: One row per "reaction role" message posted in Discord
9+
* - reaction_role_entries: One row per emoji→role mapping attached to a menu
10+
*/
11+
12+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
13+
exports.up = (pgm) => {
14+
// ── reaction_role_menus ────────────────────────────────────────────
15+
pgm.sql(`
16+
CREATE TABLE IF NOT EXISTS reaction_role_menus (
17+
id SERIAL PRIMARY KEY,
18+
guild_id TEXT NOT NULL,
19+
channel_id TEXT NOT NULL,
20+
message_id TEXT NOT NULL,
21+
title TEXT NOT NULL DEFAULT 'React to get a role',
22+
description TEXT,
23+
created_at TIMESTAMPTZ DEFAULT NOW(),
24+
UNIQUE (guild_id, message_id)
25+
)
26+
`);
27+
pgm.sql(
28+
'CREATE INDEX IF NOT EXISTS idx_reaction_role_menus_guild ON reaction_role_menus(guild_id)',
29+
);
30+
pgm.sql(
31+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_role_menus_message ON reaction_role_menus(message_id)',
32+
);
33+
34+
// ── reaction_role_entries ──────────────────────────────────────────
35+
pgm.sql(`
36+
CREATE TABLE IF NOT EXISTS reaction_role_entries (
37+
id SERIAL PRIMARY KEY,
38+
menu_id INTEGER NOT NULL REFERENCES reaction_role_menus(id) ON DELETE CASCADE,
39+
emoji TEXT NOT NULL,
40+
role_id TEXT NOT NULL,
41+
created_at TIMESTAMPTZ DEFAULT NOW(),
42+
UNIQUE (menu_id, emoji)
43+
)
44+
`);
45+
pgm.sql(
46+
'CREATE INDEX IF NOT EXISTS idx_reaction_role_entries_menu ON reaction_role_entries(menu_id)',
47+
);
48+
};
49+
50+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
51+
exports.down = (pgm) => {
52+
pgm.sql('DROP TABLE IF EXISTS reaction_role_entries');
53+
pgm.sql('DROP TABLE IF EXISTS reaction_role_menus');
54+
};

migrations/004_voice_sessions.cjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Migration 004: Voice Sessions Table
3+
*
4+
* Tracks voice channel activity for engagement metrics.
5+
* Records join/leave/move events with duration.
6+
* Gated behind voice.enabled in config (opt-in per guild).
7+
*/
8+
9+
'use strict';
10+
11+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
12+
exports.up = (pgm) => {
13+
pgm.sql(`
14+
CREATE TABLE IF NOT EXISTS voice_sessions (
15+
id SERIAL PRIMARY KEY,
16+
guild_id TEXT NOT NULL,
17+
user_id TEXT NOT NULL,
18+
channel_id TEXT NOT NULL,
19+
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
20+
left_at TIMESTAMPTZ,
21+
duration_seconds INTEGER,
22+
CONSTRAINT chk_duration_nonneg CHECK (duration_seconds IS NULL OR duration_seconds >= 0)
23+
)
24+
`);
25+
26+
pgm.sql(`
27+
CREATE INDEX IF NOT EXISTS idx_voice_sessions_guild_user
28+
ON voice_sessions(guild_id, user_id)
29+
`);
30+
31+
pgm.sql(`
32+
CREATE INDEX IF NOT EXISTS idx_voice_sessions_guild_joined
33+
ON voice_sessions(guild_id, joined_at)
34+
`);
35+
36+
// Unique partial index prevents duplicate open sessions for the same (guild_id, user_id)
37+
// This ensures crash recovery cannot leave multiple open rows per user per guild
38+
pgm.sql(`
39+
CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_sessions_open_unique
40+
ON voice_sessions(guild_id, user_id)
41+
WHERE left_at IS NULL
42+
`);
43+
};
44+
45+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
46+
exports.down = (pgm) => {
47+
pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_open_unique`);
48+
pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_guild_joined`);
49+
pgm.sql(`DROP INDEX IF EXISTS idx_voice_sessions_guild_user`);
50+
pgm.sql(`DROP TABLE IF EXISTS voice_sessions`);
51+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable */
2+
'use strict';
3+
4+
/**
5+
* Migration 005: Webhook notifications delivery log
6+
*
7+
* Creates webhook_delivery_log table to store delivery attempts
8+
* per webhook endpoint. Endpoint configs live in the per-guild config JSON.
9+
*/
10+
11+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
12+
exports.up = (pgm) => {
13+
pgm.sql(`
14+
CREATE TABLE IF NOT EXISTS webhook_delivery_log (
15+
id SERIAL PRIMARY KEY,
16+
guild_id TEXT NOT NULL,
17+
endpoint_id TEXT NOT NULL,
18+
event_type TEXT NOT NULL,
19+
payload JSONB NOT NULL,
20+
status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'pending')),
21+
response_code INTEGER,
22+
response_body TEXT,
23+
attempt INTEGER NOT NULL DEFAULT 1,
24+
delivered_at TIMESTAMPTZ DEFAULT NOW()
25+
);
26+
27+
CREATE INDEX IF NOT EXISTS idx_webhook_delivery_log_guild
28+
ON webhook_delivery_log (guild_id, delivered_at DESC);
29+
30+
CREATE INDEX IF NOT EXISTS idx_webhook_delivery_log_endpoint
31+
ON webhook_delivery_log (endpoint_id, delivered_at DESC);
32+
`);
33+
};
34+
35+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
36+
exports.down = (pgm) => {
37+
pgm.sql(`
38+
DROP INDEX IF EXISTS idx_webhook_delivery_log_endpoint;
39+
DROP INDEX IF EXISTS idx_webhook_delivery_log_guild;
40+
DROP TABLE IF EXISTS webhook_delivery_log;
41+
`);
42+
};

src/api/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import { requireAuth } from './middleware/auth.js';
99
import aiFeedbackRouter from './routes/ai-feedback.js';
1010
import auditLogRouter from './routes/auditLog.js';
1111
import authRouter from './routes/auth.js';
12+
import backupRouter from './routes/backup.js';
1213
import communityRouter from './routes/community.js';
1314
import configRouter from './routes/config.js';
1415
import conversationsRouter from './routes/conversations.js';
1516
import guildsRouter from './routes/guilds.js';
1617
import healthRouter from './routes/health.js';
1718
import membersRouter from './routes/members.js';
1819
import moderationRouter from './routes/moderation.js';
20+
import notificationsRouter from './routes/notifications.js';
21+
import performanceRouter from './routes/performance.js';
1922
import ticketsRouter from './routes/tickets.js';
2023
import webhooksRouter from './routes/webhooks.js';
2124

@@ -58,7 +61,16 @@ router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter)
5861
// GET-only; no audit middleware needed (reads are not mutating actions)
5962
router.use('/guilds', requireAuth(), auditLogRouter);
6063

64+
// Performance metrics — require x-api-secret (authenticated via route handler)
65+
router.use('/performance', performanceRouter);
66+
67+
// Notification webhook management routes — require API secret or OAuth2 JWT
68+
router.use('/guilds', requireAuth(), auditLogMiddleware(), notificationsRouter);
69+
6170
// Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret)
6271
router.use('/webhooks', requireAuth(), webhooksRouter);
6372

73+
// Backup routes — require API secret or OAuth2 JWT
74+
router.use('/backups', requireAuth(), auditLogMiddleware(), backupRouter);
75+
6476
export default router;

src/api/middleware/auditLog.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { info, error as logError } from '../../logger.js';
88
import { getConfig } from '../../modules/config.js';
99
import { maskSensitiveFields } from '../utils/configAllowlist.js';
10+
import { broadcastAuditEntry } from '../ws/auditStream.js';
1011

1112
/** HTTP methods considered mutating */
1213
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
@@ -104,7 +105,8 @@ function insertAuditEntry(pool, entry) {
104105
try {
105106
const result = pool.query(
106107
`INSERT INTO audit_logs (guild_id, user_id, action, target_type, target_id, details, ip_address)
107-
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
108+
VALUES ($1, $2, $3, $4, $5, $6, $7)
109+
RETURNING id, guild_id, user_id, action, target_type, target_id, details, ip_address, created_at`,
108110
[
109111
guildId || 'global',
110112
userId,
@@ -118,8 +120,26 @@ function insertAuditEntry(pool, entry) {
118120

119121
if (result && typeof result.then === 'function') {
120122
result
121-
.then(() => {
123+
.then((insertResult) => {
122124
info('Audit log entry created', { action, guildId, userId });
125+
// Broadcast to real-time audit log WebSocket clients
126+
const row = insertResult?.rows?.[0];
127+
const broadcastEntry = {
128+
guild_id: guildId || 'global',
129+
user_id: userId,
130+
action,
131+
target_type: targetType || null,
132+
target_id: targetId || null,
133+
details: details || null,
134+
ip_address: ipAddress || null,
135+
created_at: new Date().toISOString(),
136+
...(row || {}),
137+
};
138+
try {
139+
broadcastAuditEntry(broadcastEntry);
140+
} catch {
141+
// Non-critical — streaming failure must not affect audit integrity
142+
}
123143
})
124144
.catch((err) => {
125145
logError('Failed to insert audit log entry', { error: err.message, action, guildId });

0 commit comments

Comments
 (0)