From 19dc04fb40aefde017c6c74fecb1f518272c1069 Mon Sep 17 00:00:00 2001 From: Bill Chirico Date: Tue, 3 Feb 2026 21:41:58 -0500 Subject: [PATCH] feat(rateLimit): implement per-user and per-channel rate limiting - Add rateLimit configuration to config.json with configurable limits - Create src/utils/rateLimit.js with sliding window rate limiting logic - Integrate rate limiting into AI chat message handler - Add automatic cleanup of old rate limit entries - Provide user-friendly rate limit exceeded messages Closes review comment about dev config files (not included in this commit) --- config.json | 11 +++ src/modules/events.js | 12 +++ src/utils/rateLimit.js | 170 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 src/utils/rateLimit.js diff --git a/config.json b/config.json index 886171380..e4614415c 100644 --- a/config.json +++ b/config.json @@ -20,6 +20,17 @@ "level": "info", "fileOutput": true }, + "rateLimit": { + "enabled": true, + "perUser": { + "requestsPerMinute": 10, + "windowMinutes": 1 + }, + "perChannel": { + "requestsPerMinute": 20, + "windowMinutes": 1 + } + }, "permissions": { "enabled": true, "adminRoleId": null, diff --git a/src/modules/events.js b/src/modules/events.js index 1c8d61beb..9c30734dd 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -6,6 +6,7 @@ import { sendWelcomeMessage } from './welcome.js'; import { isSpam, sendSpamAlert } from './spam.js'; import { generateResponse } from './ai.js'; +import { checkRateLimit, getRateLimitMessage, startRateLimitCleanup } from '../utils/rateLimit.js'; /** * Register bot ready event handler @@ -32,6 +33,10 @@ export function registerReadyHandler(client, config, healthMonitor) { if (config.moderation?.enabled) { console.log(`🛡️ Moderation enabled`); } + if (config.rateLimit?.enabled) { + console.log(`⏱️ Rate limiting enabled (${config.rateLimit.perUser.requestsPerMinute}/min per user, ${config.rateLimit.perChannel.requestsPerMinute}/min per channel)`); + startRateLimitCleanup(config); + } }); } @@ -85,6 +90,13 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { return; } + // Check rate limits + const rateLimitCheck = checkRateLimit(message.author.id, message.channel.id, config); + if (!rateLimitCheck.allowed) { + await message.reply(getRateLimitMessage(rateLimitCheck.retryAfter, rateLimitCheck.type)); + return; + } + await message.channel.sendTyping(); const response = await generateResponse( diff --git a/src/utils/rateLimit.js b/src/utils/rateLimit.js new file mode 100644 index 000000000..e0fa4d04d --- /dev/null +++ b/src/utils/rateLimit.js @@ -0,0 +1,170 @@ +/** + * Rate Limiting Utility + * + * Provides per-user and per-channel rate limiting for AI requests + * using a timestamp-based sliding window approach. + */ + +import { info, warn, debug } from '../logger.js'; + +// Rate limiting tracking (timestamp-based sliding window) +const userRateLimits = new Map(); +const channelRateLimits = new Map(); + +// Cleanup interval reference +let cleanupInterval = null; +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Clean up every 5 minutes + +/** + * Check if user and channel are within rate limits + * @param {string} userId - Discord user ID + * @param {string} channelId - Discord channel ID + * @param {Object} config - Bot configuration + * @returns {{ allowed: boolean, retryAfter: number }} - Whether request is allowed and seconds until next allowed request + */ +export function checkRateLimit(userId, channelId, config) { + if (!config.rateLimit?.enabled) { + return { allowed: true, retryAfter: 0 }; + } + + const now = Date.now(); + + // Check user rate limit + const userLimit = config.rateLimit.perUser; + const userWindowMs = userLimit.windowMinutes * 60 * 1000; + + if (!userRateLimits.has(userId)) { + userRateLimits.set(userId, []); + } + + const userTimestamps = userRateLimits.get(userId); + const userRecentRequests = userTimestamps.filter(ts => now - ts < userWindowMs); + + if (userRecentRequests.length >= userLimit.requestsPerMinute) { + const oldestRequest = Math.min(...userRecentRequests); + const retryAfter = Math.ceil((oldestRequest + userWindowMs - now) / 1000); + warn('Rate limit exceeded for user', { + userId, + requests: userRecentRequests.length, + limit: userLimit.requestsPerMinute, + retryAfter + }); + return { allowed: false, retryAfter, type: 'user' }; + } + + // Check channel rate limit + const channelLimit = config.rateLimit.perChannel; + const channelWindowMs = channelLimit.windowMinutes * 60 * 1000; + + if (!channelRateLimits.has(channelId)) { + channelRateLimits.set(channelId, []); + } + + const channelTimestamps = channelRateLimits.get(channelId); + const channelRecentRequests = channelTimestamps.filter(ts => now - ts < channelWindowMs); + + if (channelRecentRequests.length >= channelLimit.requestsPerMinute) { + const oldestRequest = Math.min(...channelRecentRequests); + const retryAfter = Math.ceil((oldestRequest + channelWindowMs - now) / 1000); + warn('Rate limit exceeded for channel', { + channelId, + requests: channelRecentRequests.length, + limit: channelLimit.requestsPerMinute, + retryAfter + }); + return { allowed: false, retryAfter, type: 'channel' }; + } + + // Update tracking with current request + userTimestamps.push(now); + userRateLimits.set(userId, userTimestamps.filter(ts => now - ts < userWindowMs)); + + channelTimestamps.push(now); + channelRateLimits.set(channelId, channelTimestamps.filter(ts => now - ts < channelWindowMs)); + + debug('Rate limit check passed', { userId, channelId }); + return { allowed: true, retryAfter: 0 }; +} + +/** + * Clean up old rate limit entries to prevent memory leaks + * @param {Object} config - Bot configuration + */ +export function cleanupOldEntries(config) { + if (!config.rateLimit?.enabled) return; + + const now = Date.now(); + const userWindowMs = config.rateLimit.perUser.windowMinutes * 60 * 1000; + const channelWindowMs = config.rateLimit.perChannel.windowMinutes * 60 * 1000; + const maxWindowMs = Math.max(userWindowMs, channelWindowMs); + + let usersCleaned = 0; + let channelsCleaned = 0; + + // Clean up user rate limits + for (const [userId, timestamps] of userRateLimits.entries()) { + const recent = timestamps.filter(ts => now - ts < maxWindowMs); + if (recent.length === 0) { + userRateLimits.delete(userId); + usersCleaned++; + } else { + userRateLimits.set(userId, recent); + } + } + + // Clean up channel rate limits + for (const [channelId, timestamps] of channelRateLimits.entries()) { + const recent = timestamps.filter(ts => now - ts < maxWindowMs); + if (recent.length === 0) { + channelRateLimits.delete(channelId); + channelsCleaned++; + } else { + channelRateLimits.set(channelId, recent); + } + } + + if (usersCleaned > 0 || channelsCleaned > 0) { + debug('Rate limit cleanup completed', { usersCleaned, channelsCleaned }); + } +} + +/** + * Start the rate limit cleanup interval + * @param {Object} config - Bot configuration + */ +export function startRateLimitCleanup(config) { + if (cleanupInterval) { + clearInterval(cleanupInterval); + } + + if (config.rateLimit?.enabled) { + cleanupInterval = setInterval(() => cleanupOldEntries(config), CLEANUP_INTERVAL_MS); + info('Rate limiting enabled', { + perUser: config.rateLimit.perUser, + perChannel: config.rateLimit.perChannel + }); + } +} + +/** + * Stop the rate limit cleanup interval + */ +export function stopRateLimitCleanup() { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } +} + +/** + * Get user-friendly rate limit message + * @param {number} retryAfter - Seconds until next allowed request + * @param {string} type - 'user' or 'channel' + * @returns {string} User-friendly message + */ +export function getRateLimitMessage(retryAfter, type = 'user') { + if (type === 'channel') { + return `This channel is getting a lot of activity! Please wait ${retryAfter} seconds before asking me something.`; + } + return `Whoa there! You're asking too fast. Please wait ${retryAfter} seconds before trying again.`; +}