const DEFAULT_RATE_LIMITS = Object.freeze({ roles: { admin: { requests: 30, window_seconds: 60 }, mod: { requests: 12, window_seconds: 60 }, user: { requests: 4, window_seconds: 60 } }, platforms: { webui: { requests: 60, window_seconds: 60 }, discord: { requests: 30, window_seconds: 60 }, twitch: { requests: 20, window_seconds: 60 }, youtube: { requests: 20, window_seconds: 60 }, kick: { requests: 20, window_seconds: 60 }, other: { requests: 20, window_seconds: 60 } }, per_user: { requests: 2, window_seconds: 30 }, per_channel: { requests: 12, window_seconds: 60 }, queue_when_limited: false }); class AiRateLimiter { constructor(getConfig, recordAudit = () => {}) { this.getConfig = getConfig; this.recordAudit = recordAudit; this.buckets = new Map(); this.denials = []; } check(context) { const config = mergeLimits(this.getConfig()?.rate_limits); const platform = context.platform || context.origin || "webui"; const checks = [ [`role:${context.role}`, config.roles[context.role] || config.roles.user], [`platform:${platform}`, config.platforms[platform] || config.platforms.other], [`user:${context.user_id}`, config.per_user] ]; const channelKey = context.server_id || context.channel_id; if (channelKey) checks.push([`channel:${platform}:${channelKey}`, config.per_channel]); const now = Date.now(); const prepared = []; for (const [key, limit] of checks) { const result = this.inspect(key, limit, now); if (!result.allowed) { const denial = { at: new Date(now).toISOString(), reason: "rate_limited", bucket: key, retry_after_seconds: result.retry_after_seconds, user_id: context.user_id, platform, channel_id: context.channel_id || null }; this.denials.unshift(denial); this.denials = this.denials.slice(0, 100); this.recordAudit({ kind: "access", status: "denied", denial_reason: "rate_limited", ...denial }); return { ...result, reason: "rate_limited", bucket: key }; } prepared.push(result); } for (const result of prepared) this.commit(result, now); return { allowed: true }; } recentDenials(limit = 25) { return this.denials.slice(0, limit); } inspect(key, limit, now) { const requests = Math.max(0, Number(limit?.requests) || 0); const windowMs = Math.max(1, Number(limit?.window_seconds) || 1) * 1000; const current = (this.buckets.get(key) || []).filter((timestamp) => now - timestamp < windowMs); if (!requests) return { allowed: true, key, current, disabled: true }; if (current.length >= requests) { const retry = Math.max(1, Math.ceil((windowMs - (now - current[0])) / 1000)); return { allowed: false, retry_after_seconds: retry, key, current }; } return { allowed: true, key, current }; } commit(result, now) { if (result.disabled) return; this.buckets.set(result.key, [...result.current, now]); } } function mergeLimits(value = {}) { return { ...DEFAULT_RATE_LIMITS, ...value, roles: { ...DEFAULT_RATE_LIMITS.roles, ...(value.roles || {}) }, platforms: { ...DEFAULT_RATE_LIMITS.platforms, ...(value.platforms || {}) }, per_user: { ...DEFAULT_RATE_LIMITS.per_user, ...(value.per_user || {}) }, per_channel: { ...DEFAULT_RATE_LIMITS.per_channel, ...(value.per_channel || {}) } }; } module.exports = { DEFAULT_RATE_LIMITS, AiRateLimiter, mergeLimits };