Skip to content

Commit 9f9e921

Browse files
author
Bill Chirico
committed
Merge latest origin/main into feat/issue-144
2 parents 429ee46 + c6633d1 commit 9f9e921

11 files changed

Lines changed: 851 additions & 13 deletions

File tree

config.json

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -282,18 +282,29 @@
282282
"enabled": false,
283283
"maxPerUser": 25
284284
},
285+
"botStatus": {
286+
"enabled": true,
287+
"status": "online",
288+
"activityType": "Playing",
289+
"activities": [
290+
"with {memberCount} members",
291+
"in {guildCount} servers",
292+
"your assistant | Volvox"
293+
],
294+
"rotateIntervalMs": 30000
295+
},
285296
"quietMode": {
286297
"enabled": false,
287298
"allowedRoles": [
288299
"moderator"
289300
],
290301
"defaultDurationMinutes": 30,
291302
"maxDurationMinutes": 1440
292-
},
293-
"voice": {
294-
"enabled": false,
295-
"xpPerMinute": 2,
296-
"dailyXpCap": 120,
297-
"logChannel": null
298-
}
303+
},
304+
"voice": {
305+
"enabled": false,
306+
"xpPerMinute": 2,
307+
"dailyXpCap": 120,
308+
"logChannel": null
309+
}
299310
}

src/api/middleware/rateLimit.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,27 @@
33
* Simple in-memory per-IP rate limiter with no external dependencies
44
*/
55

6+
const DEFAULT_MESSAGE = 'Too many requests, please try again later';
7+
68
/**
79
* Creates rate-limiting middleware that tracks requests per IP address.
810
* Returns 429 JSON error when the limit is exceeded.
911
*
1012
* @param {Object} [options] - Rate limiter configuration
1113
* @param {number} [options.windowMs=900000] - Time window in milliseconds (default: 15 minutes)
1214
* @param {number} [options.max=100] - Maximum requests per window per IP (default: 100)
15+
* @param {string} [options.message] - Custom error message for 429 responses
1316
* @returns {import('express').RequestHandler & { destroy: () => void }} Express middleware with a destroy method to clear the cleanup timer
1417
*/
15-
export function rateLimit({ windowMs = 15 * 60 * 1000, max = 100 } = {}) {
18+
export function rateLimit({
19+
windowMs = 15 * 60 * 1000,
20+
max = 100,
21+
message = DEFAULT_MESSAGE,
22+
} = {}) {
23+
const errorMessage = typeof message === 'string' && message.trim() ? message : DEFAULT_MESSAGE;
24+
1625
/** @type {Map<string, { count: number, resetAt: number }>} */
26+
1727
const clients = new Map();
1828

1929
// Periodically clean up expired entries to prevent memory leaks
@@ -49,7 +59,7 @@ export function rateLimit({ windowMs = 15 * 60 * 1000, max = 100 } = {}) {
4959
if (entry.count > max) {
5060
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
5161
res.set('Retry-After', String(retryAfter));
52-
return res.status(429).json({ error: 'Too many requests, please try again later' });
62+
return res.status(429).json({ error: errorMessage });
5363
}
5464

5565
next();

src/api/routes/auth.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ export function stopAuthCleanup() {
108108
/** Rate limiter for OAuth initiation — 10 requests per 15 minutes per IP */
109109
const oauthRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
110110

111+
/** Rate limiter for OAuth callback — 10 attempts per minute per IP (prevents code brute-force and Discord rate-limit exhaustion) */
112+
const callbackRateLimit = rateLimit({
113+
windowMs: 60 * 1000,
114+
max: 10,
115+
message: 'Too many authentication attempts',
116+
});
117+
111118
/**
112119
* @openapi
113120
* /auth/discord:
@@ -208,6 +215,8 @@ router.get('/discord', oauthRateLimit, (_req, res) => {
208215
* application/json:
209216
* schema:
210217
* $ref: "#/components/schemas/Error"
218+
* "429":
219+
* $ref: "#/components/responses/RateLimited"
211220
* "500":
212221
* $ref: "#/components/responses/ServerError"
213222
* "502":
@@ -217,7 +226,7 @@ router.get('/discord', oauthRateLimit, (_req, res) => {
217226
* schema:
218227
* $ref: "#/components/schemas/Error"
219228
*/
220-
router.get('/discord/callback', async (req, res) => {
229+
router.get('/discord/callback', callbackRateLimit, async (req, res) => {
221230
cleanExpiredStates();
222231

223232
const { code, state } = req.query;

src/api/utils/configAllowlist.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const SAFE_CONFIG_KEYS = new Set([
2828
'review',
2929
'auditLog',
3030
'reminders',
31+
'botStatus',
3132
'quietMode',
3233
]);
3334

src/config-listeners.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import { addPostgresTransport, error, info, removePostgresTransport } from './logger.js';
12+
import { reloadBotStatus } from './modules/botStatus.js';
1213
import { onConfigChange } from './modules/config.js';
1314
import { fireEvent } from './modules/webhookNotifier.js';
1415
import { cacheDelPattern } from './utils/cache.js';
@@ -104,6 +105,22 @@ export function registerConfigListeners({ dbPool, config }) {
104105
await cacheDelPattern(`discord:guild:${guildId}:*`).catch(() => {});
105106
}
106107
});
108+
// ── Bot status / presence hot-reload ───────────────────────────────
109+
for (const key of [
110+
'botStatus',
111+
'botStatus.enabled',
112+
'botStatus.status',
113+
'botStatus.activityType',
114+
'botStatus.activities',
115+
'botStatus.rotateIntervalMs',
116+
]) {
117+
onConfigChange(key, (_newValue, _oldValue, _path, guildId) => {
118+
// Bot presence is global — ignore per-guild overrides here
119+
if (guildId && guildId !== 'global') return;
120+
reloadBotStatus();
121+
});
122+
}
123+
107124
onConfigChange('reputation.*', async (_newValue, _oldValue, _path, guildId) => {
108125
if (guildId && guildId !== 'global') {
109126
await cacheDelPattern(`leaderboard:${guildId}*`).catch(() => {});

src/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
startConversationCleanup,
4444
stopConversationCleanup,
4545
} from './modules/ai.js';
46+
import { startBotStatus, stopBotStatus } from './modules/botStatus.js';
4647
import { getConfig, loadConfig } from './modules/config.js';
4748
import { registerEventHandlers } from './modules/events.js';
4849
import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js';
@@ -54,7 +55,6 @@ import { startScheduler, stopScheduler } from './modules/scheduler.js';
5455
import { startTriage, stopTriage } from './modules/triage.js';
5556
import { startVoiceFlush, stopVoiceFlush } from './modules/voice.js';
5657
import { fireEventAllGuilds } from './modules/webhookNotifier.js';
57-
import { closeRedisClient as closeRedis, initRedis } from './redis.js';
5858
import { pruneOldLogs } from './transports/postgres.js';
5959
import { stopCacheCleanup } from './utils/cache.js';
6060
import {
@@ -318,6 +318,7 @@ async function gracefulShutdown(signal) {
318318
stopScheduler();
319319
stopGithubFeed();
320320
perfMonitor.stop();
321+
stopBotStatus();
321322
stopVoiceFlush();
322323

323324
// 1.5. Stop API server (drain in-flight HTTP requests before closing DB)
@@ -532,6 +533,9 @@ async function startup() {
532533
await loadCommands();
533534
await client.login(token);
534535

536+
// Start bot status/activity rotation (runs after login so client.user is available)
537+
startBotStatus(client);
538+
535539
// Set Sentry context now that we know the bot identity (no-op if disabled)
536540
import('./sentry.js')
537541
.then(({ Sentry, sentryEnabled }) => {

0 commit comments

Comments
 (0)