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

VolvoxLLC / volvox-bot / 23596933282

26 Mar 2026 01:29PM UTC coverage: 90.671% (+0.03%) from 90.643%
23596933282

push

github

web-flow
refactor: reduce cognitive complexity - bot batch 2 (#393)

* refactor: reduce cognitive complexity in ai, guilds, timeParser, grantRole

* style: fix Biome lint formatting

6549 of 7658 branches covered (85.52%)

Branch coverage included in aggregate %.

84 of 86 new or added lines in 4 files covered. (97.67%)

36 existing lines in 7 files now uncovered.

11150 of 11862 relevant lines covered (94.0%)

224.28 hits per line

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

87.6
/src/modules/moderation.js
1
/**
2
 * Moderation Module
3
 * Shared logic for case management, DM notifications, mod log posting,
4
 * auto-escalation, and tempban scheduling.
5
 */
6

7
import { EmbedBuilder } from 'discord.js';
8
import { getPool } from '../db.js';
9
import { info, error as logError, warn as logWarn } from '../logger.js';
10
import { fetchChannelCached } from '../utils/discordCache.js';
11
import { parseDuration } from '../utils/duration.js';
12
import { mergeRoleIds } from '../utils/permissions.js';
13
import { safeSend } from '../utils/safeSend.js';
14
import { getConfig } from './config.js';
15
import { fireEvent } from './webhookNotifier.js';
16

17
/**
18
 * Color map for mod log embeds by action type.
19
 * @type {Record<string, number>}
20
 */
21
export const ACTION_COLORS = {
12✔
22
  warn: 0xfee75c,
23
  kick: 0xed4245,
24
  timeout: 0xe67e22,
25
  untimeout: 0x57f287,
26
  ban: 0xed4245,
27
  tempban: 0xed4245,
28
  unban: 0x57f287,
29
  softban: 0xed4245,
30
  purge: 0x5865f2,
31
  lock: 0xe67e22,
32
  unlock: 0x57f287,
33
  slowmode: 0x5865f2,
34
};
35

36
/**
37
 * Past-tense label for DM notifications by action type.
38
 * @type {Record<string, string>}
39
 */
40
const ACTION_PAST_TENSE = {
12✔
41
  warn: 'warned',
42
  kick: 'kicked',
43
  timeout: 'timed out',
44
  untimeout: 'had their timeout removed',
45
  ban: 'banned',
46
  tempban: 'temporarily banned',
47
  unban: 'unbanned',
48
  softban: 'soft-banned',
49
};
50

51
/**
52
 * Channel config key for each action type (maps to moderation.logging.channels.*).
53
 * @type {Record<string, string>}
54
 */
55
export const ACTION_LOG_CHANNEL_KEY = {
12✔
56
  warn: 'warns',
57
  kick: 'kicks',
58
  timeout: 'timeouts',
59
  untimeout: 'timeouts',
60
  ban: 'bans',
61
  tempban: 'bans',
62
  unban: 'bans',
63
  softban: 'bans',
64
  purge: 'purges',
65
  lock: 'locks',
66
  unlock: 'locks',
67
  slowmode: 'locks',
68
};
69

70
/** @type {ReturnType<typeof setInterval> | null} */
71
let schedulerInterval = null;
12✔
72

73
/** @type {boolean} */
74
let schedulerPollInFlight = false;
12✔
75

76
/**
77
 * Create a moderation case in the database.
78
 * Uses a per-guild advisory lock to atomically assign sequential case numbers.
79
 * @param {string} guildId - Discord guild ID
80
 * @param {Object} data - Case data
81
 * @param {string} data.action - Action type (warn, kick, ban, etc.)
82
 * @param {string} data.targetId - Target user ID
83
 * @param {string} data.targetTag - Target user tag
84
 * @param {string} data.moderatorId - Moderator user ID
85
 * @param {string} data.moderatorTag - Moderator user tag
86
 * @param {string} [data.reason] - Reason for action
87
 * @param {string} [data.duration] - Duration string (for timeout/tempban)
88
 * @param {Date} [data.expiresAt] - Expiration timestamp
89
 * @returns {Promise<Object>} Created case row
90
 */
91
export async function createCase(guildId, data) {
92
  const pool = getPool();
8✔
93
  const client = await pool.connect();
8✔
94

95
  try {
7✔
96
    await client.query('BEGIN');
7✔
97

98
    // Serialize case-number generation per guild to prevent race conditions.
99
    await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [guildId]);
7✔
100

101
    const { rows } = await client.query(
6✔
102
      `INSERT INTO mod_cases
103
        (
104
          guild_id,
105
          case_number,
106
          action,
107
          target_id,
108
          target_tag,
109
          moderator_id,
110
          moderator_tag,
111
          reason,
112
          duration,
113
          expires_at
114
        )
115
      VALUES (
116
        $1,
117
        COALESCE((SELECT MAX(case_number) FROM mod_cases WHERE guild_id = $1), 0) + 1,
118
        $2,
119
        $3,
120
        $4,
121
        $5,
122
        $6,
123
        $7,
124
        $8,
125
        $9
126
      )
127
      RETURNING *`,
128
      [
129
        guildId,
130
        data.action,
131
        data.targetId,
132
        data.targetTag,
133
        data.moderatorId,
134
        data.moderatorTag,
135
        data.reason || null,
7✔
136
        data.duration || null,
13✔
137
        data.expiresAt || null,
14✔
138
      ],
139
    );
140

141
    await client.query('COMMIT');
4✔
142

143
    const createdCase = rows[0];
3✔
144
    info('Moderation case created', {
3✔
145
      guildId,
146
      caseNumber: createdCase.case_number,
147
      action: data.action,
148
      target: data.targetTag,
149
      moderator: data.moderatorTag,
150
    });
151

152
    // Fire webhook notification — fire-and-forget, don't block case creation
153
    fireEvent('moderation.action', guildId, {
3✔
154
      action: data.action,
155
      caseNumber: createdCase.case_number,
156
      targetId: data.targetId,
157
      targetTag: data.targetTag,
158
      moderatorId: data.moderatorId,
159
      moderatorTag: data.moderatorTag,
160
      reason: data.reason || null,
3!
161
    }).catch(() => {});
162

163
    return createdCase;
8✔
164
  } catch (err) {
165
    await client.query('ROLLBACK').catch(() => {});
4✔
166
    throw err;
3✔
167
  } finally {
168
    client.release();
7✔
169
  }
170
}
171

172
/**
173
 * Schedule a moderation action for future execution.
174
 * @param {string} guildId - Discord guild ID
175
 * @param {string} action - Action type (e.g. unban)
176
 * @param {string} targetId - Target user ID
177
 * @param {number|null} caseId - Related case ID (if any)
178
 * @param {Date} executeAt - When to execute the action
179
 * @returns {Promise<Object>} Created scheduled action row
180
 */
181
export async function scheduleAction(guildId, action, targetId, caseId, executeAt) {
182
  const pool = getPool();
2✔
183
  const { rows } = await pool.query(
2✔
184
    `INSERT INTO mod_scheduled_actions
185
      (guild_id, action, target_id, case_id, execute_at)
186
    VALUES ($1, $2, $3, $4, $5)
187
    RETURNING *`,
188
    [guildId, action, targetId, caseId || null, executeAt],
3✔
189
  );
190

191
  return rows[0];
2✔
192
}
193

194
/**
195
 * Send a DM notification to a member before a moderation action.
196
 * Silently fails if the user has DMs disabled.
197
 * @param {import('discord.js').GuildMember} member - Target member
198
 * @param {string} action - Action type
199
 * @param {string|null} reason - Reason for the action
200
 * @param {string} guildName - Server name
201
 */
202
export async function sendDmNotification(member, action, reason, guildName) {
203
  const pastTense = ACTION_PAST_TENSE[action] || action;
4✔
204
  const embed = new EmbedBuilder()
4✔
205
    .setColor(ACTION_COLORS[action] || 0x5865f2)
5✔
206
    .setTitle(`You have been ${pastTense} in ${guildName}`)
207
    .addFields({ name: 'Reason', value: reason || 'No reason provided' })
5✔
208
    .setTimestamp();
209

210
  try {
4✔
211
    await member.send({ embeds: [embed] });
4✔
212
  } catch {
213
    // User has DMs disabled — silently continue
214
  }
215
}
216

217
/**
218
 * Post a moderation log embed for a case to the configured logging channel.
219
 *
220
 * Attempts to send an embed describing the case to the channel determined by
221
 * the moderation logging configuration. On successful send it records the
222
 * sent message's ID on the case row (logging any storage failures) and returns
223
 * the sent message; if sending or channel resolution fails, returns `null`.
224
 *
225
 * @param {import('discord.js').Client} client - Discord client instance used to resolve channels.
226
 * @param {Object} config - Bot configuration object containing moderation.logging.channels.
227
 * @param {Object} caseData - Case object returned by createCase(), including at least `id`, `case_number`, `action`, `target_id`, `target_tag`, `moderator_id`, `moderator_tag`, and optional `reason`, `duration`, `created_at`.
228
 * @returns {import('discord.js').Message|null} The sent log message if delivered, `null` if no message was sent.
229
 */
230
export async function sendModLogEmbed(client, config, caseData) {
231
  const channels = config.moderation?.logging?.channels;
10✔
232
  if (!channels) return null;
10✔
233

234
  const actionKey = ACTION_LOG_CHANNEL_KEY[caseData.action];
9✔
235
  const channelId = channels[actionKey] || channels.default;
9✔
236
  if (!channelId) return null;
10✔
237

238
  const channel = await fetchChannelCached(client, channelId, caseData.guild_id);
8✔
239
  if (!channel) return null;
8✔
240

241
  const embed = new EmbedBuilder()
7✔
242
    .setColor(ACTION_COLORS[caseData.action] || 0x5865f2)
7!
243
    .setTitle(`Case #${caseData.case_number} — ${caseData.action.toUpperCase()}`)
244
    .addFields(
245
      { name: 'Target', value: `<@${caseData.target_id}> (${caseData.target_tag})`, inline: true },
246
      {
247
        name: 'Moderator',
248
        value: `<@${caseData.moderator_id}> (${caseData.moderator_tag})`,
249
        inline: true,
250
      },
251
      { name: 'Reason', value: caseData.reason || 'No reason provided' },
10!
252
    )
253
    .setTimestamp(caseData.created_at ? new Date(caseData.created_at) : new Date())
7✔
254
    .setFooter({ text: `Case #${caseData.case_number}` });
255

256
  if (caseData.duration) {
10✔
257
    embed.addFields({ name: 'Duration', value: caseData.duration, inline: true });
2✔
258
  }
259

260
  try {
7✔
261
    const sentMessage = await safeSend(channel, { embeds: [embed] });
7✔
262

263
    // Store log message ID for future editing
264
    try {
6✔
265
      const pool = getPool();
6✔
266
      await pool.query('UPDATE mod_cases SET log_message_id = $1 WHERE id = $2', [
6✔
267
        sentMessage.id,
268
        caseData.id,
269
      ]);
270
    } catch (err) {
271
      logError('Failed to store log message ID', {
1✔
272
        caseId: caseData.id,
273
        messageId: sentMessage.id,
274
        error: err.message,
275
      });
276
    }
277

278
    return sentMessage;
6✔
279
  } catch (err) {
280
    logWarn('Failed to send mod log embed', { error: err.message, channelId });
1✔
281
    return null;
1✔
282
  }
283
}
284

285
/**
286
 * Evaluate configured escalation thresholds for a guild target and apply the first matching escalation.
287
 *
288
 * If a threshold is met, performs the configured action (e.g., timeout or ban), creates a moderation case, and posts the mod-log for the escalation.
289
 *
290
 * @param {import('discord.js').Client} client - Discord client instance.
291
 * @param {string} guildId - ID of the guild where escalation is evaluated.
292
 * @param {string} targetId - ID of the target user being evaluated.
293
 * @param {string} moderatorId - ID used as the moderator for the escalation case (typically the bot).
294
 * @param {string} moderatorTag - Tag to record for the moderator in the created case.
295
 * @param {Object} config - Bot configuration containing moderation.escalation settings and thresholds.
296
 * @returns {Object|null} The created escalation case object when an escalation is applied, `null` if no thresholds triggered or on failure.
297
 */
298
/**
299
 * Count active warnings for a user within a threshold window.
300
 * Falls back to mod_cases if the warnings table doesn't exist yet.
301
 */
302
async function countActiveWarnings(pool, guildId, targetId, withinDays) {
303
  try {
5✔
304
    const { rows } = await pool.query(
5✔
305
      `SELECT COUNT(*)::integer AS count FROM warnings
306
       WHERE guild_id = $1 AND user_id = $2 AND active = TRUE
307
       AND (expires_at IS NULL OR expires_at > NOW())
308
       AND created_at > NOW() - INTERVAL '1 day' * $3`,
309
      [guildId, targetId, withinDays],
310
    );
311
    return rows[0]?.count || 0;
3!
312
  } catch (err) {
313
    if (err.code === '42P01') {
2✔
314
      // 42P01 = undefined_table — fall back to mod_cases
315
      const { rows } = await pool.query(
1✔
316
        `SELECT COUNT(*)::integer AS count FROM mod_cases
317
         WHERE guild_id = $1 AND target_id = $2 AND action = 'warn'
318
         AND created_at > NOW() - INTERVAL '1 day' * $3`,
319
        [guildId, targetId, withinDays],
320
      );
321
      return rows[0]?.count || 0;
1✔
322
    }
323
    logError('Failed to count active warnings for escalation', {
1✔
324
      error: err.message,
325
      guildId,
326
      targetId,
327
    });
328
    throw err;
1✔
329
  }
330
}
331

332
/**
333
 * Execute a single escalation action (timeout or ban) and create the mod case.
334
 */
335
async function executeEscalationAction(
336
  client,
337
  config,
338
  guildId,
339
  targetId,
340
  moderatorId,
341
  moderatorTag,
342
  threshold,
343
  reason,
344
) {
345
  const guild = await client.guilds.fetch(guildId);
2✔
346
  const member = await guild.members.fetch(targetId).catch(() => null);
2✔
347

348
  if (threshold.action === 'timeout' && member) {
2✔
349
    const ms = parseDuration(threshold.duration);
1✔
350
    if (ms) {
1!
351
      await member.timeout(ms, reason);
1✔
352
    }
353
  } else if (threshold.action === 'ban') {
1!
354
    await guild.members.ban(targetId, { reason });
1✔
355
  }
356

357
  const escalationCase = await createCase(guildId, {
2✔
358
    action: threshold.action,
359
    targetId,
360
    targetTag: member?.user?.tag || targetId,
2!
361
    moderatorId,
362
    moderatorTag,
363
    reason,
364
    duration: threshold.duration || null,
3✔
365
  });
366

367
  await sendModLogEmbed(client, config, escalationCase);
2✔
368
  return escalationCase;
2✔
369
}
370

371
export async function checkEscalation(
372
  client,
373
  guildId,
374
  targetId,
375
  moderatorId,
376
  moderatorTag,
377
  config,
378
) {
379
  if (!config.moderation?.escalation?.enabled) return null;
8✔
380

381
  const thresholds = config.moderation.escalation.thresholds;
6✔
382
  if (!thresholds?.length) return null;
6✔
383

384
  const pool = getPool();
5✔
385

386
  for (const threshold of thresholds) {
5✔
387
    const warnCount = await countActiveWarnings(pool, guildId, targetId, threshold.withinDays);
5✔
388
    if (warnCount < threshold.warns) continue;
4✔
389

390
    const reason = `Auto-escalation: ${warnCount} active warns in ${threshold.withinDays} days`;
2✔
391
    info('Escalation triggered', { guildId, targetId, warnCount, threshold });
2✔
392

393
    try {
2✔
394
      return await executeEscalationAction(
2✔
395
        client,
396
        config,
397
        guildId,
398
        targetId,
399
        moderatorId,
400
        moderatorTag,
401
        threshold,
402
        reason,
403
      );
404
    } catch (err) {
UNCOV
405
      logError('Escalation action failed', { error: err.message, guildId, targetId, threshold });
×
UNCOV
406
      return null;
×
407
    }
408
  }
409

410
  return null;
2✔
411
}
412

413
/**
414
 * Poll for expired tempbans and execute unbans.
415
 * @param {import('discord.js').Client} client - Discord client
416
 */
417
async function pollTempbans(client) {
418
  if (schedulerPollInFlight) {
5!
UNCOV
419
    return;
×
420
  }
421

422
  schedulerPollInFlight = true;
5✔
423

424
  try {
5✔
425
    const pool = getPool();
5✔
426
    const { rows } = await pool.query(
5✔
427
      `SELECT * FROM mod_scheduled_actions
428
       WHERE executed = FALSE AND execute_at <= NOW()
429
       ORDER BY execute_at ASC
430
       LIMIT 50`,
431
    );
432

433
    for (const row of rows) {
4✔
434
      // Use a transaction to ensure atomicity:
435
      // 1. Lock the row with FOR UPDATE SKIP LOCKED
436
      // 2. Execute Discord unban
437
      // 3. Only mark executed after successful unban
438
      const txClient = await pool.connect();
3✔
439
      try {
3✔
440
        await txClient.query('BEGIN');
3✔
441

442
        // Lock the row - skip if already executed by another poll
443
        const { rows: lockRows } = await txClient.query(
3✔
444
          'SELECT id FROM mod_scheduled_actions WHERE id = $1 AND executed = FALSE FOR UPDATE SKIP LOCKED',
445
          [row.id],
446
        );
447
        if (lockRows.length === 0) {
3!
UNCOV
448
          await txClient.query('ROLLBACK');
×
UNCOV
449
          continue; // Already handled by another poll
×
450
        }
451

452
        // Execute the Discord unban FIRST (before marking executed)
453
        // Track any error for logging, but don't throw - we still mark as executed
454
        // to prevent infinite retry on non-recoverable errors
455
        let unbanError = null;
2✔
456
        const guild = await client.guilds.fetch(row.guild_id);
2✔
457
        try {
2✔
458
          await guild.members.unban(row.target_id, 'Tempban expired');
2✔
459
        } catch (err) {
460
          unbanError = err;
1✔
461
          // Unknown Ban (code 10026) means already unbanned - not really an error
462
          const isAlreadyUnbanned = err?.code === 10026 || /Unknown Ban/i.test(err?.message || '');
1!
463
          if (isAlreadyUnbanned) {
1!
UNCOV
464
            info('Tempban target already unbanned; finalizing scheduled action', {
×
465
              id: row.id,
466
              guildId: row.guild_id,
467
              targetId: row.target_id,
468
            });
UNCOV
469
            unbanError = null; // Clear error - this is success
×
470
          }
471
        }
472

473
        // Mark executed regardless of unban outcome to prevent infinite retry
474
        await txClient.query('UPDATE mod_scheduled_actions SET executed = TRUE WHERE id = $1', [
2✔
475
          row.id,
476
        ]);
477
        await txClient.query('COMMIT');
2✔
478

479
        // Log unban failure AFTER successful commit (if there was a real error)
480
        if (unbanError) {
2✔
481
          logError('Failed to unban tempban target (marked as executed to prevent retry)', {
1✔
482
            error: unbanError.message,
483
            id: row.id,
484
            guildId: row.guild_id,
485
            targetId: row.target_id,
486
          });
487
        }
488
      } catch (err) {
489
        // Only reach here on transaction/DB errors (not unban errors)
490
        await txClient.query('ROLLBACK').catch(() => {});
1✔
UNCOV
491
        logError('Failed to process expired tempban', {
×
492
          error: err.message,
493
          id: row.id,
494
          guildId: row.guild_id,
495
          targetId: row.target_id,
496
        });
497
        // Action remains unexecuted (executed = FALSE) and will be retried on next poll
498
        txClient.release();
×
UNCOV
499
        continue; // Skip post-commit work since transaction failed
×
500
      }
501

502
      // Transaction succeeded - release client before post-commit work
503
      txClient.release();
2✔
504

505
      // Post-commit work (outside transaction): create case, send mod-log
506
      // These are non-critical - failures here don't affect the unban itself
507
      try {
2✔
508
        const targetUser = await client.users.fetch(row.target_id).catch(() => null);
2✔
509

510
        // Create unban case
511
        const config = getConfig(row.guild_id);
1✔
512
        const unbanCase = await createCase(row.guild_id, {
1✔
513
          action: 'unban',
514
          targetId: row.target_id,
515
          targetTag: targetUser?.tag || row.target_id,
1!
516
          moderatorId: client.user?.id || 'system',
3!
517
          moderatorTag: client.user?.tag || 'System',
3!
518
          reason: `Tempban expired (case #${row.case_id ? row.case_id : 'unknown'})`,
1!
519
        });
520

UNCOV
521
        await sendModLogEmbed(client, config, unbanCase);
×
522

UNCOV
523
        info('Tempban expired, user unbanned', {
×
524
          guildId: row.guild_id,
525
          targetId: row.target_id,
526
        });
527
      } catch (err) {
528
        // Log but don't retry - the unban itself succeeded, just the logging failed
529
        logError('Post-commit work failed for tempban (unban already executed)', {
2✔
530
          error: err.message,
531
          id: row.id,
532
          guildId: row.guild_id,
533
          targetId: row.target_id,
534
        });
535
      }
536
    }
537
  } catch (err) {
538
    logError('Tempban scheduler poll error', { error: err.message });
2✔
539
  } finally {
540
    schedulerPollInFlight = false;
5✔
541
  }
542
}
543

544
/**
545
 * Start the tempban scheduler polling interval.
546
 * Polls every 60 seconds for expired tempbans.
547
 * Runs an immediate check on startup to catch missed unbans.
548
 * @param {import('discord.js').Client} client - Discord client
549
 */
550
export function startTempbanScheduler(client) {
551
  if (schedulerInterval) return;
6✔
552

553
  // Immediate check on startup
554
  pollTempbans(client).catch((err) => {
5✔
UNCOV
555
    logError('Initial tempban poll failed', { error: err.message });
×
556
  });
557

558
  schedulerInterval = setInterval(() => {
5✔
UNCOV
559
    pollTempbans(client).catch((err) => {
×
UNCOV
560
      logError('Tempban poll failed', { error: err.message });
×
561
    });
562
  }, 60000);
563

564
  info('Tempban scheduler started');
5✔
565
}
566

567
/**
568
 * Stop the tempban scheduler.
569
 */
570
export function stopTempbanScheduler() {
571
  if (schedulerInterval) {
57✔
572
    clearInterval(schedulerInterval);
5✔
573
    schedulerInterval = null;
5✔
574
    info('Tempban scheduler stopped');
5✔
575
  }
576
}
577

578
/**
579
 * Determine whether a guild member is protected from moderation actions.
580
 * Protection is driven by the guild's live moderation.protectRoles settings (server owner, admin/moderator roles, and explicit role IDs).
581
 * @param {import('discord.js').GuildMember} target - Member to evaluate.
582
 * @param {import('discord.js').Guild} guild - Guild containing the member.
583
 * @returns {boolean} `true` if the member is protected from moderation actions, `false` otherwise.
584
 */
585
export function isProtectedTarget(target, guild) {
586
  // Fetch config per-invocation so live config edits take effect immediately.
587
  const config = getConfig(guild.id);
10✔
588
  /**
589
   * When the protectRoles block is missing from persisted configuration,
590
   * fall back to the intended defaults: protection enabled, include owner,
591
   * admins, and moderators (matches config.json defaults and web UI defaults).
592
   */
593
  const defaultProtectRoles = {
10✔
594
    enabled: true,
595
    includeAdmins: true,
596
    includeModerators: true,
597
    includeServerOwner: true,
598
    roleIds: [],
599
  };
600

601
  // Deep-merge defaults so a partial persisted object (e.g. only roleIds set)
602
  // never leaves enabled/include* as undefined/falsy.
603
  const protectRoles = { ...defaultProtectRoles, ...config.moderation?.protectRoles };
10✔
604
  if (!protectRoles.enabled) {
10✔
605
    return false;
1✔
606
  }
607

608
  // Server owner is always protected when enabled
609
  if (protectRoles.includeServerOwner && target.id === guild.ownerId) {
9✔
610
    return true;
1✔
611
  }
612

613
  // Resolve admin/moderator role ID arrays — mergeRoleIds handles the case where
614
  // defaults inject adminRoleIds:[] alongside a legacy adminRoleId guild override
615
  const adminRoleIds = mergeRoleIds(
8✔
616
    config.permissions?.adminRoleIds,
617
    config.permissions?.adminRoleId,
618
  );
619
  const moderatorRoleIds = mergeRoleIds(
10✔
620
    config.permissions?.moderatorRoleIds,
621
    config.permissions?.moderatorRoleId,
622
  );
623

624
  const protectedRoleIds = [
10✔
625
    ...(protectRoles.includeAdmins ? adminRoleIds : []),
8✔
626
    ...(protectRoles.includeModerators ? moderatorRoleIds : []),
8✔
627
    ...(Array.isArray(protectRoles.roleIds) ? protectRoles.roleIds : []),
8!
628
  ].filter(Boolean);
629

630
  if (protectedRoleIds.length === 0) return false;
10✔
631

632
  const memberRoleIds = [...target.roles.cache.keys()];
4✔
633
  return protectedRoleIds.some((roleId) => memberRoleIds.includes(roleId));
5✔
634
}
635

636
/**
637
 * Check if the moderator (and optionally the bot) can moderate a target member.
638
 * @param {import('discord.js').GuildMember} moderator - The moderator
639
 * @param {import('discord.js').GuildMember} target - The target member
640
 * @param {import('discord.js').GuildMember|null} [botMember=null] - The bot's own guild member
641
 * @returns {string|null} Error message if cannot moderate, null if OK
642
 */
643
export function checkHierarchy(moderator, target, botMember = null) {
6✔
644
  if (target.roles.highest.position >= moderator.roles.highest.position) {
6✔
645
    return '❌ You cannot moderate a member with an equal or higher role than yours.';
2✔
646
  }
647
  if (botMember && target.roles.highest.position >= botMember.roles.highest.position) {
4✔
648
    return '❌ I cannot moderate this member — my role is not high enough.';
1✔
649
  }
650
  return null;
3✔
651
}
652

653
/**
654
 * Check if DM notification is enabled for an action type.
655
 * @param {Object} config - Bot configuration
656
 * @param {string} action - Action type
657
 * @returns {boolean} True if DM should be sent
658
 */
659
export function shouldSendDm(config, action) {
660
  return config.moderation?.dmNotifications?.[action] === true;
3✔
661
}
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