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
18 changes: 18 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Enforce LF line endings for all text files.
# Overrides core.autocrlf on Windows checkouts.
* text=auto eol=lf

# Explicitly mark binary formats as binary (no line-ending conversion).
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.otf binary
*.zip binary
*.tar binary
*.gz binary
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ All configuration lives in `config.json` and can be updated at runtime via the `
| `logging.channels.timeouts` | string | Channel for timeout events |
| `logging.channels.purges` | string | Channel for purge events |
| `logging.channels.locks` | string | Channel for lock/unlock events |
| `protectRoles.enabled` | boolean | Enable role protection (prevents moderating admins/mods/owner) |
| `protectRoles.includeServerOwner` | boolean | Include server owner in protection |
| `protectRoles.includeAdmins` | boolean | Include admin role in protection |
| `protectRoles.includeModerators` | boolean | Include moderator role in protection |
| `protectRoles.roleIds` | string[] | Additional role IDs to protect from moderation |

**Escalation thresholds** are objects with: `warns` (count), `withinDays` (window), `action` ("timeout" or "ban"), `duration` (for timeout, e.g. "1h").

Expand Down
7 changes: 7 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@
"purges": null,
"locks": null
}
},
"protectRoles": {
"enabled": true,
"roleIds": [],
"includeAdmins": true,
"includeModerators": true,
"includeServerOwner": true
}
},
"memory": {
Expand Down
10 changes: 10 additions & 0 deletions src/api/utils/configValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ export const CONFIG_SCHEMA = {
},
},
},
protectRoles: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
roleIds: { type: 'array' },
includeAdmins: { type: 'boolean' },
includeModerators: { type: 'boolean' },
includeServerOwner: { type: 'boolean' },
},
},
},
},
triage: {
Expand Down
1 change: 1 addition & 0 deletions src/commands/untimeout.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export async function execute(interaction) {
await executeModAction(interaction, {
action: 'untimeout',
skipDm: true,
skipProtection: true,
getTarget: (inter) => {
const target = inter.options.getMember('user');
if (!target) return { earlyReturn: '\u274C User is not in this server.' };
Expand Down
51 changes: 51 additions & 0 deletions src/modules/moderation.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,57 @@ 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
* @param {Object} config - Bot configuration
* @returns {boolean} True if the target should not be moderated
*/
export function isProtectedTarget(target, guild, config) {
/**
* When the protectRoles block is missing from persisted configuration,
* fall back to the intended defaults: protection enabled, include owner,
* admins, and moderators (matches config.json defaults and web UI defaults).
*/
const defaultProtectRoles = {
enabled: true,
includeAdmins: true,
includeModerators: true,
includeServerOwner: true,
roleIds: [],
};

// Deep-merge defaults so a partial persisted object (e.g. only roleIds set)
// never leaves enabled/include* as undefined/falsy.
const protectRoles = { ...defaultProtectRoles, ...config.moderation?.protectRoles };
Comment on lines +458 to +468
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isProtectedTarget function defaults enabled: true when protectRoles is absent from the persisted config. This means all existing guilds without an explicit protectRoles config block will silently have protection enabled after this update, which is a breaking behavior change.

Specifically, if config.permissions.adminRoleId or config.permissions.moderatorRoleId is set on a guild that has not yet configured protectRoles, admins and moderators will immediately become un-moderatable. The PR description does not mention this as intentional for existing deployments.

Consider defaulting to enabled: false when the key is absent (opt-in semantics), or at minimum document this behavior prominently in the migration notes. The config.json already ships the block, so new deployments are unaffected, but existing guilds whose config was persisted before this change will be silently affected.

Copilot uses AI. Check for mistakes.
if (!protectRoles.enabled) {
return false;
}

// Server owner is always protected when enabled
if (protectRoles.includeServerOwner && target.id === guild.ownerId) {
return true;
}

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

if (protectedRoleIds.length === 0) return false;

const memberRoleIds = [...target.roles.cache.keys()];
return protectedRoleIds.some((roleId) => memberRoleIds.includes(roleId));
}

/**
* Check if the moderator (and optionally the bot) can moderate a target member.
* @param {import('discord.js').GuildMember} moderator - The moderator
Expand Down
23 changes: 23 additions & 0 deletions src/modules/triage-respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter.
import { safeSend } from '../utils/safeSend.js';
import { splitMessage } from '../utils/splitMessage.js';
import { addToHistory } from './ai.js';
import { isProtectedTarget } from './moderation.js';
import { resolveMessageId, sanitizeText } from './triage-filter.js';

/** Maximum characters to keep from fetched context messages. */
Expand Down Expand Up @@ -105,6 +106,28 @@ export async function sendModerationLog(client, classification, snapshot, channe
// Find target messages from the snapshot
const targets = snapshot.filter((m) => classification.targetMessageIds?.includes(m.messageId));

// Skip moderation log if any flagged user is a protected role (admin/mod/owner)
const guild = logChannel.guild;
if (guild && targets.length > 0) {
const seenUserIds = new Set();
for (const t of targets) {
if (seenUserIds.has(t.userId)) continue;
seenUserIds.add(t.userId);
try {
const member = await guild.members.fetch(t.userId);
if (isProtectedTarget(member, guild, config)) {
warn('Triage skipped moderation log: target is a protected role', {
userId: t.userId,
channelId,
});
return;
}
} catch {
// Member not in guild or fetch failed — proceed with logging
}
}
}

const actionLabels = {
warn: '\u26A0\uFE0F Warn',
timeout: '\uD83D\uDD07 Timeout',
Expand Down
24 changes: 23 additions & 1 deletion src/utils/modAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
* case creation, mod log, success reply, and error handling.
*/

import { debug, info, error as logError } from '../logger.js';
import { debug, info, error as logError, warn } from '../logger.js';
import { getConfig } from '../modules/config.js';
import {
checkHierarchy,
createCase,
isProtectedTarget,
sendDmNotification,
sendModLogEmbed,
shouldSendDm,
Expand All @@ -35,6 +36,7 @@ import { safeEditReply } from './safeSend.js';
* command — they are passed to `actionFn` but excluded from case data by the
* `...extraCaseData` spread (callers must destructure them out).
* @param {boolean} [opts.skipHierarchy=false] - Skip role hierarchy check
* @param {boolean} [opts.skipProtection=false] - Skip protected-role check (e.g. unban)
* @param {boolean} [opts.skipDm=false] - Skip DM notification
* @param {string} [opts.dmAction] - Override action name for DM (e.g. tempban uses 'ban')
* @param {Function} [opts.afterCase] - async (caseData, interaction, config) => void
Expand All @@ -49,6 +51,7 @@ export async function executeModAction(interaction, opts) {
actionFn,
extractOptions,
skipHierarchy = false,
skipProtection = false,
skipDm = false,
dmAction,
afterCase,
Expand Down Expand Up @@ -91,6 +94,25 @@ export async function executeModAction(interaction, opts) {

const { target, targetId, targetTag } = resolved;

// Self-moderation is always blocked, even when skipProtection is true
if (target && target.id === interaction.user.id) {
return await safeEditReply(interaction, '\u274C You cannot moderate yourself.');
}

// Protected-role check (skipped when skipProtection is true)
if (!skipProtection && target) {
if (isProtectedTarget(target, interaction.guild, config)) {
warn('Moderation blocked: target is a protected role', {
action,
targetId: target.id,
targetTag,
moderatorId: interaction.user.id,
guildId: interaction.guildId,
});
return await safeEditReply(interaction, '\u274C Cannot moderate a protected user.');
}
}

// Hierarchy check
if (!skipHierarchy && target) {
const hierarchyError = checkHierarchy(
Expand Down
1 change: 1 addition & 0 deletions tests/commands/ban.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({
sendDmNotification: vi.fn().mockResolvedValue(undefined),
sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }),
checkHierarchy: vi.fn().mockReturnValue(null),
isProtectedTarget: vi.fn().mockReturnValue(false),
shouldSendDm: vi.fn().mockReturnValue(true),
}));

Expand Down
18 changes: 17 additions & 1 deletion tests/commands/kick.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({
sendDmNotification: vi.fn().mockResolvedValue(undefined),
sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }),
checkHierarchy: vi.fn().mockReturnValue(null),
isProtectedTarget: vi.fn().mockReturnValue(false),
shouldSendDm: vi.fn().mockReturnValue(true),
}));

Expand All @@ -26,7 +27,12 @@ vi.mock('../../src/modules/config.js', () => ({
vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }));

import { adminOnly, data, execute } from '../../src/commands/kick.js';
import { checkHierarchy, createCase, sendDmNotification } from '../../src/modules/moderation.js';
import {
checkHierarchy,
createCase,
isProtectedTarget,
sendDmNotification,
} from '../../src/modules/moderation.js';

describe('kick command', () => {
afterEach(() => {
Expand Down Expand Up @@ -75,6 +81,16 @@ describe('kick command', () => {
expect(adminOnly).toBe(true);
});

it('should reject when target is a protected role', async () => {
isProtectedTarget.mockReturnValueOnce(true);
const { interaction, mockMember } = createInteraction();

await execute(interaction);

expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('protected'));
expect(mockMember.kick).not.toHaveBeenCalled();
});

it('should kick a user successfully', async () => {
const { interaction, mockMember } = createInteraction();

Expand Down
1 change: 1 addition & 0 deletions tests/commands/softban.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({
sendDmNotification: vi.fn().mockResolvedValue(undefined),
sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }),
checkHierarchy: vi.fn().mockReturnValue(null),
isProtectedTarget: vi.fn().mockReturnValue(false),
shouldSendDm: vi.fn().mockReturnValue(true),
}));

Expand Down
1 change: 1 addition & 0 deletions tests/commands/tempban.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock('../../src/modules/moderation.js', () => ({
sendDmNotification: vi.fn().mockResolvedValue(undefined),
sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }),
checkHierarchy: vi.fn().mockReturnValue(null),
isProtectedTarget: vi.fn().mockReturnValue(false),
shouldSendDm: vi.fn().mockReturnValue(true),
}));

Expand Down
1 change: 1 addition & 0 deletions tests/commands/timeout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('../../src/modules/moderation.js', () => ({
sendDmNotification: vi.fn().mockResolvedValue(undefined),
sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }),
checkHierarchy: vi.fn().mockReturnValue(null),
isProtectedTarget: vi.fn().mockReturnValue(false),
shouldSendDm: vi.fn().mockReturnValue(true),
}));

Expand Down
1 change: 1 addition & 0 deletions tests/commands/warn.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ vi.mock('../../src/modules/moderation.js', () => ({
sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }),
checkEscalation: vi.fn().mockResolvedValue(null),
checkHierarchy: vi.fn().mockReturnValue(null),
isProtectedTarget: vi.fn().mockReturnValue(false),
shouldSendDm: vi.fn().mockReturnValue(true),
}));

Expand Down
Loading
Loading