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: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ data/*
# Test and coverage outputs
coverage/

# Specs (tracked outside repo)
.specs/

# Verification scripts
verify-*.js
VERIFICATION_GUIDE.md

35 changes: 32 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
| `src/modules/chimeIn.js` | Organic conversation joining logic |
| `src/modules/welcome.js` | Dynamic welcome message generation |
| `src/modules/spam.js` | Spam/scam pattern detection |
| `src/modules/moderation.js` | Moderation — case creation, DM notifications, mod log embeds, escalation, tempban scheduler |
| `src/modules/config.js` | Config loading/saving (DB + file), runtime updates |
| `src/modules/events.js` | Event handler registration (wires modules to Discord events) |
| `src/utils/errors.js` | Error classes and handling utilities |
Expand All @@ -37,6 +38,7 @@
| `src/utils/retry.js` | Retry utility for flaky operations |
| `src/utils/registerCommands.js` | Discord REST API command registration |
| `src/utils/splitMessage.js` | Message splitting for Discord's 2000-char limit |
| `src/utils/duration.js` | Duration parsing — "1h", "7d" ↔ ms with human-readable formatting |
| `config.json` | Default configuration (seeded to DB on first run) |
| `.env.example` | Environment variable template |

Expand Down Expand Up @@ -87,9 +89,31 @@ export async function execute(interaction) {
}
```

2. Commands are auto-discovered from `src/commands/` on startup
3. Run `pnpm run deploy` to register with Discord (or restart the bot)
4. Add permission in `config.json` under `permissions.allowedCommands`
2. Export `adminOnly = true` for mod-only commands
3. Commands are auto-discovered from `src/commands/` on startup
4. Run `pnpm run deploy` to register with Discord (or restart the bot)
5. Add permission in `config.json` under `permissions.allowedCommands`

### Moderation Command Pattern

Moderation commands follow a shared pattern via `src/modules/moderation.js`:

1. `deferReply({ ephemeral: true })` — respond privately
2. Validate inputs (hierarchy check, target vs. moderator, etc.)
3. `sendDmNotification()` — DM the target (if enabled in config)
4. Execute the Discord action (ban, kick, timeout, etc.)
5. `createCase()` — record in `mod_cases` table
6. `sendModLogEmbed()` — post embed to the configured mod log channel
7. `checkEscalation()` — for warn commands, check auto-escalation thresholds

Duration-based commands (timeout, tempban, slowmode) use `parseDuration()` from `src/utils/duration.js`.

### Database Tables

| Table | Purpose |
|-------|---------|
| `mod_cases` | All moderation actions — warn, kick, ban, timeout, etc. One row per action per guild |
| `mod_scheduled_actions` | Scheduled operations (tempban expiry). Polled every 60s by the tempban scheduler |

## How to Add a Module

Expand Down Expand Up @@ -145,3 +169,8 @@ After every code change, check whether these files need updating:
4. **DATABASE_URL optional** — the bot works without a database (uses config.json only), but config persistence requires PostgreSQL
5. **Undici override** — `pnpm.overrides` pins undici; this was originally added for Node 18 compatibility and may no longer be needed on Node 22. Verify before removing
6. **2000-char limit** — Discord messages can't exceed 2000 characters; use `splitMessage()` utility
7. **DM before action** — moderation commands DM the target *before* executing kicks/bans; once a user is kicked/banned they can't receive DMs from the bot
8. **Hierarchy checks** — `checkHierarchy(moderator, target)` prevents moderating users with equal or higher roles; always call this before executing mod actions
9. **Duration caps** — Discord timeouts max at 28 days; slowmode caps at 6 hours (21600s). Both are enforced in command logic
10. **Tempban scheduler** — runs on a 60s interval; started in `index.js` startup and stopped in graceful shutdown. Catches up on missed unbans after restart
11. **Case numbering** — per-guild sequential and assigned atomically inside `createCase()` using `COALESCE(MAX(case_number), 0) + 1` in a single INSERT
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ AI-powered Discord bot for the [Volvox](https://volvox.dev) developer community.
- **🎯 Chime-In** — Bot can organically join conversations when it has something relevant to add (configurable per-channel).
- **👋 Dynamic Welcome Messages** — Contextual onboarding with time-of-day greetings, community activity snapshots, member milestones, and highlight channels.
- **🛡️ Spam Detection** — Pattern-based scam/spam detection with mod alerts and optional auto-delete.
- **⚔️ Moderation Suite** — Full-featured mod toolkit: warn, kick, ban, tempban, softban, timeout, purge, lock/unlock, slowmode. Includes case management, mod log routing, DM notifications, auto-escalation, and tempban scheduling.
- **⚙️ Config Management** — All settings stored in PostgreSQL with live `/config` slash command for runtime changes.
- **📊 Health Monitoring** — Built-in health checks and `/status` command for uptime, memory, and latency stats.
- **🎤 Voice Activity Tracking** — Tracks voice channel activity for community insights.
Expand Down Expand Up @@ -145,9 +146,24 @@ All configuration lives in `config.json` and can be updated at runtime via the `

| Key | Type | Description |
|-----|------|-------------|
| `enabled` | boolean | Enable spam detection |
| `enabled` | boolean | Enable moderation features |
| `alertChannelId` | string | Channel for mod alerts |
| `autoDelete` | boolean | Auto-delete detected spam |
| `dmNotifications.warn` | boolean | DM users when warned |
| `dmNotifications.timeout` | boolean | DM users when timed out |
| `dmNotifications.kick` | boolean | DM users when kicked |
| `dmNotifications.ban` | boolean | DM users when banned |
| `escalation.enabled` | boolean | Enable auto-escalation after repeated warns |
| `escalation.thresholds` | array | Escalation rules (see below) |
| `logging.channels.default` | string | Fallback mod log channel ID |
| `logging.channels.warns` | string | Channel for warn events |
| `logging.channels.bans` | string | Channel for ban/unban events |
| `logging.channels.kicks` | string | Channel for kick events |
| `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 |

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

### Permissions (`permissions`)

Expand All @@ -157,6 +173,60 @@ All configuration lives in `config.json` and can be updated at runtime via the `
| `adminRoleId` | string | Role ID for admin commands |
| `allowedCommands` | object | Per-command permission levels |

## ⚔️ Moderation Commands

All moderation commands require the admin role (configured via `permissions.adminRoleId`).

### Core Actions

| Command | Description |
|---------|-------------|
| `/warn <user> [reason]` | Issue a warning |
| `/kick <user> [reason]` | Remove from server |
| `/timeout <user> <duration> [reason]` | Temporarily mute (up to 28 days) |
| `/untimeout <user> [reason]` | Remove active timeout |
| `/ban <user> [reason] [delete_days]` | Permanent ban |
| `/tempban <user> <duration> [reason] [delete_days]` | Temporary ban with auto-unban |
| `/unban <user_id> [reason]` | Unban by user ID |
| `/softban <user> [reason] [delete_days]` | Ban + immediate unban (purges messages) |

### Message Management

| Command | Description |
|---------|-------------|
| `/purge all <count>` | Bulk delete messages (1–100) |
| `/purge user <user> <count>` | Delete messages from a specific user |
| `/purge bot <count>` | Delete bot messages only |
| `/purge contains <text> <count>` | Delete messages containing text |
| `/purge links <count>` | Delete messages with URLs |
| `/purge attachments <count>` | Delete messages with files/images |

### Case Management

| Command | Description |
|---------|-------------|
| `/case view <case_id>` | View a specific case |
| `/case list [user] [type]` | List recent cases with optional filters |
| `/case reason <case_id> <reason>` | Update a case's reason |
| `/case delete <case_id>` | Delete a case |
| `/history <user>` | View full mod history for a user |

### Channel Control

| Command | Description |
|---------|-------------|
| `/lock [channel] [reason]` | Prevent @everyone from sending messages |
| `/unlock [channel] [reason]` | Restore send permissions |
| `/slowmode <duration> [channel]` | Set channel slowmode (0 to disable) |

### Mod Log Configuration

| Command | Description |
|---------|-------------|
| `/modlog setup` | Interactive channel routing with select menus |
| `/modlog view` | View current log routing config |
| `/modlog disable` | Disable all mod logging |

## 🛠️ Development

### Scripts
Expand Down
9 changes: 1 addition & 8 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"files": {
"includes": [
"src/**/*.js",
"tests/**/*.js",
"!node_modules/**",
"!coverage/**",
"!logs/**",
"!data/**"
]
"includes": ["src/**/*.js", "tests/**/*.js", "!node_modules", "!coverage", "!logs", "!data"]
Comment on lines 2 to +4
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Biome 2.3.14 schema version release documentation

💡 Result:

Biome 2.3.14 configuration schema (biome.json / biome.jsonc)

Schema version to use (matches Biome 2.3.14):

{
  "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json"
}

This is shown in Biome’s “Getting started” guide (example config) for v2.3.14. [1]

Alternative (recommended to avoid hardcoding the URL version):

{
  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
}

Biome documents this local schema file option in the configuration reference. [2]


Biome 2.3.14 release documentation (what changed)

Biome publishes the 2.3.14 changelog entry here (“All versions since 2.3.14” → section 2.3.14). It lists the patch changes included in 2.3.14. [3]

Sources: [1] (biomejs.dev) [2] (biomejs.dev) [3] (main--biomejs.netlify.app)

Citations:


🌐 Web query:

Biome files.includes negation patterns exclude behavior

💡 Result:

In Biome, files.includes is an allowlist of glob patterns, with optional negated “exception” patterns to exclude matches.

  • Negation syntax: patterns starting with ! exclude files/folders that would otherwise be included. (biomejs.dev)
  • You must include ** first if you want negations to work globally (otherwise the negated patterns may match nothing). Example:
    { "files": { "includes": ["**", "!**/dist", "!**/*.generated.js"] } }
    (biomejs.dev)
  • Order matters: patterns are processed in order, so you can “exclude, then re-include” (exceptions to exceptions), e.g.:
    { "files": { "includes": ["**", "!**/*.test.js", "**/special.test.js", "!test"] } }
    (biomejs.dev)
  • files.includes is a hard gate: if a file doesn’t match files.includes, then tool-specific filters like linter.includes / formatter.includes cannot bring it back. (biomejs.dev)
  • Directory exclusion shorthand: !test (exclude a directory) is supported in files.includes; in tool-specific includes you typically need something like !/test/**. (biomejs.dev)
  • Force-ignore (!!) for the scanner/indexing: use !!pattern to ensure Biome never scans/indexes those paths (useful for build output dirs). (biomejs.dev)

Citations:


Remove redundant exclusion patterns from files.includes.

The schema version 2.3.14 is valid and confirmed in Biome's official documentation. However, the negation patterns !node_modules, !coverage, !logs, and !data are unnecessary. Since includes already restricts files to src/**/*.js and tests/**/*.js, those directories are never matched in the first place. These negation patterns have no effect and should be removed for clarity.

"files": {
-  "includes": ["src/**/*.js", "tests/**/*.js", "!node_modules", "!coverage", "!logs", "!data"]
+  "includes": ["src/**/*.js", "tests/**/*.js"]
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"files": {
"includes": [
"src/**/*.js",
"tests/**/*.js",
"!node_modules/**",
"!coverage/**",
"!logs/**",
"!data/**"
]
"includes": ["src/**/*.js", "tests/**/*.js", "!node_modules", "!coverage", "!logs", "!data"]
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"files": {
"includes": ["src/**/*.js", "tests/**/*.js"]
🤖 Prompt for AI Agents
In `@biome.json` around lines 2 - 4, Update the biome.json "files.includes" array
to remove the redundant negation patterns: delete "!node_modules", "!coverage",
"!logs", and "!data" from the includes entry so it only contains "src/**/*.js"
and "tests/**/*.js"; keep the "$schema" value unchanged and ensure the key name
"files.includes" is preserved.

},
"linter": {
"enabled": true,
Expand Down
43 changes: 41 additions & 2 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,31 @@
"moderation": {
"enabled": true,
"alertChannelId": "1438665401243275284",
"autoDelete": false
"autoDelete": false,
"dmNotifications": {
"warn": true,
"timeout": true,
"kick": true,
"ban": true
},
Comment on lines +36 to +41
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

dmNotifications is missing a softban key — softban DMs will be silently disabled.

The softban command calls shouldSendDm(config, 'softban') (see src/commands/softban.js Line 56), which checks config.moderation.dmNotifications.softban. Since this key is absent, the check returns false and no DM is sent before a softban. If this is intentional (grouping softban under ban or deliberately suppressing DMs), add an inline comment. Otherwise, add the key:

🐛 Proposed fix
     "dmNotifications": {
       "warn": true,
       "timeout": true,
       "kick": true,
-      "ban": true
+      "ban": true,
+      "softban": true
     },
🤖 Prompt for AI Agents
In `@config.json` around lines 36 - 41, The dmNotifications object is missing a
softban key so shouldSendDm(config, 'softban') will treat softban as false; add
a "softban": true (or false to intentionally disable) entry to the
dmNotifications block (e.g. mirror the "ban" value) or, if you intentionally
want softban DMs suppressed, add an inline comment next to the dmNotifications
block documenting that softban is intentionally omitted/handled via "ban" to
avoid silent behaviour; reference dmNotifications and the shouldSendDm/softban
check to locate the fix.

"escalation": {
"enabled": false,
"thresholds": [
{ "warns": 3, "withinDays": 7, "action": "timeout", "duration": "1h" },
{ "warns": 5, "withinDays": 30, "action": "ban" }
]
},
"logging": {
"channels": {
"default": null,
"warns": null,
"bans": null,
"kicks": null,
"timeouts": null,
"purges": null,
"locks": null
}
}
},
"logging": {
"level": "info",
Expand All @@ -44,7 +68,22 @@
"usePermissions": true,
"allowedCommands": {
"ping": "everyone",
"config": "admin"
"config": "admin",
"warn": "admin",
"kick": "admin",
"timeout": "admin",
"untimeout": "admin",
"ban": "admin",
"tempban": "admin",
"unban": "admin",
"softban": "admin",
"purge": "admin",
"case": "admin",
"history": "admin",
"lock": "admin",
"unlock": "admin",
"slowmode": "admin",
"modlog": "admin"
}
}
}
96 changes: 96 additions & 0 deletions src/commands/ban.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Ban Command
* Bans a user from the server and records a moderation case.
*/

import { SlashCommandBuilder } from 'discord.js';
import { info, error as logError } from '../logger.js';
import { getConfig } from '../modules/config.js';
import {
checkHierarchy,
createCase,
sendDmNotification,
sendModLogEmbed,
shouldSendDm,
} from '../modules/moderation.js';

export const data = new SlashCommandBuilder()
.setName('ban')
.setDescription('Ban a user from the server')
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) =>
opt.setName('reason').setDescription('Reason for ban').setRequired(false),
)
.addIntegerOption((opt) =>
opt
.setName('delete_messages')
.setDescription('Days of messages to delete (0-7)')
.setMinValue(0)
.setMaxValue(7)
.setRequired(false),
);

export const adminOnly = true;

/**
* Execute the ban command
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
export async function execute(interaction) {
try {
await interaction.deferReply({ ephemeral: true });

const config = getConfig();
const user = interaction.options.getUser('user');
const reason = interaction.options.getString('reason');
const deleteMessageDays = interaction.options.getInteger('delete_messages') || 0;

let member = null;
try {
member = await interaction.guild.members.fetch(user.id);
} catch {
// User not in guild — skip hierarchy check
}

if (member) {
const hierarchyError = checkHierarchy(
interaction.member,
member,
interaction.guild.members.me,
);
if (hierarchyError) {
return await interaction.editReply(hierarchyError);
}

if (shouldSendDm(config, 'ban')) {
await sendDmNotification(member, 'ban', reason, interaction.guild.name);
}
}

await interaction.guild.members.ban(user.id, {
deleteMessageSeconds: deleteMessageDays * 86400,
reason: reason || undefined,
});

const caseData = await createCase(interaction.guild.id, {
action: 'ban',
targetId: user.id,
targetTag: user.tag,
moderatorId: interaction.user.id,
moderatorTag: interaction.user.tag,
reason,
});

await sendModLogEmbed(interaction.client, config, caseData);

info('User banned', { target: user.tag, moderator: interaction.user.tag });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Structured log missing guildId — same as softban.

For log-filtering parity with other commands (e.g., purge includes guildId), consider adding it here as well.

♻️ Suggested fix
-    info('User banned', { target: user.tag, moderator: interaction.user.tag });
+    info('User banned', { guildId: interaction.guild.id, target: user.tag, moderator: interaction.user.tag });
🤖 Prompt for AI Agents
In `@src/commands/ban.js` at line 86, The structured log call in the info
invocation inside src/commands/ban.js (info('User banned', { target: user.tag,
moderator: interaction.user.tag })) is missing guildId; update that structured
payload to include the guild ID (use interaction.guild.id or
interaction.guild?.id to be safe) so the log entry includes { guildId: <guild
id>, target: user.tag, moderator: interaction.user.tag } for parity with other
commands like purge.

await interaction.editReply(
`✅ **${user.tag}** has been banned. (Case #${caseData.case_number})`,
);
} catch (err) {
logError('Command error', { error: err.message, command: 'ban' });
await interaction
.editReply('❌ An error occurred. Please try again or contact an administrator.')
.catch(() => {});
}
}
Loading
Loading