Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@
},
"permissions": {
"enabled": true,
"adminRoleId": null,
"moderatorRoleId": null,
"adminRoleIds": [],
"moderatorRoleIds": [],
"botOwners": [],
"usePermissions": true,
"allowedCommands": {
Expand Down
12 changes: 12 additions & 0 deletions src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ export const CONFIG_SCHEMA = {
logChannel: { type: 'string', nullable: true },
},
},
permissions: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
usePermissions: { type: 'boolean' },
adminRoleIds: { type: 'array', items: { type: 'string' } },
moderatorRoleIds: { type: 'array', items: { type: 'string' } },
modRoles: { type: 'array', items: { type: 'string' } },
botOwners: { type: 'array', items: { type: 'string' } },
allowedCommands: { type: 'object' },
},
},
};

/**
Expand Down
31 changes: 19 additions & 12 deletions src/modules/moderation.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getPool } from '../db.js';
import { info, error as logError, warn as logWarn } from '../logger.js';
import { fetchChannelCached } from '../utils/discordCache.js';
import { parseDuration } from '../utils/duration.js';
import { mergeRoleIds } from '../utils/permissions.js';
import { safeSend } from '../utils/safeSend.js';
import { getConfig } from './config.js';
import { fireEvent } from './webhookNotifier.js';
Expand Down Expand Up @@ -550,12 +551,11 @@ export function stopTempbanScheduler() {
}

/**
* Check if a target member is protected from moderation actions.
* Protected members include the server owner, admins, moderators, and any custom role IDs
* configured under `moderation.protectRoles`.
* @param {import('discord.js').GuildMember} target - Target member to check
* @param {import('discord.js').Guild} guild - Discord guild
* @returns {boolean} True if the target should not be moderated
* Determine whether a guild member is protected from moderation actions.
* Protection is driven by the guild's live moderation.protectRoles settings (server owner, admin/moderator roles, and explicit role IDs).
* @param {import('discord.js').GuildMember} target - Member to evaluate.
* @param {import('discord.js').Guild} guild - Guild containing the member.
* @returns {boolean} `true` if the member is protected from moderation actions, `false` otherwise.
*/
export function isProtectedTarget(target, guild) {
// Fetch config per-invocation so live config edits take effect immediately.
Expand Down Expand Up @@ -585,13 +585,20 @@ export function isProtectedTarget(target, guild) {
return true;
}

// Resolve admin/moderator role ID arrays — mergeRoleIds handles the case where
// defaults inject adminRoleIds:[] alongside a legacy adminRoleId guild override
const adminRoleIds = mergeRoleIds(
config.permissions?.adminRoleIds,
config.permissions?.adminRoleId,
);
const moderatorRoleIds = mergeRoleIds(
config.permissions?.moderatorRoleIds,
config.permissions?.moderatorRoleId,
);

const protectedRoleIds = [
...(protectRoles.includeAdmins && config.permissions?.adminRoleId
? [config.permissions.adminRoleId]
: []),
...(protectRoles.includeModerators && config.permissions?.moderatorRoleId
? [config.permissions.moderatorRoleId]
: []),
...(protectRoles.includeAdmins ? adminRoleIds : []),
...(protectRoles.includeModerators ? moderatorRoleIds : []),
...(Array.isArray(protectRoles.roleIds) ? protectRoles.roleIds : []),
].filter(Boolean);

Expand Down
2 changes: 1 addition & 1 deletion src/utils/dbMaintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
*/

import { info, error as logError, warn } from '../logger.js';
import { getConfig } from '../modules/config.js';
import { purgeOldAuditLogs } from '../modules/auditLogger.js';
import { getConfig } from '../modules/config.js';

/** Track optional tables we've already warned about to avoid hourly log spam */
const warnedMissingOptionalTables = new Set();
Expand Down
27 changes: 20 additions & 7 deletions src/utils/modExempt.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
*/

import { PermissionFlagsBits } from 'discord.js';
import { mergeRoleIds } from './permissions.js';

/**
* Check whether a message author has mod/admin permissions and should be
* exempted from automated moderation actions.
*
* Exempt if the member:
* - has the ADMINISTRATOR Discord permission, OR
* - holds the role at `config.permissions.adminRoleId` (singular ID), OR
* - holds the role at `config.permissions.moderatorRoleId` (singular ID), OR
* - holds any role in `config.permissions.adminRoleIds` (array), OR
* - holds any role in `config.permissions.moderatorRoleIds` (array), OR
* - holds any role ID or name listed in `config.permissions.modRoles` (array)
*
* Backward compat: also checks singular `adminRoleId` / `moderatorRoleId` fields
* so old configs continue to work without migration.
*
* @param {import('discord.js').Message} message
* @param {Object} config - Merged guild config
* @returns {boolean}
Expand All @@ -27,11 +31,20 @@ export function isExempt(message, config) {
// ADMINISTRATOR permission bypasses everything
if (member.permissions.has(PermissionFlagsBits.Administrator)) return true;

// Singular role IDs — the actual config schema (permissions.adminRoleId / moderatorRoleId)
const adminRoleId = config.permissions?.adminRoleId;
const moderatorRoleId = config.permissions?.moderatorRoleId;
if (adminRoleId && member.roles.cache.has(adminRoleId)) return true;
if (moderatorRoleId && member.roles.cache.has(moderatorRoleId)) return true;
// Array role IDs — new schema (permissions.adminRoleIds / moderatorRoleIds)
// Use mergeRoleIds to handle configs that have both the new empty-array default
// AND the old singular field set from a legacy guild override.
const adminRoleIds = mergeRoleIds(
config.permissions?.adminRoleIds,
config.permissions?.adminRoleId,
);
if (adminRoleIds.some((id) => member.roles.cache.has(id))) return true;

const moderatorRoleIds = mergeRoleIds(
config.permissions?.moderatorRoleIds,
config.permissions?.moderatorRoleId,
);
if (moderatorRoleIds.some((id) => member.roles.cache.has(id))) return true;

// Legacy / test-facing array of role IDs or names (permissions.modRoles)
const modRoles = config.permissions?.modRoles ?? [];
Expand Down
67 changes: 49 additions & 18 deletions src/utils/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@

import { PermissionFlagsBits } from 'discord.js';

/**
* Merge the new plural role IDs array with the legacy singular field.
*
* After defaults are merged, old guild configs will have BOTH `roleIds: []`
* (from defaults) AND `roleId: 'abc'` (from their stored override). Using `??`
* alone misses this case because the empty array is truthy. We always combine
* both so no configured role is ever silently dropped.
*
* @param {string[]} [roleIds=[]] - New plural field (may be empty from defaults)
* @param {string|null} [roleId=null] - Legacy singular field
* @returns {string[]} Deduplicated merged list
*/
export function mergeRoleIds(roleIds, roleId) {
const merged = [...(roleIds ?? [])];
if (roleId && !merged.includes(roleId)) merged.push(roleId);
return merged;
}

/**
* Retrieve the configured bot owner user IDs.
*
Expand Down Expand Up @@ -43,11 +61,11 @@ export function isBotOwner(member, config) {
}

/**
* Check if a member is an admin
* Determine whether a guild member has administrative privileges.
*
* @param {GuildMember} member - Discord guild member
* @param {Object} config - Bot configuration
* @returns {boolean} True if member is admin
* @param {GuildMember} member - The guild member to check.
* @param {Object} config - Bot configuration containing permission role IDs.
* @returns {boolean} `true` if the member is an admin, `false` otherwise.
*/
export function isAdmin(member, config) {
if (!member) return false;
Expand All @@ -62,9 +80,14 @@ export function isAdmin(member, config) {
return true;
}

// Check if member has the configured admin role
if (config.permissions?.adminRoleId) {
return member.roles.cache.has(config.permissions.adminRoleId);
// Check if member has any of the configured admin roles
// mergeRoleIds handles the case where defaults inject adminRoleIds:[] alongside a legacy adminRoleId value
const adminRoleIds = mergeRoleIds(
config.permissions?.adminRoleIds,
config.permissions?.adminRoleId,
);
if (adminRoleIds.length > 0) {
return adminRoleIds.some((id) => member.roles.cache.has(id));
}

return false;
Expand Down Expand Up @@ -134,11 +157,12 @@ export function isGuildAdmin(member, config) {
}

/**
* Check if a member is a moderator (has MANAGE_GUILD permission or bot admin role)
* Determine whether a guild member is considered a moderator.
*
* @param {GuildMember} member - Discord guild member
* @param {Object} config - Bot configuration
* @returns {boolean} True if member is a moderator
* Considers bot owners, members with the Administrator or Manage Guild permission, and members with any configured admin or moderator role IDs (supports legacy singular role ID fields).
* @param {GuildMember} member - Discord guild member to check.
* @param {Object} config - Bot configuration containing permission role settings (e.g., permissions.adminRoleIds, permissions.moderatorRoleIds or legacy adminRoleId/moderatorRoleId).
* @returns {boolean} `true` if the member is a moderator, `false` otherwise.
*/
export function isModerator(member, config) {
if (!member) return false;
Expand All @@ -158,15 +182,22 @@ export function isModerator(member, config) {
return true;
}

// Check bot admin role from config
if (config.permissions?.adminRoleId) {
if (member.roles.cache.has(config.permissions.adminRoleId)) {
return true;
}
// Check bot admin roles from config
const adminRoleIds = mergeRoleIds(
config.permissions?.adminRoleIds,
config.permissions?.adminRoleId,
);
if (adminRoleIds.some((id) => member.roles.cache.has(id))) {
return true;
}

if (config.permissions?.moderatorRoleId) {
return member.roles.cache.has(config.permissions.moderatorRoleId);
// Check bot moderator roles from config
const moderatorRoleIds = mergeRoleIds(
config.permissions?.moderatorRoleIds,
config.permissions?.moderatorRoleId,
);
if (moderatorRoleIds.some((id) => member.roles.cache.has(id))) {
return true;
}

return false;
Expand Down
52 changes: 44 additions & 8 deletions tests/utils/modExempt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,66 @@ describe('isExempt', () => {
expect(isExempt(msg, {})).toBe(false);
});

it('should return true when member has adminRoleId', () => {
it('should return true when member has a role in adminRoleIds array', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['admin-role-id'] });
const config = { permissions: { adminRoleId: 'admin-role-id' } };
const config = { permissions: { adminRoleIds: ['admin-role-id'] } };
expect(isExempt(msg, config)).toBe(true);
});

it('should return true when member has any of multiple adminRoleIds', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['admin-role-2'] });
const config = { permissions: { adminRoleIds: ['admin-role-1', 'admin-role-2'] } };
expect(isExempt(msg, config)).toBe(true);
});

it('should return false when adminRoleId is set but member does not have it', () => {
it('should return false when adminRoleIds is set but member does not have any', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['other-role'] });
const config = { permissions: { adminRoleId: 'admin-role-id' } };
const config = { permissions: { adminRoleIds: ['admin-role-id'] } };
expect(isExempt(msg, config)).toBe(false);
});

it('should return true when member has moderatorRoleId', () => {
it('should return true when member has a role in moderatorRoleIds array', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['mod-role-id'] });
const config = { permissions: { moderatorRoleId: 'mod-role-id' } };
const config = { permissions: { moderatorRoleIds: ['mod-role-id'] } };
expect(isExempt(msg, config)).toBe(true);
});

it('should return false when moderatorRoleId is set but member does not have it', () => {
it('should return true when member has any of multiple moderatorRoleIds', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['mod-role-2'] });
const config = { permissions: { moderatorRoleIds: ['mod-role-1', 'mod-role-2'] } };
expect(isExempt(msg, config)).toBe(true);
});

it('should return false when moderatorRoleIds is set but member does not have any', () => {
const msg = makeMessage({ isAdmin: false, roleIds: [] });
const config = { permissions: { moderatorRoleId: 'mod-role-id' } };
const config = { permissions: { moderatorRoleIds: ['mod-role-id'] } };
expect(isExempt(msg, config)).toBe(false);
});

it('should support backward compat: singular adminRoleId still grants exemption', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['admin-role-id'] });
const config = { permissions: { adminRoleId: 'admin-role-id' } };
expect(isExempt(msg, config)).toBe(true);
});

it('should support backward compat: singular moderatorRoleId still grants exemption', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['mod-role-id'] });
const config = { permissions: { moderatorRoleId: 'mod-role-id' } };
expect(isExempt(msg, config)).toBe(true);
});

it('should grant exemption via legacy adminRoleId even when adminRoleIds:[] default is present (merged config)', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['legacy-admin'] });
const config = { permissions: { adminRoleIds: [], adminRoleId: 'legacy-admin' } };
expect(isExempt(msg, config)).toBe(true);
});

it('should grant exemption via legacy moderatorRoleId even when moderatorRoleIds:[] default is present (merged config)', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['legacy-mod'] });
const config = { permissions: { moderatorRoleIds: [], moderatorRoleId: 'legacy-mod' } };
expect(isExempt(msg, config)).toBe(true);
});

it('should return true when member has a role ID in modRoles array', () => {
const msg = makeMessage({ isAdmin: false, roleIds: ['custom-mod'] });
const config = { permissions: { modRoles: ['custom-mod'] } };
Expand Down
Loading
Loading