@botport/core
Version:
Unified framework for Discord bot products, published by BotPort. Combines docky and framework functionality.
211 lines (178 loc) • 5.58 kB
JavaScript
import logger from '../logger/logger.js';
class UserLimiter {
constructor() {
this.limits = new Map();
// Default limits (can be overridden)
this.defaultLimits = {
'ticket:create': { max: 3, window: 60000 }, // 3 per minute
'ticket:close': { max: 5, window: 30000 }, // 5 per 30 seconds
'button:click': { max: 10, window: 5000 }, // 10 per 5 seconds
'command:use': { max: 20, window: 10000 }, // 20 per 10 seconds
'message:send': { max: 5, window: 5000 }, // 5 per 5 seconds
};
// Custom limits (set by addons)
this.customLimits = new Map();
// Start cleanup interval
this.startCleanup();
}
/**
* Check if user is rate limited for an action
* @param {string} userId - User ID
* @param {string} action - Action identifier
* @param {Object} [customLimit] - Override default limit
* @returns {Object} { allowed: boolean, remaining: number, retryAfter: number }
*/
check(userId, action, customLimit = null) {
const key = `${userId}:${action}`;
const limit = customLimit || this.customLimits.get(action) || this.defaultLimits[action] || { max: 5, window: 5000 };
// Get or create limit entry
if (!this.limits.has(key)) {
this.limits.set(key, {
count: 0,
resetAt: Date.now() + limit.window,
limit: limit
});
}
const entry = this.limits.get(key);
const now = Date.now();
// Reset if window expired
if (now >= entry.resetAt) {
entry.count = 0;
entry.resetAt = now + limit.window;
}
// Check if over limit
if (entry.count >= limit.max) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
logger.debug(`🚫 Rate limit hit: ${key} (retry in ${retryAfter}s)`);
return {
allowed: false,
remaining: 0,
retryAfter: retryAfter,
resetAt: entry.resetAt
};
}
// Increment count
entry.count++;
const remaining = limit.max - entry.count;
logger.debug(`✅ Rate limit passed: ${key} (${remaining} remaining)`);
return {
allowed: true,
remaining: remaining,
retryAfter: 0,
resetAt: entry.resetAt
};
}
/**
* Set a custom limit for an action
* @param {string} action - Action identifier
* @param {number} max - Maximum requests
* @param {number} window - Time window in milliseconds
*/
setLimit(action, max, window) {
this.customLimits.set(action, { max, window });
logger.info(`📝 Custom limit set: ${action} → ${max} per ${window}ms`);
}
/**
* Reset limits for a specific user and action
* @param {string} userId - User ID
* @param {string} [action] - Action identifier (optional, resets all if not provided)
*/
reset(userId, action = null) {
if (action) {
const key = `${userId}:${action}`;
this.limits.delete(key);
logger.debug(`🔄 Reset limit: ${key}`);
} else {
// Reset all limits for user
let count = 0;
for (const key of this.limits.keys()) {
if (key.startsWith(`${userId}:`)) {
this.limits.delete(key);
count++;
}
}
logger.debug(`🔄 Reset ${count} limits for user: ${userId}`);
}
}
/**
* Get current limit status for a user and action
* @param {string} userId - User ID
* @param {string} action - Action identifier
* @returns {Object|null}
*/
getStatus(userId, action) {
const key = `${userId}:${action}`;
const entry = this.limits.get(key);
if (!entry) return null;
const now = Date.now();
const resetIn = Math.max(0, entry.resetAt - now);
return {
count: entry.count,
max: entry.limit.max,
remaining: entry.limit.max - entry.count,
resetIn: Math.ceil(resetIn / 1000),
resetAt: entry.resetAt
};
}
/**
* Cleanup expired entries (prevent memory leak)
* @private
*/
cleanup() {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of this.limits) {
// Remove entries that expired more than 1 minute ago
if (now > entry.resetAt + 60000) {
this.limits.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug(`🗑️ Cleaned up ${cleaned} expired rate limit entries`);
}
}
/**
* Start automatic cleanup
* @private
*/
startCleanup() {
// Cleanup every 5 minutes
setInterval(() => {
this.cleanup();
}, 300000);
}
/**
* Get statistics
* @returns {Object}
*/
getStats() {
const stats = {
totalEntries: this.limits.size,
activeUsers: new Set(),
byAction: {}
};
for (const key of this.limits.keys()) {
const [userId, action] = key.split(':');
stats.activeUsers.add(userId);
if (!stats.byAction[action]) {
stats.byAction[action] = 0;
}
stats.byAction[action]++;
}
stats.activeUsers = stats.activeUsers.size;
return stats;
}
/**
* Clear all limits (use with caution!)
*/
clear() {
const count = this.limits.size;
this.limits.clear();
logger.warn(`⚠️ Cleared ${count} rate limit entries`);
}
}
// Singleton instance
const userLimiter = new UserLimiter();
export default userLimiter;
export { UserLimiter };