Skip to content

fix: protect admins/mods/owner from moderation actions (#174)#181

Merged
BillChirico merged 6 commits intoVolvoxLLC:mainfrom
brian-mwirigi:fix/174-protect-privileged-roles
Mar 2, 2026
Merged

fix: protect admins/mods/owner from moderation actions (#174)#181
BillChirico merged 6 commits intoVolvoxLLC:mainfrom
brian-mwirigi:fix/174-protect-privileged-roles

Conversation

@brian-mwirigi
Copy link
Contributor

Closes #174

What changed

  • isProtectedTarget() exported from moderation.js — checks server owner, adminRoleId, moderatorRoleId, and custom roleIds
  • All mod commands (/warn, /kick, /ban, /tempban, /softban, /timeout) blocked from targeting protected users via executeModAction
  • Self-moderation prevention — mods cannot moderate themselves
  • skipProtection: true on /untimeout (removing a punishment should never be blocked)
  • Audit log: warn() fires with context whenever protection blocks an action
  • Triage: sendModerationLog skips the moderation embed if a flagged user is a protected role
  • Web dashboard: "Protect Roles from Moderation" fieldset added to the Moderation config card (toggle + 3 checkboxes + custom role IDs)
  • configValidation.js schema updated
  • TypeScript types added (ModerationProtectRoles interface)

Config schema added

"moderation": {
  "protectRoles": {
    "enabled": true,
    "roleIds": [],
    "includeAdmins": true,
    "includeModerators": true,
    "includeServerOwner": true
  }
}

Copilot AI review requested due to automatic review settings March 1, 2026 21:11
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a configurable moderation "protectRoles" feature: schema, validation, types, dashboard UI, runtime protection check (isProtectedTarget), integration into moderation actions and triage logging (with skipProtection), tests, and a new .gitattributes file.

Changes

Cohort / File(s) Summary
Repository metadata
​.gitattributes
Adds .gitattributes enforcing LF line endings and marking common binaries as binary.
Config & validation
config.json, src/api/utils/configValidation.js, web/src/types/config.ts
Adds moderation.protectRoles config shape and validation/type definitions: enabled, roleIds, includeAdmins, includeModerators, includeServerOwner.
Core moderation logic
src/modules/moderation.js, src/utils/modAction.js
Adds exported isProtectedTarget(target, guild) and integrates protection checks into executeModAction() with a new skipProtection option and self-moderation guard; logs warnings and returns user-facing errors when protection blocks actions.
Triage / logging integration
src/modules/triage-respond.js
Imports isProtectedTarget and suppresses sending moderation logs for protected targets (emits a warning); continues on member-fetch failures.
Commands
src/commands/untimeout.js
Passes skipProtection: true to executeModAction() for untimeout to bypass protection when reversing actions.
Web UI
web/src/components/.../config-editor.tsx, web/src/components/dashboard/config-sections/ModerationSection.tsx
Adds "Protect Roles from Moderation" controls, CSV parsing for extra role IDs, local state syncing, and a new onProtectRolesChange prop to wire changes into the draft/save flow.
Docs
README.md
Documents new protectRoles configuration keys and descriptions.
Tests
tests/modules/moderation.test.js, tests/modules/triage-respond.test.js, tests/utils/modAction.test.js, tests/commands/*.test.js
Adds unit tests for isProtectedTarget, updates mocks to export isProtectedTarget, and adds cases covering owner/admin/moderator/custom role scenarios, skipProtection, and self-target blocking.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'fix: protect admins/mods/owner from moderation actions (#174)' clearly and concisely summarizes the main change: preventing moderation actions against protected roles.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, detailing all modifications including the new isProtectedTarget function, blocked commands, config schema, and web dashboard changes.
Linked Issues check ✅ Passed The PR successfully implements all coding objectives from issue #174: isProtectedTarget checks server owner/admins/mods/custom roles [#174], mod commands blocked via executeModAction [#174], self-moderation prevention [#174], skipProtection for /untimeout [#174], audit logging via warn() [#174], triage respects protection [#174], web dashboard UI added [#174], and config validation/TypeScript types updated [#174].
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #174 objectives: protection logic, command blocking, configuration, validation, UI components, and testing are all in scope for the role-protection feature.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds configurable “protected targets” logic to prevent moderation actions (and some automated triage logging) from applying to privileged members (owner/admin/mod/custom roles), with corresponding web dashboard controls and validation updates.

Changes:

  • Introduces isProtectedTarget() and applies it in executeModAction plus triage moderation logging.
  • Adds web config UI + TypeScript types for moderation.protectRoles, and extends API config validation/schema.
  • Expands test coverage across moderation actions, triage logging, and the new helper.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
web/src/types/config.ts Adds ModerationProtectRoles type and protectRoles field on moderation config.
web/src/components/dashboard/config-sections/ModerationSection.tsx Adds “Protect Roles from Moderation” UI controls (Switches + roleIds input).
web/src/components/dashboard/config-editor.tsx Adds protect-roles editing to the config editor and a helper updater callback.
src/utils/modAction.js Blocks self-moderation and protected-target moderation; logs a warning on block.
src/modules/moderation.js Exports isProtectedTarget() implementing owner/admin/mod/custom role checks.
src/modules/triage-respond.js Skips triage moderation logging when a flagged target is protected.
src/commands/untimeout.js Allows /untimeout to bypass protection checks (skipProtection: true).
src/api/utils/configValidation.js Adds validation schema for moderation.protectRoles.
config.json Adds default moderation.protectRoles configuration.
tests/utils/modAction.test.js Adds tests for protected-target/self-moderation behavior and warning logs.
tests/modules/triage-respond.test.js Adds tests for skipping moderation logs when target is protected.
tests/modules/moderation.test.js Adds unit tests for isProtectedTarget().
tests/commands/*.test.js Updates mocks to include isProtectedTarget.
.gitattributes Adds LF enforcement and binary patterns (including *.lock).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/commands/kick.test.js (1)

9-16: 🧹 Nitpick | 🔵 Trivial

Mock is correct; consider adding test coverage for protected targets.

The isProtectedTarget mock returning false is appropriate for testing the standard kick flow. However, consider adding a test case where isProtectedTarget returns true to verify that the kick command properly rejects moderation of protected users.

💡 Suggested test for protected target scenario
it('should reject when target is a protected role', async () => {
  const { isProtectedTarget } = await import('../../src/modules/moderation.js');
  isProtectedTarget.mockReturnValueOnce(true);
  const { interaction, mockMember } = createInteraction();

  await execute(interaction);

  expect(interaction.editReply).toHaveBeenCalledWith(
    expect.stringContaining('protected'),
  );
  expect(mockMember.kick).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/commands/kick.test.js` around lines 9 - 16, Add a new test that asserts
the kick command rejects protected targets by mocking isProtectedTarget to
return true, then calling execute with a created interaction and verifying
interaction.editReply was called with a message containing "protected" and that
mockMember.kick was not called; specifically update the tests to import
isProtectedTarget from '../../src/modules/moderation.js' and use
isProtectedTarget.mockReturnValueOnce(true) before invoking execute (use
createInteraction to get interaction and mockMember, and assert on
interaction.editReply and mockMember.kick).
src/api/utils/configValidation.js (1)

96-122: ⚠️ Potential issue | 🔴 Critical

Schema path mismatch: protectRoles is nested incorrectly.

The protectRoles schema is placed under moderation.logging.protectRoles, but in config.json (lines 108-114), protectRoles is a direct child of moderation (sibling to logging), not nested inside logging.

This mismatch will cause validation to either fail or not validate the protectRoles config at all.

🐛 Proposed fix: Move protectRoles to be a sibling of logging
       logging: {
         type: 'object',
         properties: {
           channels: {
             type: 'object',
             properties: {
               default: { type: 'string', nullable: true },
               warns: { type: 'string', nullable: true },
               bans: { type: 'string', nullable: true },
               kicks: { type: 'string', nullable: true },
               timeouts: { type: 'string', nullable: true },
               purges: { type: 'string', nullable: true },
               locks: { type: 'string', nullable: true },
             },
           },
         },
       },
-    },
+      protectRoles: {
+        type: 'object',
+        properties: {
+          enabled: { type: 'boolean' },
+          roleIds: { type: 'array' },
+          includeAdmins: { type: 'boolean' },
+          includeModerators: { type: 'boolean' },
+          includeServerOwner: { type: 'boolean' },
+        },
+      },
+    },
-      protectRoles: {
-        type: 'object',
-        properties: {
-          enabled: { type: 'boolean' },
-          roleIds: { type: 'array' },
-          includeAdmins: { type: 'boolean' },
-          includeModerators: { type: 'boolean' },
-          includeServerOwner: { type: 'boolean' },
-        },
-      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/utils/configValidation.js` around lines 96 - 122, The schema places
protectRoles inside the moderation.logging object, but the actual config has
protectRoles as a sibling of logging; move the protectRoles definition out of
the logging properties and add it as a top-level property alongside logging in
the moderation schema so the protectRoles object (with enabled, roleIds,
includeAdmins, includeModerators, includeServerOwner) is validated correctly;
update any references to the protectRoles symbol in the schema so it is no
longer nested under logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@config.json`:
- Around line 108-114: Update the README.md Moderation section to document the
new protectRoles configuration: add five table rows to the moderation config
table describing protectRoles.enabled (boolean — Enable role protection
features), protectRoles.roleIds (string[] — Role IDs to protect from
modifications), protectRoles.includeAdmins (boolean — Include admin roles in
protection), protectRoles.includeModerators (boolean — Include moderator roles
in protection), and protectRoles.includeServerOwner (boolean — Include server
owner in protection); place these rows together in the Moderation table so they
clearly belong to the protectRoles block.

In `@src/modules/triage-respond.js`:
- Around line 85-103: The variable guildConfig is a redundant alias for config;
remove the extra declaration and update the protection check to call
isProtectedTarget(member, guild, config) directly (in the loop that fetches
members and calls isProtectedTarget) so you don't keep an unused alias named
guildConfig in triage-respond.js.

In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1087-1095: The current input normalizes roleIds on every keystroke
(value={(draftConfig.moderation?.protectRoles?.roleIds ?? []).join(', ')} with
onChange splitting/trim/filter), which removes trailing commas and disrupts
typing; introduce a protectRoleIdsRaw string state (initialized/synced like
roleMenuRaw/dmStepsRaw) and bind the input value to protectRoleIdsRaw, update
protectRoleIdsRaw onChange, and only parse/sync into
draftConfig.moderation.protectRoles.roleIds by splitting/trim/filter on blur or
save (use updateProtectRolesField('roleIds', parsedArray) there) so typing is
not normalized mid-entry.

In `@web/src/components/dashboard/config-sections/ModerationSection.tsx`:
- Around line 160-168: The input currently re-parses and re-serializes roleIds
on every keystroke (value={(draftConfig.moderation?.protectRoles?.roleIds ??
[]).join(', ')} with onChange calling onProtectRolesChange), which strips commas
while typing; change to maintain a local raw string state (e.g., roleIdsRaw) in
ModerationSection, bind the input value to that raw state and update it on every
onChange, and only parse/split/trim/filter and call
onProtectRolesChange('roleIds', parsedArray) on onBlur or when saving; also
initialize/sync the raw state from draftConfig.moderation?.protectRoles?.roleIds
and handle external updates to keep it in sync.

---

Outside diff comments:
In `@src/api/utils/configValidation.js`:
- Around line 96-122: The schema places protectRoles inside the
moderation.logging object, but the actual config has protectRoles as a sibling
of logging; move the protectRoles definition out of the logging properties and
add it as a top-level property alongside logging in the moderation schema so the
protectRoles object (with enabled, roleIds, includeAdmins, includeModerators,
includeServerOwner) is validated correctly; update any references to the
protectRoles symbol in the schema so it is no longer nested under logging.

In `@tests/commands/kick.test.js`:
- Around line 9-16: Add a new test that asserts the kick command rejects
protected targets by mocking isProtectedTarget to return true, then calling
execute with a created interaction and verifying interaction.editReply was
called with a message containing "protected" and that mockMember.kick was not
called; specifically update the tests to import isProtectedTarget from
'../../src/modules/moderation.js' and use
isProtectedTarget.mockReturnValueOnce(true) before invoking execute (use
createInteraction to get interaction and mockMember, and assert on
interaction.editReply and mockMember.kick).

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3eb272 and d283a73.

📒 Files selected for processing (19)
  • .gitattributes
  • config.json
  • src/api/utils/configValidation.js
  • src/commands/untimeout.js
  • src/modules/moderation.js
  • src/modules/triage-respond.js
  • src/utils/modAction.js
  • tests/commands/ban.test.js
  • tests/commands/kick.test.js
  • tests/commands/softban.test.js
  • tests/commands/tempban.test.js
  • tests/commands/timeout.test.js
  • tests/commands/warn.test.js
  • tests/modules/moderation.test.js
  • tests/modules/triage-respond.test.js
  • tests/utils/modAction.test.js
  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
  • web/src/types/config.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (8)
**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

**/*.js: Use ESM modules — use import/export, never require()
Always use node: protocol for Node.js builtins (e.g. import { readFileSync } from 'node:fs')
Always use semicolons in JavaScript code
Use single quotes for strings (enforced by Biome)
Use 2-space indentation (enforced by Biome)

Files:

  • tests/modules/moderation.test.js
  • tests/modules/triage-respond.test.js
  • src/modules/triage-respond.js
  • tests/commands/softban.test.js
  • tests/commands/timeout.test.js
  • tests/commands/ban.test.js
  • tests/utils/modAction.test.js
  • tests/commands/kick.test.js
  • tests/commands/tempban.test.js
  • src/utils/modAction.js
  • src/modules/moderation.js
  • src/api/utils/configValidation.js
  • src/commands/untimeout.js
  • tests/commands/warn.test.js
tests/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

All new code must include tests. Test coverage must maintain an 80% threshold on statements, branches, functions, and lines. Run pnpm test before every commit

Files:

  • tests/modules/moderation.test.js
  • tests/modules/triage-respond.test.js
  • tests/commands/softban.test.js
  • tests/commands/timeout.test.js
  • tests/commands/ban.test.js
  • tests/utils/modAction.test.js
  • tests/commands/kick.test.js
  • tests/commands/tempban.test.js
  • tests/commands/warn.test.js
src/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.js: Always use Winston logger — import { info, warn, error } from '../logger.js'. NEVER use console.log, console.warn, console.error, or any console.* method in src/ files — replace any existing console calls with Winston equivalents
Pass structured metadata to Winston logs: info('Message processed', { userId, channelId })

Files:

  • src/modules/triage-respond.js
  • src/utils/modAction.js
  • src/modules/moderation.js
  • src/api/utils/configValidation.js
  • src/commands/untimeout.js
src/modules/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Per-request modules (AI, spam, moderation) should call getConfig(guildId) on every invocation for automatic config changes. Stateful resources should use onConfigChange listeners for reactive updates

Files:

  • src/modules/triage-respond.js
  • src/modules/moderation.js
web/src/components/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Component files should integrate with Zustand stores for state management (e.g., discord-entities store for caching Discord channels and roles per guild)

Files:

  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
web/src/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript with type safety. Share contracts between dashboard UI and API responses via web/src/types/analytics.ts and similar type definition files

Files:

  • web/src/types/config.ts
config.json

📄 CodeRabbit inference engine (AGENTS.md)

When adding a new config section or key, document it in README.md's config reference section

Files:

  • config.json
src/commands/*.js

📄 CodeRabbit inference engine (AGENTS.md)

src/commands/*.js: Slash commands must export data (SlashCommandBuilder) and execute(interaction) function. Export adminOnly = true for mod-only commands
Moderation commands must follow the shared pattern: deferReply({ ephemeral: true }), validate inputs, sendDmNotification(), execute Discord action, createCase(), sendModLogEmbed(), checkEscalation()

Files:

  • src/commands/untimeout.js
🧠 Learnings (4)
📓 Common learnings
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Always call `checkHierarchy(moderator, target)` before executing moderation actions to prevent moderating users with equal or higher roles
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Applies to src/commands/*.js : Moderation commands must follow the shared pattern: `deferReply({ ephemeral: true })`, validate inputs, `sendDmNotification()`, execute Discord action, `createCase()`, `sendModLogEmbed()`, `checkEscalation()`

Applied to files:

  • tests/modules/moderation.test.js
  • tests/modules/triage-respond.test.js
  • src/modules/triage-respond.js
  • web/src/types/config.ts
  • tests/utils/modAction.test.js
  • src/utils/modAction.js
  • tests/commands/warn.test.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Always call `checkHierarchy(moderator, target)` before executing moderation actions to prevent moderating users with equal or higher roles

Applied to files:

  • tests/modules/moderation.test.js
  • src/modules/triage-respond.js
  • tests/utils/modAction.test.js
  • src/utils/modAction.js
  • src/modules/moderation.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Use `splitMessage()` utility for messages exceeding Discord's 2000-character limit

Applied to files:

  • src/modules/triage-respond.js
🧬 Code graph analysis (7)
tests/modules/moderation.test.js (1)
src/modules/moderation.js (4)
  • guild (306-306)
  • guild (371-371)
  • config (377-377)
  • isProtectedTarget (452-475)
tests/modules/triage-respond.test.js (2)
src/modules/moderation.js (1)
  • isProtectedTarget (452-475)
src/modules/triage-respond.js (1)
  • sendModerationLog (74-141)
src/modules/triage-respond.js (1)
src/modules/moderation.js (5)
  • guild (306-306)
  • guild (371-371)
  • config (377-377)
  • member (307-307)
  • isProtectedTarget (452-475)
web/src/components/dashboard/config-editor.tsx (2)
web/src/lib/config-utils.ts (1)
  • GuildConfig (4-4)
web/src/components/dashboard/toggle-switch.tsx (1)
  • ToggleSwitch (14-32)
tests/utils/modAction.test.js (3)
src/modules/moderation.js (2)
  • isProtectedTarget (452-475)
  • createCase (88-156)
src/utils/modAction.js (1)
  • executeModAction (47-179)
src/logger.js (1)
  • warn (238-240)
src/utils/modAction.js (3)
src/utils/safeSend.js (1)
  • safeEditReply (178-185)
src/modules/moderation.js (2)
  • isProtectedTarget (452-475)
  • config (377-377)
src/logger.js (1)
  • warn (238-240)
web/src/components/dashboard/config-sections/ModerationSection.tsx (4)
src/utils/cronParser.js (1)
  • field (34-34)
web/src/components/ui/label.tsx (1)
  • Label (21-21)
web/src/components/ui/switch.tsx (1)
  • Switch (35-35)
web/src/components/ui/input.tsx (1)
  • Input (21-21)
🔇 Additional comments (17)
.gitattributes (1)

1-19: LGTM!

Good addition for ensuring consistent LF line endings across platforms. The binary file exclusions are comprehensive and cover common formats.

src/commands/untimeout.js (1)

24-38: LGTM!

Adding skipProtection: true for the untimeout command is correct — removing a punishment from a protected user should not be blocked. This aligns well with the PR objectives.

tests/commands/timeout.test.js (1)

9-16: LGTM!

The mock addition is consistent with other command test files. Same suggestion as for kick.test.js applies — consider adding a test case for when a protected target is encountered.

tests/modules/triage-respond.test.js (2)

40-46: LGTM!

Good setup for testing the isProtectedTarget integration with triage moderation logging.


269-331: Excellent test coverage for protected role handling.

These tests comprehensively verify both scenarios:

  1. Moderation log is skipped and warning is emitted when target has a protected role
  2. Moderation log is sent normally when target is not protected

The assertions correctly check safeSend call behavior and the warning message content.

tests/commands/ban.test.js (1)

9-16: LGTM!

The mock addition is consistent with other command test files. The same suggestion about adding a protected target test case applies here as well.

tests/commands/warn.test.js (1)

17-17: LGTM!

The isProtectedTarget mock returning false by default is correctly added, ensuring existing tests continue to work without protection blocking the moderation flow.

web/src/types/config.ts (1)

119-127: LGTM!

The ModerationProtectRoles interface is well-structured with appropriate types for each field. The optional protectRoles property in ModerationConfig correctly reflects the opt-in nature of this feature.

tests/commands/softban.test.js (1)

14-14: LGTM!

The mock follows the same pattern as other command tests, ensuring consistent test behavior across the moderation command suite.

src/utils/modAction.js (1)

97-118: LGTM!

The protection guard implementation is well-structured:

  • Self-moderation check prevents mods from acting on themselves
  • Protected target check integrates with isProtectedTarget function
  • Warning log includes comprehensive audit metadata (action, targetId, targetTag, moderatorId, guildId)
  • User-facing error messages are clear and consistent with the codebase style
  • skipProtection option correctly allows lift-punishment commands (like /untimeout) to bypass protection

Based on learnings: The protection check correctly runs before checkHierarchy(moderator, target), maintaining the established pattern while adding the new security layer.

src/modules/moderation.js (1)

452-475: LGTM!

The isProtectedTarget function is well-implemented:

  • Early return when protection is disabled avoids unnecessary computation
  • Server owner check is correctly prioritized
  • Role aggregation handles missing config values gracefully with optional chaining
  • The .filter(Boolean) correctly removes falsy values from the protected role IDs array
  • Membership check via target.roles.cache.keys() correctly accesses Discord.js GuildMember role collection
tests/commands/tempban.test.js (1)

20-20: LGTM!

Consistent mock addition following the established pattern across command tests.

tests/modules/moderation.test.js (1)

576-741: Comprehensive test coverage for isProtectedTarget.

The test suite thoroughly covers:

  • Feature toggle states (enabled/disabled/absent)
  • All protection flags (includeServerOwner, includeAdmins, includeModerators)
  • Custom role IDs
  • Negative cases ensuring non-protected users aren't incorrectly flagged
  • Edge case where no protected role IDs resolve

The makeTarget helper correctly simulates GuildMember.roles.cache.keys() behavior.

web/src/components/dashboard/config-sections/ModerationSection.tsx (1)

16-17: Prop wiring for onProtectRolesChange looks correct.

The new callback is consistently threaded through props and component signature.

Also applies to: 26-27

web/src/components/dashboard/config-editor.tsx (1)

576-590: updateProtectRolesField follows the existing nested update pattern cleanly.

This is consistent with the other section updaters and keeps writes scoped to moderation.protectRoles.

tests/utils/modAction.test.js (2)

3-10: Mock/import updates for protection flow are solid.

isProtectedTarget and warn are correctly wired for the new execution paths.

Also applies to: 32-43


266-331: Good coverage for protection guard paths.

These cases meaningfully cover protected-target block, warning audit, skipProtection bypass, and self-target behavior.

@greptile-apps
Copy link

greptile-apps bot commented Mar 1, 2026

Greptile Summary

This PR implements role-based protection from moderation actions, preventing moderators from targeting admins, other mods, the server owner, and custom protected roles.

Key changes:

  • isProtectedTarget() function fetches config internally for live updates, applies sensible defaults via deep-merge, and checks server owner, admin/moderator roles, and custom role IDs
  • All mod commands (/warn, /kick, /ban, /tempban, /softban, /timeout) block protected users via executeModAction, with skipProtection: true on /untimeout to allow removing punishments
  • Self-moderation prevention enforced before protection checks (cannot moderate yourself)
  • Triage optimization: skips expensive member-fetch loop when protection is explicitly disabled
  • Web dashboard: new "Protect Roles from Moderation" UI section with toggle, checkboxes, and role ID input
  • Config validation enhanced with items: { type: 'string' } for roleIds array
  • TypeScript types properly defined in web/src/types/config.ts
  • Comprehensive test coverage for both backend logic and triage optimization

The implementation is well-structured, follows project conventions (ESM, Winston logging, parameterized validation), and properly handles edge cases like partial config objects and role changes.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The implementation is clean, well-tested, and follows all project conventions. No security vulnerabilities, logic errors, or code quality issues found. Default values are consistent, error handling is appropriate, and the optimization in triage-respond.js correctly avoids unnecessary API calls. Tests comprehensively cover protection scenarios.
  • No files require special attention

Important Files Changed

Filename Overview
src/modules/moderation.js Added isProtectedTarget() function with proper defaults and deep-merge logic for role protection configuration
src/utils/modAction.js Integrated protection check into moderation flow, correctly ordered after self-moderation check
src/modules/triage-respond.js Added optimization to skip member fetch loop when protection is explicitly disabled, preventing unnecessary API calls
web/src/components/dashboard/config-sections/ModerationSection.tsx Added UI section for role protection with switches and role ID input, properly synced with draft config

Last reviewed commit: 7fc24c3

@BillChirico BillChirico self-assigned this Mar 1, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
web/src/components/dashboard/config-editor.tsx (1)

1089-1100: ⚠️ Potential issue | 🟡 Minor

protectRoleIdsRaw can be missed by Ctrl/Cmd+S saves while the input is focused.

Line 1092 only commits parsed IDs on blur. In this component, save can be triggered from keyboard without blurring first, so stale draftConfig.moderation.protectRoles.roleIds may be persisted.

💡 Proposed fix
                 <input
                   type="text"
                   value={protectRoleIdsRaw}
-                  onChange={(e) => setProtectRoleIdsRaw(e.target.value)}
-                  onBlur={() =>
-                    updateProtectRolesField(
-                      'roleIds',
-                      protectRoleIdsRaw
-                        .split(',')
-                        .map((s) => s.trim())
-                        .filter(Boolean),
-                    )
-                  }
+                  onChange={(e) => {
+                    const raw = e.target.value;
+                    setProtectRoleIdsRaw(raw);
+                    updateProtectRolesField(
+                      'roleIds',
+                      raw
+                        .split(',')
+                        .map((s) => s.trim())
+                        .filter(Boolean),
+                    );
+                  }}
+                  onBlur={(e) =>
+                    setProtectRoleIdsRaw(
+                      e.target.value
+                        .split(',')
+                        .map((s) => s.trim())
+                        .filter(Boolean)
+                        .join(', '),
+                    )
+                  }
                   disabled={saving}
                   className={inputClasses}
                   placeholder="Role ID 1, Role ID 2"
                 />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-editor.tsx` around lines 1089 - 1100,
protectRoleIdsRaw is only parsed and saved in the onBlur handler, so a
Ctrl/Cmd+S save while the input is focused can persist stale
draftConfig.moderation.protectRoles.roleIds; update the component to also commit
the parsed IDs when a save keystroke or form submit occurs (or when Ctrl/Cmd+S
is detected). Specifically, keep the existing onBlur parsing logic but also add
a handler that listens for the save action (e.g., onKeyDown for the input or the
global save handler used by the form) and calls the same parsing code and
updateProtectRolesField('roleIds', parsedArray) (reusing the split/trim/filter
logic), or programmatically blur the input by calling setProtectRoleIdsRaw and
then triggering the onBlur behavior so values are synchronized before saving.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/utils/modAction.js`:
- Around line 97-118: The self-moderation check is incorrectly guarded by
skipProtection, allowing actions on oneself when skipProtection is true; update
the logic in the mod action flow so the "Prevent mods from moderating
themselves" check (compare target.id to interaction.user.id and call
safeEditReply with 'You cannot moderate yourself.') runs unconditionally (or
before any skipProtection gating), while leaving the protected-role check
(isProtectedTarget(...)) still behind skipProtection as intended.

---

Duplicate comments:
In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1089-1100: protectRoleIdsRaw is only parsed and saved in the
onBlur handler, so a Ctrl/Cmd+S save while the input is focused can persist
stale draftConfig.moderation.protectRoles.roleIds; update the component to also
commit the parsed IDs when a save keystroke or form submit occurs (or when
Ctrl/Cmd+S is detected). Specifically, keep the existing onBlur parsing logic
but also add a handler that listens for the save action (e.g., onKeyDown for the
input or the global save handler used by the form) and calls the same parsing
code and updateProtectRolesField('roleIds', parsedArray) (reusing the
split/trim/filter logic), or programmatically blur the input by calling
setProtectRoleIdsRaw and then triggering the onBlur behavior so values are
synchronized before saving.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d283a73 and b5452be.

📒 Files selected for processing (8)
  • .gitattributes
  • README.md
  • src/modules/moderation.js
  • src/modules/triage-respond.js
  • src/utils/modAction.js
  • tests/commands/kick.test.js
  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (6)
**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

**/*.js: Use ESM modules — use import/export, never require()
Always use node: protocol for Node.js builtins (e.g. import { readFileSync } from 'node:fs')
Always use semicolons in JavaScript code
Use single quotes for strings (enforced by Biome)
Use 2-space indentation (enforced by Biome)

Files:

  • src/modules/moderation.js
  • tests/commands/kick.test.js
  • src/utils/modAction.js
  • src/modules/triage-respond.js
src/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.js: Always use Winston logger — import { info, warn, error } from '../logger.js'. NEVER use console.log, console.warn, console.error, or any console.* method in src/ files — replace any existing console calls with Winston equivalents
Pass structured metadata to Winston logs: info('Message processed', { userId, channelId })

Files:

  • src/modules/moderation.js
  • src/utils/modAction.js
  • src/modules/triage-respond.js
src/modules/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Per-request modules (AI, spam, moderation) should call getConfig(guildId) on every invocation for automatic config changes. Stateful resources should use onConfigChange listeners for reactive updates

Files:

  • src/modules/moderation.js
  • src/modules/triage-respond.js
tests/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

All new code must include tests. Test coverage must maintain an 80% threshold on statements, branches, functions, and lines. Run pnpm test before every commit

Files:

  • tests/commands/kick.test.js
web/src/components/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Component files should integrate with Zustand stores for state management (e.g., discord-entities store for caching Discord channels and roles per guild)

Files:

  • web/src/components/dashboard/config-editor.tsx
  • web/src/components/dashboard/config-sections/ModerationSection.tsx
README.md

📄 CodeRabbit inference engine (AGENTS.md)

Keep README.md updated with setup instructions, architecture overview, config reference, and environment variables. Update when adding new commands, modules, or changing architecture

Files:

  • README.md
🧠 Learnings (5)
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Always call `checkHierarchy(moderator, target)` before executing moderation actions to prevent moderating users with equal or higher roles

Applied to files:

  • src/modules/moderation.js
  • tests/commands/kick.test.js
  • src/utils/modAction.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Applies to src/commands/*.js : Moderation commands must follow the shared pattern: `deferReply({ ephemeral: true })`, validate inputs, `sendDmNotification()`, execute Discord action, `createCase()`, `sendModLogEmbed()`, `checkEscalation()`

Applied to files:

  • tests/commands/kick.test.js
  • src/utils/modAction.js
  • src/modules/triage-respond.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Moderation commands must DM the target before executing kicks/bans, because once a user is kicked/banned they cannot receive DMs from the bot

Applied to files:

  • tests/commands/kick.test.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Applies to src/commands/*.js : Slash commands must export `data` (SlashCommandBuilder) and `execute(interaction)` function. Export `adminOnly = true` for mod-only commands

Applied to files:

  • tests/commands/kick.test.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Use `splitMessage()` utility for messages exceeding Discord's 2000-character limit

Applied to files:

  • src/modules/triage-respond.js
🧬 Code graph analysis (5)
src/modules/moderation.js (3)
src/utils/modAction.js (1)
  • config (64-64)
src/commands/config.js (3)
  • config (126-126)
  • config (162-162)
  • config (200-200)
src/commands/modlog.js (2)
  • config (35-35)
  • config (187-187)
tests/commands/kick.test.js (1)
src/modules/moderation.js (1)
  • isProtectedTarget (452-490)
web/src/components/dashboard/config-editor.tsx (1)
web/src/lib/config-utils.ts (1)
  • GuildConfig (4-4)
src/utils/modAction.js (3)
src/utils/safeSend.js (1)
  • safeEditReply (178-185)
src/modules/moderation.js (2)
  • isProtectedTarget (452-490)
  • config (377-377)
src/logger.js (1)
  • warn (238-240)
src/modules/triage-respond.js (1)
src/modules/moderation.js (5)
  • guild (306-306)
  • guild (371-371)
  • member (307-307)
  • isProtectedTarget (452-490)
  • config (377-377)
🔇 Additional comments (7)
.gitattributes (1)

1-18: LGTM! Well-formed .gitattributes configuration.

The file correctly enforces LF line endings for text files and marks common binary formats to prevent corruption. This is a standard best practice for cross-platform repositories.

Note for the team: After merging, developers may see line-ending changes in their working tree when they pull/checkout. This is expected behavior as Git normalizes existing files to LF. Running git add --renormalize . can help apply the new rules cleanly.

README.md (1)

203-207: Protect-roles config docs are clear and complete.

These additions make the new moderation.protectRoles settings discoverable and align with the implemented behavior.

src/modules/triage-respond.js (1)

85-105: Protected-target guard is correctly integrated into triage moderation logging.

The early return on protected members plus structured warning metadata is the right behavior for this flow.

tests/commands/kick.test.js (1)

84-92: Good coverage addition for protected-target behavior.

This test closes an important regression path by asserting both user feedback and that the kick action is not executed.

web/src/components/dashboard/config-sections/ModerationSection.tsx (1)

30-37: Role ID input handling is much safer now.

Using roleIdsRaw plus blur-time parsing avoids destructive normalization while typing and is a solid UX fix.

Also applies to: 167-183

web/src/components/dashboard/config-editor.tsx (1)

579-593: updateProtectRolesField follows the existing nested-update pattern well.

The helper is clean and keeps protect-roles state mutations localized and consistent.

src/modules/moderation.js (1)

466-467: No changes needed—nested defaults for protectRoles are already properly materialized.

getConfig(guildId) deep-merges guild overrides onto a full clone of global defaults (line 310 in src/modules/config.js). The deepMerge function preserves keys from the global config that are not overridden by the guild config, so missing properties like enabled retain their global default value of true. Additionally, the fallback at line 466 (config.moderation?.protectRoles ?? defaultProtectRoles) provides redundant protection. Per-field defaults are unnecessary.

Copilot AI review requested due to automatic review settings March 1, 2026 21:46
coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 1, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 1, 2026
@coveralls
Copy link

Coverage Status

coverage: 90.13% (+0.008%) from 90.122%
when pulling 244da9c on brian-mwirigi:fix/174-protect-privileged-roles
into a3eb272 on VolvoxLLC:main.

BillChirico added a commit that referenced this pull request Mar 2, 2026
* fix: protect admins/mods/owner from moderation actions (#174)

- Add moderation.protectRoles config (enabled, includeAdmins,
  includeModerators, includeServerOwner, roleIds)
- Export isProtectedTarget() from moderation.js — checks server
  owner, adminRoleId, moderatorRoleId, and custom roleIds
- Guard executeModAction with protection check before hierarchy
  check; add skipProtection flag for lift-punishment commands
- Add skipProtection: true to untimeout (removing punishment)
- Extend configValidation.js schema with protectRoles shape
- Add ModerationProtectRoles TypeScript interface to web types
- Add isProtectedTarget unit tests (9 cases) in moderation.test.js
- Add executeModAction protection path tests in modAction.test.js
- Update moderation.js mock in all affected command tests

* chore: add .gitattributes to enforce LF line endings on Windows

Biome enforces LF line endings. Without this file, Git's core.autocrlf=true
on Windows converts LF->CRLF on checkout causing 373+ format errors locally.
* text=auto eol=lf overrides autocrlf and ensures all text files are
checked out with LF regardless of OS or Git config.

* Revert "chore: add .gitattributes to enforce LF line endings on Windows"

This reverts commit 2b6262d.

* chore: add .gitattributes to enforce LF line endings

Fixes 373 Biome format errors on Windows caused by core.autocrlf=true
converting LF->CRLF on checkout. * text=auto eol=lf ensures all text
files are checked out with LF regardless of OS or local Git config.

* fix: complete protect-roles implementation for #174

- Self-moderation: prevent mods from moderating themselves
  (both self-mod + protectRoles checks now share skipProtection flag
  so untimeout can still target self)
- Audit log: warn() when protection blocks an action with
  action/targetId/moderatorId/guildId context
- Triage: sendModerationLog skips embed when any flagged user is a
  protected role (admin/mod/owner); logs warn with userId/channelId
- Dashboard UI: Protect Roles fieldset added to Moderation Card in
  config-editor.tsx (toggle + 3 checkboxes + role IDs text input)
- ModerationSection component updated with onProtectRolesChange prop
  and matching UI
- Tests: +5 tests (self-mod, warn log, skipProtection bypass,
  triage skip-protected, triage send-when-not-protected)

* fix: address code review feedback on protect-roles (#181)

* fix: separate self-mod from skipProtection, simplify error message

* fix: deep-merge protectRoles defaults, remove dead code from ModerationSection

---------

Co-authored-by: TheCoderBuilder-dev <brianinesh@gmail.com>
Co-authored-by: Pip Build <pip@volvox.gg>
Copilot AI review requested due to automatic review settings March 2, 2026 04:40
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
web/src/components/dashboard/config-editor.tsx (1)

1098-1107: ⚠️ Potential issue | 🟠 Major

Ctrl/Cmd+S can save stale protectRoles.roleIds when this field is focused.

roleIds are only synced on blur. Keyboard save can run before blur, so the latest typed IDs may not persist.

Proposed fix
                 <input
                   type="text"
                   value={protectRoleIdsRaw}
-                  onChange={(e) => setProtectRoleIdsRaw(e.target.value)}
-                  onBlur={() =>
-                    updateProtectRolesField(
-                      'roleIds',
-                      protectRoleIdsRaw
-                        .split(',')
-                        .map((s) => s.trim())
-                        .filter(Boolean),
-                    )
-                  }
+                  onChange={(e) => {
+                    const raw = e.target.value;
+                    setProtectRoleIdsRaw(raw);
+                    updateProtectRolesField(
+                      'roleIds',
+                      raw
+                        .split(',')
+                        .map((s) => s.trim())
+                        .filter(Boolean),
+                    );
+                  }}
+                  onBlur={() => {
+                    const parsed = protectRoleIdsRaw
+                      .split(',')
+                      .map((s) => s.trim())
+                      .filter(Boolean);
+                    setProtectRoleIdsRaw(parsed.join(', '));
+                  }}
                   disabled={saving}
                   className={inputClasses}
                   placeholder="Role ID 1, Role ID 2"
                 />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/dashboard/config-editor.tsx` around lines 1098 - 1107, The
protect role IDs input only updates protectRoles.roleIds on blur, so using
Ctrl/Cmd+S while the field is focused can save stale data; update the raw value
before save by invoking the same sync logic (the split/trim/filter of
protectRoleIdsRaw) when handling the global save shortcut or when detecting
Ctrl/Cmd+S in the input. Locate the input handlers (setProtectRoleIdsRaw,
updateProtectRolesField, protectRoleIdsRaw) and call
updateProtectRolesField('roleIds', protectRoleIdsRaw.split(',').map(s =>
s.trim()).filter(Boolean)) from the save shortcut handler (or add an onKeyDown
for Ctrl/Cmd+S that calls that sync) so the latest typed IDs are persisted
before the save proceeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/modules/moderation.js`:
- Around line 452-469: The isProtectedTarget function currently uses the
caller-supplied config which can become stale; update isProtectedTarget to call
getConfig(guild.id) at the start of the function to fetch the latest
configuration per invocation, then use that returned config (not the passed-in
config) when building defaultProtectRoles and computing protectRoles (deep-merge
with moderation?.protectRoles). Ensure you reference getConfig(guild.id) and
replace usages of the incoming config variable with the fresh config inside
isProtectedTarget so protection checks reflect live config changes.

---

Duplicate comments:
In `@web/src/components/dashboard/config-editor.tsx`:
- Around line 1098-1107: The protect role IDs input only updates
protectRoles.roleIds on blur, so using Ctrl/Cmd+S while the field is focused can
save stale data; update the raw value before save by invoking the same sync
logic (the split/trim/filter of protectRoleIdsRaw) when handling the global save
shortcut or when detecting Ctrl/Cmd+S in the input. Locate the input handlers
(setProtectRoleIdsRaw, updateProtectRolesField, protectRoleIdsRaw) and call
updateProtectRolesField('roleIds', protectRoleIdsRaw.split(',').map(s =>
s.trim()).filter(Boolean)) from the save shortcut handler (or add an onKeyDown
for Ctrl/Cmd+S that calls that sync) so the latest typed IDs are persisted
before the save proceeds.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1d6b96 and f9f0912.

📒 Files selected for processing (2)
  • src/modules/moderation.js
  • web/src/components/dashboard/config-editor.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (4)
web/src/components/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Component files should integrate with Zustand stores for state management (e.g., discord-entities store for caching Discord channels and roles per guild)

Files:

  • web/src/components/dashboard/config-editor.tsx
**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

**/*.js: Use ESM modules — use import/export, never require()
Always use node: protocol for Node.js builtins (e.g. import { readFileSync } from 'node:fs')
Always use semicolons in JavaScript code
Use single quotes for strings (enforced by Biome)
Use 2-space indentation (enforced by Biome)

Files:

  • src/modules/moderation.js
src/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.js: Always use Winston logger — import { info, warn, error } from '../logger.js'. NEVER use console.log, console.warn, console.error, or any console.* method in src/ files — replace any existing console calls with Winston equivalents
Pass structured metadata to Winston logs: info('Message processed', { userId, channelId })

Files:

  • src/modules/moderation.js
src/modules/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Per-request modules (AI, spam, moderation) should call getConfig(guildId) on every invocation for automatic config changes. Stateful resources should use onConfigChange listeners for reactive updates

Files:

  • src/modules/moderation.js
🧠 Learnings (1)
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Always call `checkHierarchy(moderator, target)` before executing moderation actions to prevent moderating users with equal or higher roles

Applied to files:

  • src/modules/moderation.js
🧬 Code graph analysis (1)
web/src/components/dashboard/config-editor.tsx (2)
web/src/lib/config-utils.ts (1)
  • GuildConfig (4-4)
web/src/components/dashboard/toggle-switch.tsx (1)
  • ToggleSwitch (14-32)

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +85 to +105
// 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
}
}
}
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.

sendModerationLog always does guild.members.fetch() for each flagged user before calling isProtectedTarget(). When moderation.protectRoles.enabled is explicitly set to false, this extra fetch work is unnecessary. Add an early guard to skip the protected-role fetch loop entirely when protection is disabled (e.g., check config.moderation?.protectRoles?.enabled === false before iterating).

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +121
protectRoles: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
roleIds: { type: 'array' },
includeAdmins: { type: 'boolean' },
includeModerators: { type: 'boolean' },
includeServerOwner: { type: 'boolean' },
},
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.

moderation.protectRoles.roleIds is validated only as type: 'array' here, so the API will accept arrays containing non-strings (numbers/objects), and validateValue() won’t catch it because items isn’t specified. Define items: { type: 'string' } for roleIds to ensure only role ID strings are accepted.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/modules/moderation.js`:
- Around line 447-450: The JSDoc for isProtectedTarget documents a removed
parameter named config; update the comment block for the function
isProtectedTarget in src/modules/moderation.js by removing the stale "@param
{Object} config - Bot configuration" line (or replace it with any current params
if needed) so the JSDoc matches the actual function signature and return
description; ensure the remaining `@param` entries (target, guild) and the
`@returns` line remain accurate.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f9f0912 and 5ae3984.

📒 Files selected for processing (4)
  • src/modules/moderation.js
  • src/modules/triage-respond.js
  • src/utils/modAction.js
  • tests/modules/moderation.test.js
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (4)
**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

**/*.js: Use ESM modules — use import/export, never require()
Always use node: protocol for Node.js builtins (e.g. import { readFileSync } from 'node:fs')
Always use semicolons in JavaScript code
Use single quotes for strings (enforced by Biome)
Use 2-space indentation (enforced by Biome)

Files:

  • src/utils/modAction.js
  • src/modules/moderation.js
  • src/modules/triage-respond.js
  • tests/modules/moderation.test.js
src/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

src/**/*.js: Always use Winston logger — import { info, warn, error } from '../logger.js'. NEVER use console.log, console.warn, console.error, or any console.* method in src/ files — replace any existing console calls with Winston equivalents
Pass structured metadata to Winston logs: info('Message processed', { userId, channelId })

Files:

  • src/utils/modAction.js
  • src/modules/moderation.js
  • src/modules/triage-respond.js
src/modules/*.js

📄 CodeRabbit inference engine (AGENTS.md)

Per-request modules (AI, spam, moderation) should call getConfig(guildId) on every invocation for automatic config changes. Stateful resources should use onConfigChange listeners for reactive updates

Files:

  • src/modules/moderation.js
  • src/modules/triage-respond.js
tests/**/*.js

📄 CodeRabbit inference engine (AGENTS.md)

All new code must include tests. Test coverage must maintain an 80% threshold on statements, branches, functions, and lines. Run pnpm test before every commit

Files:

  • tests/modules/moderation.test.js
🧠 Learnings (4)
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Applies to src/commands/*.js : Moderation commands must follow the shared pattern: `deferReply({ ephemeral: true })`, validate inputs, `sendDmNotification()`, execute Discord action, `createCase()`, `sendModLogEmbed()`, `checkEscalation()`

Applied to files:

  • src/utils/modAction.js
  • src/modules/triage-respond.js
  • tests/modules/moderation.test.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Always call `checkHierarchy(moderator, target)` before executing moderation actions to prevent moderating users with equal or higher roles

Applied to files:

  • src/utils/modAction.js
  • src/modules/moderation.js
  • src/modules/triage-respond.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Applies to src/modules/*.js : Per-request modules (AI, spam, moderation) should call `getConfig(guildId)` on every invocation for automatic config changes. Stateful resources should use `onConfigChange` listeners for reactive updates

Applied to files:

  • src/modules/moderation.js
  • src/modules/triage-respond.js
  • tests/modules/moderation.test.js
📚 Learning: 2026-03-01T06:03:34.399Z
Learnt from: CR
Repo: VolvoxLLC/volvox-bot PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-01T06:03:34.399Z
Learning: Use `splitMessage()` utility for messages exceeding Discord's 2000-character limit

Applied to files:

  • src/modules/triage-respond.js
🧬 Code graph analysis (4)
src/utils/modAction.js (3)
src/utils/safeSend.js (1)
  • safeEditReply (178-185)
src/modules/moderation.js (1)
  • isProtectedTarget (452-494)
src/logger.js (1)
  • warn (238-240)
src/modules/moderation.js (1)
src/modules/config.js (1)
  • getConfig (282-313)
src/modules/triage-respond.js (1)
src/modules/moderation.js (4)
  • guild (306-306)
  • guild (371-371)
  • member (307-307)
  • isProtectedTarget (452-494)
tests/modules/moderation.test.js (2)
src/modules/moderation.js (3)
  • guild (306-306)
  • guild (371-371)
  • isProtectedTarget (452-494)
src/modules/config.js (1)
  • getConfig (282-313)
🔇 Additional comments (3)
src/modules/triage-respond.js (1)

85-105: Protected-target triage guard is correctly applied.

Line 94–Line 99 correctly short-circuits moderation-log emission when a flagged target is protected, and Line 95 logs an auditable warning with metadata.

tests/modules/moderation.test.js (1)

577-742: Coverage for isProtectedTarget scenarios is strong.

The added cases exercise owner/admin/mod/custom-role and disabled/no-match paths, which is solid coverage for the new protection logic.

src/utils/modAction.js (1)

97-114: Self-moderation and protected-role guards are now correctly separated.

Line 98–Line 100 blocks self-moderation unconditionally, while Line 103–Line 114 keeps protected-role blocking behind skipProtection as intended.

Comment on lines +447 to +450
* @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
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove stale JSDoc param from isProtectedTarget.

Line 449 documents a config parameter that no longer exists in the function signature, which is misleading.

♻️ Suggested doc fix
 /**
  * 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
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/moderation.js` around lines 447 - 450, The JSDoc for
isProtectedTarget documents a removed parameter named config; update the comment
block for the function isProtectedTarget in src/modules/moderation.js by
removing the stale "@param {Object} config - Bot configuration" line (or replace
it with any current params if needed) so the JSDoc matches the actual function
signature and return description; ensure the remaining `@param` entries (target,
guild) and the `@returns` line remain accurate.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

20 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

* 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
Copy link

Choose a reason for hiding this comment

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

Remove this @param line - the function doesn't take a config parameter (it fetches config internally on line 454)

Suggested change
* @param {Object} config - Bot configuration
* @returns {boolean} True if the target should not be moderated
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/moderation.js
Line: 449

Comment:
Remove this `@param` line - the function doesn't take a `config` parameter (it fetches config internally on line 454)

```suggestion
 * @returns {boolean} True if the target should not be moderated
```

How can I resolve this? If you propose a fix, please make it concise.

Copilot AI review requested due to automatic review settings March 2, 2026 05:47
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +451 to +493
export function isProtectedTarget(target, guild) {
// Fetch config per-invocation so live config edits take effect immediately.
const config = getConfig(guild.id);
/**
* 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 };
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));
}
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.

isProtectedTarget() fetches a fresh config via getConfig(guild.id) on every call, but callers like executeModAction() and sendModerationLog() already have a guild config available. This adds extra deep-clone work in hot paths (and in triage it can happen inside a loop). Consider refactoring isProtectedTarget to accept an optional pre-fetched config (or a protectRoles+permissions snapshot) so callers can reuse the config they already loaded and avoid redundant getConfig() calls.

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +107
// Skip moderation log if any flagged user is a protected role (admin/mod/owner)
const guild = logChannel.guild;
if (guild && targets.length > 0) {
// Skip the expensive member-fetch loop when protection is explicitly disabled.
if (config.moderation?.protectRoles?.enabled !== false) {
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)) {
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
}
}
}
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.

Inside this protected-role loop, isProtectedTarget(member, guild) currently re-fetches config internally (via getConfig) for each member checked. Since sendModerationLog already receives config and may iterate multiple user IDs, this can cause repeated deep-clone/config work. Consider passing the existing config into isProtectedTarget (or adding a variant that accepts config) so the loop stays O(n) without extra config loads.

Copilot uses AI. Check for mistakes.
@brian-mwirigi brian-mwirigi force-pushed the fix/174-protect-privileged-roles branch from 0712e97 to 7fc24c3 Compare March 2, 2026 06:04
@BillChirico BillChirico merged commit 5d850c0 into VolvoxLLC:main Mar 2, 2026
3 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Bot moderates admins and moderators

4 participants