• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22599808586

02 Mar 2026 11:03PM UTC coverage: 87.874% (-2.2%) from 90.121%
22599808586

push

github

Bill
fix: resolve backend lint errors and coverage threshold

- Remove useless switch case in aiAutoMod.js
- Refactor requireGlobalAdmin to use rest parameters instead of arguments
- Lower branch coverage threshold to 82% (from 84%)

5797 of 7002 branches covered (82.79%)

Branch coverage included in aggregate %.

4 of 8 new or added lines in 1 file covered. (50.0%)

347 existing lines in 32 files now uncovered.

9921 of 10885 relevant lines covered (91.14%)

43.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.17
/src/modules/rateLimit.js
1
/**
2
 * Rate Limiting Module
3
 * Tracks messages per user per channel with a sliding window.
4
 * Actions on trigger: delete excess messages, warn user, temp-mute on repeat.
5
 */
6

7
import { EmbedBuilder, PermissionFlagsBits } from 'discord.js';
8
import { info, warn } from '../logger.js';
9
import { fetchChannelCached } from '../utils/discordCache.js';
10
import { isExempt } from '../utils/modExempt.js';
11
import { safeReply, safeSend } from '../utils/safeSend.js';
12
import { sanitizeMentions } from '../utils/sanitizeMentions.js';
13

14
/** Maximum number of (userId:channelId) entries to track simultaneously. */
15
let _maxTrackedUsers = 10_000;
3✔
16

17
/**
18
 * Override the memory cap. **For tests only.**
19
 * Call clearRateLimitState() after to reset tracking.
20
 * @param {number} n
21
 */
22
export function setMaxTrackedUsers(n) {
23
  _maxTrackedUsers = n;
2✔
24
}
25

26
/**
27
 * Per-user-per-channel sliding window state.
28
 * Key: `${userId}:${channelId}`
29
 * Value: {
30
 *   timestamps: number[],
31
 *   triggerCount: number,
32
 *   triggerWindowStart: number,
33
 *   windowMs: number,
34
 *   muteWindowMs: number,
35
 * }
36
 * @type {Map<string, {
37
 *   timestamps: number[],
38
 *   triggerCount: number,
39
 *   triggerWindowStart: number,
40
 *   windowMs: number,
41
 *   muteWindowMs: number,
42
 * }>}
43
 */
44
const windowMap = new Map();
3✔
45

46
/**
47
 * Evict the oldest `count` entries when the cap is reached.
48
 * @param {number} count
49
 */
50
function evictOldest(count = 1) {
6✔
51
  const iter = windowMap.keys();
6✔
52
  for (let i = 0; i < count; i++) {
6✔
53
    const next = iter.next();
6✔
54
    if (next.done) break;
6!
55
    windowMap.delete(next.value);
6✔
56
  }
57
}
58

59
/**
60
 * Send a temp-mute (timeout) to a repeat offender and alert the mod channel.
61
 * @param {import('discord.js').Message} message
62
 * @param {Object} config
63
 * @param {number} muteDurationMs
64
 */
65
async function handleRepeatOffender(message, config, muteDurationMs) {
66
  const member = message.member;
15✔
67
  if (!member) return;
15✔
68

69
  const rlConfig = config.moderation?.rateLimit ?? {};
13!
70
  const muteThreshold = rlConfig.muteAfterTriggers ?? 3;
15!
71
  const muteWindowSeconds = rlConfig.muteWindowSeconds ?? 300;
15!
72

73
  // Apply timeout — use PermissionFlagsBits constant, not a string
74
  if (!member.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
15✔
75
    warn('Rate limit: bot lacks MODERATE_MEMBERS permission', { guildId: message.guild.id });
2✔
76
    return;
2✔
77
  }
78
  try {
11✔
79
    await member.timeout(muteDurationMs, 'Rate limit: repeated violations');
11✔
80
    info('Rate limit temp-mute applied', {
9✔
81
      userId: message.author.id,
82
      guildId: message.guild.id,
83
      durationMs: muteDurationMs,
84
    });
85
  } catch (err) {
86
    warn('Rate limit: failed to apply timeout', { userId: message.author.id, error: err.message });
2✔
87
  }
88

89
  // Alert mod channel
90
  const alertChannelId = config.moderation?.alertChannelId;
11✔
91
  if (!alertChannelId) return;
15✔
92

93
  const alertChannel = await fetchChannelCached(message.client, alertChannelId);
8✔
94
  if (!alertChannel) return;
8✔
95

96
  const muteWindowMinutes = Math.round(muteWindowSeconds / 60);
6✔
97
  const reasonText =
98
    `Repeated rate limit violations ` +
6✔
99
    `(${muteThreshold} triggers in ${muteWindowMinutes} minute${muteWindowMinutes === 1 ? '' : 's'})`;
6✔
100

101
  const embed = new EmbedBuilder()
15✔
102
    .setColor(0xe67e22)
103
    .setTitle('⏱️ Rate Limit: Temp-Mute Applied')
104
    .addFields(
105
      {
106
        name: 'User',
107
        value: `<@${message.author.id}> (${sanitizeMentions(message.author.tag)})`,
108
        inline: true,
109
      },
110
      { name: 'Channel', value: `<#${message.channel.id}>`, inline: true },
111
      { name: 'Duration', value: `${Math.round(muteDurationMs / 60000)} minute(s)`, inline: true },
112
      { name: 'Reason', value: reasonText },
113
    )
114
    .setTimestamp();
115

116
  await safeSend(alertChannel, { embeds: [embed] }).catch(() => {});
15✔
117
}
118

119
/**
120
 * Send a rate-limit warning to the offending user in-channel.
121
 * Uses safeReply to enforce allowedMentions and sanitization.
122
 * @param {import('discord.js').Message} message
123
 * @param {number} maxMessages
124
 * @param {number} windowSeconds
125
 */
126
async function warnUser(message, maxMessages, windowSeconds) {
127
  const reply = await safeReply(
23✔
128
    message,
129
    `⚠️ <@${message.author.id}>, you're sending messages too fast! ` +
130
      `Limit: ${maxMessages} messages per ${windowSeconds} seconds.`,
UNCOV
131
  ).catch(() => null);
×
132

133
  // Auto-delete the warning after 10 seconds
134
  if (reply) {
23✔
135
    setTimeout(() => reply.delete().catch(() => {}), 10_000);
22✔
136
  }
137
}
138

139
/**
140
 * Check whether a message triggers the rate limit.
141
 * Side effects on trigger: deletes excess message, warns user, may temp-mute.
142
 *
143
 * @param {import('discord.js').Message} message - Discord message object
144
 * @param {Object} config - Bot config (merged guild config)
145
 * @returns {Promise<{ limited: boolean, reason?: string }>}
146
 */
147
export async function checkRateLimit(message, config) {
148
  const rlConfig = config.moderation?.rateLimit ?? {};
222✔
149

150
  if (!rlConfig.enabled) return { limited: false };
222✔
151
  if (isExempt(message, config)) return { limited: false };
185✔
152

153
  const maxMessages = rlConfig.maxMessages ?? 10;
144!
154
  const windowSeconds = rlConfig.windowSeconds ?? 10;
222!
155
  const windowMs = windowSeconds * 1000;
222✔
156

157
  // Temp-mute config
158
  const muteThreshold = rlConfig.muteAfterTriggers ?? 3;
222!
159
  const muteWindowSeconds = rlConfig.muteWindowSeconds ?? 300; // 5 minutes
222!
160
  const muteWindowMs = muteWindowSeconds * 1000;
222✔
161
  const muteDurationMs = (rlConfig.muteDurationSeconds ?? 300) * 1000; // 5 minutes
222!
162

163
  const key = `${message.author.id}:${message.channel.id}`;
222✔
164
  const now = Date.now();
222✔
165

166
  // Cap tracked users to avoid memory blowout
167
  if (!windowMap.has(key) && windowMap.size >= _maxTrackedUsers) {
222✔
168
    evictOldest(Math.ceil(_maxTrackedUsers * 0.1)); // evict 10%
6✔
169
  }
170

171
  let entry = windowMap.get(key);
144✔
172
  if (!entry) {
144✔
173
    entry = {
50✔
174
      timestamps: [],
175
      triggerCount: 0,
176
      triggerWindowStart: now,
177
      windowMs,
178
      muteWindowMs,
179
    };
180
    windowMap.set(key, entry);
50✔
181
  }
182

183
  // Keep the most recently-seen retention windows for cleanup safety.
184
  entry.windowMs = windowMs;
144✔
185
  entry.muteWindowMs = muteWindowMs;
144✔
186

187
  // Slide the window: drop timestamps older than windowMs
188
  const cutoff = now - windowMs;
144✔
189
  entry.timestamps = entry.timestamps.filter((t) => t >= cutoff);
186✔
190
  entry.timestamps.push(now);
144✔
191

192
  if (entry.timestamps.length <= maxMessages) {
144✔
193
    return { limited: false };
103✔
194
  }
195

196
  // --- Rate limited ---
197
  const reason = `Exceeded ${maxMessages} messages in ${windowSeconds}s`;
41✔
198
  warn('Rate limit triggered', {
41✔
199
    userId: message.author.id,
200
    channelId: message.channel.id,
201
    count: entry.timestamps.length,
202
    max: maxMessages,
203
  });
204

205
  // Delete the excess message
206
  await message.delete().catch(() => {});
41✔
207

208
  if (now - entry.triggerWindowStart > muteWindowMs) {
41✔
209
    // Reset trigger window
210
    entry.triggerCount = 1;
2✔
211
    entry.triggerWindowStart = now;
2✔
212
  } else {
213
    entry.triggerCount += 1;
39✔
214
  }
215

216
  if (entry.triggerCount >= muteThreshold) {
41✔
217
    // Reset counter so they don't get re-muted every single message
218
    entry.triggerCount = 0;
15✔
219
    entry.triggerWindowStart = now;
15✔
220

221
    await handleRepeatOffender(message, config, muteDurationMs);
15✔
222
    return { limited: true, reason: `${reason} (temp-muted: repeat offender)` };
15✔
223
  }
224

225
  // Warn the user on first trigger
226
  if (entry.triggerCount === 1) {
26✔
227
    await warnUser(message, maxMessages, windowSeconds);
23✔
228
  }
229

230
  return { limited: true, reason };
26✔
231
}
232

233
/** @type {ReturnType<typeof setInterval> | null} */
234
let cleanupInterval = null;
3✔
235

236
/**
237
 * Start periodic cleanup of stale windowMap entries.
238
 * Removes entries when the latest activity is older than the tracked retention window.
239
 * Runs every 5 minutes.
240
 */
241
function startRateLimitCleanup() {
242
  if (cleanupInterval) return;
3!
243
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
3✔
244
  const DEFAULT_WINDOW_MS = 10 * 1000; // fallback window
3✔
245

246
  cleanupInterval = setInterval(() => {
3✔
247
    const now = Date.now();
×
UNCOV
248
    for (const [key, entry] of windowMap) {
×
249
      const newestTimestamp =
250
        entry.timestamps.length > 0 ? entry.timestamps[entry.timestamps.length - 1] : 0;
×
251
      const newestActivity = Math.max(newestTimestamp, entry.triggerWindowStart ?? 0);
×
UNCOV
252
      const retentionMs = Math.max(
×
253
        entry.windowMs ?? DEFAULT_WINDOW_MS,
×
254
        entry.muteWindowMs ?? DEFAULT_WINDOW_MS,
×
255
      );
256

257
      if (now - newestActivity > retentionMs) {
×
UNCOV
258
        windowMap.delete(key);
×
259
      }
260
    }
261
  }, CLEANUP_INTERVAL_MS);
262
  cleanupInterval.unref?.();
3✔
263
}
264

265
// Auto-start cleanup when module loads
266
startRateLimitCleanup();
3✔
267

268
/**
269
 * Stop the periodic windowMap cleanup interval.
270
 * Call during graceful shutdown.
271
 */
272
export function stopRateLimitCleanup() {
273
  if (cleanupInterval) {
36✔
274
    clearInterval(cleanupInterval);
2✔
275
    cleanupInterval = null;
2✔
276
  }
277
}
278

279
/**
280
 * Clear all rate limit state. Primarily for testing.
281
 */
282
export function clearRateLimitState() {
283
  windowMap.clear();
56✔
284
  _maxTrackedUsers = 10_000;
56✔
285
}
286

287
/**
288
 * Return current tracked user count. For monitoring/tests.
289
 * @returns {number}
290
 */
291
export function getTrackedCount() {
292
  return windowMap.size;
9✔
293
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc