Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 18 additions & 1 deletion src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,22 @@ 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' } },
// Legacy singular fields — kept for backward compat during migration
adminRoleId: { type: 'string', nullable: true },
moderatorRoleId: { type: 'string', nullable: true },
modRoles: { type: 'array', items: { type: 'string' } },
botOwners: { type: 'array', items: { type: 'string' } },
// allowedCommands is a freeform map of command → permission level — no fixed property list
allowedCommands: { type: 'object', openProperties: true },
},
},
};

/**
Expand Down Expand Up @@ -274,9 +290,10 @@ export function validateValue(value, schema, path) {
for (const [key, val] of Object.entries(value)) {
if (Object.hasOwn(schema.properties, key)) {
errors.push(...validateValue(val, schema.properties[key], `${path}.${key}`));
} else {
} else if (!schema.openProperties) {
errors.push(`${path}.${key}: unknown config key`);
}
// openProperties: true — freeform map, unknown keys are allowed
}
}
break;
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
78 changes: 60 additions & 18 deletions src/utils/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@

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) {
// Normalize roleIds defensively — persisted config may contain a string instead of an array
let base;
if (Array.isArray(roleIds)) {
base = roleIds;
} else if (typeof roleIds === 'string' && roleIds.length > 0) {
base = [roleIds];
} else {
base = [];
}
const merged = new Set(base);
if (typeof roleId === 'string' && roleId.length > 0) {
merged.add(roleId);
}
return [...merged];
}

/**
* Retrieve the configured bot owner user IDs.
*
Expand Down Expand Up @@ -43,11 +72,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 +91,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 +168,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 +193,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