diff --git a/.gitignore b/.gitignore index 942c1b2d3..93f60939f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ data/* # Test and coverage outputs coverage/ +# Specs (tracked outside repo) +.specs/ + # Verification scripts verify-*.js VERIFICATION_GUIDE.md + diff --git a/AGENTS.md b/AGENTS.md index 9b61c9d4c..b6890cb08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | @@ -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 | @@ -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 @@ -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 diff --git a/README.md b/README.md index 774ec6ae6..011b058e1 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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`) @@ -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 [reason]` | Issue a warning | +| `/kick [reason]` | Remove from server | +| `/timeout [reason]` | Temporarily mute (up to 28 days) | +| `/untimeout [reason]` | Remove active timeout | +| `/ban [reason] [delete_days]` | Permanent ban | +| `/tempban [reason] [delete_days]` | Temporary ban with auto-unban | +| `/unban [reason]` | Unban by user ID | +| `/softban [reason] [delete_days]` | Ban + immediate unban (purges messages) | + +### Message Management + +| Command | Description | +|---------|-------------| +| `/purge all ` | Bulk delete messages (1–100) | +| `/purge user ` | Delete messages from a specific user | +| `/purge bot ` | Delete bot messages only | +| `/purge contains ` | Delete messages containing text | +| `/purge links ` | Delete messages with URLs | +| `/purge attachments ` | Delete messages with files/images | + +### Case Management + +| Command | Description | +|---------|-------------| +| `/case view ` | View a specific case | +| `/case list [user] [type]` | List recent cases with optional filters | +| `/case reason ` | Update a case's reason | +| `/case delete ` | Delete a case | +| `/history ` | 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 [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 diff --git a/biome.json b/biome.json index 21f3fd9a5..d9ab2f39c 100644 --- a/biome.json +++ b/biome.json @@ -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"] }, "linter": { "enabled": true, diff --git a/config.json b/config.json index 1c0bca0fc..6e42ab34d 100644 --- a/config.json +++ b/config.json @@ -32,7 +32,31 @@ "moderation": { "enabled": true, "alertChannelId": "1438665401243275284", - "autoDelete": false + "autoDelete": false, + "dmNotifications": { + "warn": true, + "timeout": true, + "kick": true, + "ban": true + }, + "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", @@ -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" } } } diff --git a/src/commands/ban.js b/src/commands/ban.js new file mode 100644 index 000000000..3827504eb --- /dev/null +++ b/src/commands/ban.js @@ -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 }); + 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(() => {}); + } +} diff --git a/src/commands/case.js b/src/commands/case.js new file mode 100644 index 000000000..c5e82a220 --- /dev/null +++ b/src/commands/case.js @@ -0,0 +1,279 @@ +/** + * Case Command + * View, list, update, and delete moderation cases + */ + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { ACTION_COLORS, ACTION_LOG_CHANNEL_KEY } from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('case') + .setDescription('Manage moderation cases') + .addSubcommand((sub) => + sub + .setName('view') + .setDescription('View a case by number') + .addIntegerOption((opt) => + opt.setName('case_id').setDescription('Case number').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List recent cases') + .addUserOption((opt) => + opt.setName('user').setDescription('Filter by user').setRequired(false), + ) + .addStringOption((opt) => + opt + .setName('type') + .setDescription('Filter by action type') + .setRequired(false) + .addChoices( + { name: 'warn', value: 'warn' }, + { name: 'kick', value: 'kick' }, + { name: 'timeout', value: 'timeout' }, + { name: 'untimeout', value: 'untimeout' }, + { name: 'ban', value: 'ban' }, + { name: 'tempban', value: 'tempban' }, + { name: 'unban', value: 'unban' }, + { name: 'softban', value: 'softban' }, + { name: 'lock', value: 'lock' }, + { name: 'unlock', value: 'unlock' }, + { name: 'purge', value: 'purge' }, + { name: 'slowmode', value: 'slowmode' }, + ), + ), + ) + .addSubcommand((sub) => + sub + .setName('reason') + .setDescription('Update case reason') + .addIntegerOption((opt) => + opt.setName('case_id').setDescription('Case number').setRequired(true), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('New reason').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('delete') + .setDescription('Delete a case') + .addIntegerOption((opt) => + opt.setName('case_id').setDescription('Case number').setRequired(true), + ), + ); + +export const adminOnly = true; + +/** + * Build an embed for a single case row. + * @param {Object} caseRow - Database row from mod_cases + * @returns {EmbedBuilder} + */ +function buildCaseEmbed(caseRow) { + return new EmbedBuilder() + .setColor(ACTION_COLORS[caseRow.action] || 0x5865f2) + .setTitle(`Case #${caseRow.case_number} — ${caseRow.action.toUpperCase()}`) + .addFields( + { name: 'Target', value: `<@${caseRow.target_id}> (${caseRow.target_tag})`, inline: true }, + { + name: 'Moderator', + value: `<@${caseRow.moderator_id}> (${caseRow.moderator_tag})`, + inline: true, + }, + { name: 'Reason', value: caseRow.reason || 'No reason provided' }, + ) + .setTimestamp(new Date(caseRow.created_at)); +} + +/** + * Execute the case command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const subcommand = interaction.options.getSubcommand(); + + try { + switch (subcommand) { + case 'view': + await handleView(interaction); + break; + case 'list': + await handleList(interaction); + break; + case 'reason': + await handleReason(interaction); + break; + case 'delete': + await handleDelete(interaction); + break; + } + } catch (err) { + logError('Case command failed', { error: err.message, subcommand }); + await interaction.editReply('Failed to execute case command.'); + } +} + +/** + * Handle /case view + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleView(interaction) { + const caseId = interaction.options.getInteger('case_id'); + const pool = getPool(); + + const { rows } = await pool.query( + 'SELECT * FROM mod_cases WHERE guild_id = $1 AND case_number = $2', + [interaction.guild.id, caseId], + ); + + if (rows.length === 0) { + return await interaction.editReply(`Case #${caseId} not found.`); + } + + const embed = buildCaseEmbed(rows[0]); + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Handle /case list + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleList(interaction) { + const user = interaction.options.getUser('user'); + const type = interaction.options.getString('type'); + const pool = getPool(); + + let query = 'SELECT * FROM mod_cases WHERE guild_id = $1'; + const params = [interaction.guild.id]; + let paramIndex = 2; + + if (user) { + query += ` AND target_id = $${paramIndex}`; + params.push(user.id); + paramIndex++; + } + + if (type) { + query += ` AND action = $${paramIndex}`; + params.push(type); + paramIndex++; + } + + query += ' ORDER BY created_at DESC LIMIT 10'; + + const { rows } = await pool.query(query, params); + + if (rows.length === 0) { + return await interaction.editReply('No cases found matching the criteria.'); + } + + const lines = rows.map((row) => { + const timestamp = Math.floor(new Date(row.created_at).getTime() / 1000); + const reason = row.reason + ? row.reason.length > 50 + ? `${row.reason.slice(0, 47)}...` + : row.reason + : 'No reason'; + return `**#${row.case_number}** — ${row.action.toUpperCase()} — <@${row.target_id}> — — ${reason}`; + }); + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('Moderation Cases') + .setDescription(lines.join('\n')) + .setFooter({ text: `Showing ${rows.length} case(s)` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Handle /case reason + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleReason(interaction) { + const caseId = interaction.options.getInteger('case_id'); + const reason = interaction.options.getString('reason'); + const pool = getPool(); + + const { rows } = await pool.query( + 'UPDATE mod_cases SET reason = $1 WHERE guild_id = $2 AND case_number = $3 RETURNING *', + [reason, interaction.guild.id, caseId], + ); + + if (rows.length === 0) { + return await interaction.editReply(`Case #${caseId} not found.`); + } + + const caseRow = rows[0]; + + // Try to edit the log message if it exists + if (caseRow.log_message_id) { + try { + const config = getConfig(); + const channels = config.moderation?.logging?.channels; + if (channels) { + const channelKey = ACTION_LOG_CHANNEL_KEY[caseRow.action]; + const logChannelId = channels[channelKey] || channels.default; + if (logChannelId) { + const logChannel = await interaction.client.channels + .fetch(logChannelId) + .catch(() => null); + if (logChannel) { + const logMessage = await logChannel.messages + .fetch(caseRow.log_message_id) + .catch(() => null); + if (logMessage) { + const embed = buildCaseEmbed(caseRow); + await logMessage.edit({ embeds: [embed] }); + } + } + } + } + } catch (err) { + logError('Failed to edit log message', { error: err.message, caseId }); + } + } + + info('Case reason updated', { + guildId: interaction.guild.id, + caseNumber: caseId, + moderator: interaction.user.tag, + }); + + await interaction.editReply(`Updated reason for case #${caseId}.`); +} + +/** + * Handle /case delete + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleDelete(interaction) { + const caseId = interaction.options.getInteger('case_id'); + const pool = getPool(); + + const { rows } = await pool.query( + 'DELETE FROM mod_cases WHERE guild_id = $1 AND case_number = $2 RETURNING *', + [interaction.guild.id, caseId], + ); + + if (rows.length === 0) { + return await interaction.editReply(`Case #${caseId} not found.`); + } + + info('Case deleted', { + guildId: interaction.guild.id, + caseNumber: caseId, + moderator: interaction.user.tag, + }); + + await interaction.editReply(`Deleted case #${caseId} (${rows[0].action}).`); +} diff --git a/src/commands/history.js b/src/commands/history.js new file mode 100644 index 000000000..8c55903ba --- /dev/null +++ b/src/commands/history.js @@ -0,0 +1,76 @@ +/** + * History Command + * View moderation history for a user + */ + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, error as logError } from '../logger.js'; + +export const data = new SlashCommandBuilder() + .setName('history') + .setDescription('View moderation history for a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)); + +export const adminOnly = true; + +/** + * Execute the history command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const user = interaction.options.getUser('user'); + const pool = getPool(); + const { rows } = await pool.query( + 'SELECT * FROM mod_cases WHERE guild_id = $1 AND target_id = $2 ORDER BY created_at DESC LIMIT 25', + [interaction.guild.id, user.id], + ); + + if (rows.length === 0) { + return await interaction.editReply(`No moderation history found for ${user.tag}.`); + } + + const lines = rows.map((row) => { + const timestamp = Math.floor(new Date(row.created_at).getTime() / 1000); + const reason = row.reason + ? row.reason.length > 40 + ? `${row.reason.slice(0, 37)}...` + : row.reason + : 'No reason'; + return `**#${row.case_number}** — ${row.action.toUpperCase()} — — ${reason}`; + }); + + // Count actions by type + const counts = {}; + for (const row of rows) { + counts[row.action] = (counts[row.action] || 0) + 1; + } + const summary = Object.entries(counts) + .map(([action, count]) => `${action}: ${count}`) + .join(' | '); + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`Moderation History — ${user.tag}`) + .setDescription(lines.join('\n')) + .addFields({ name: 'Summary', value: summary }) + .setFooter({ text: `${rows.length} case(s) shown (max 25)` }) + .setThumbnail(user.displayAvatarURL()) + .setTimestamp(); + + info('History viewed', { + guildId: interaction.guild.id, + target: user.tag, + moderator: interaction.user.tag, + caseCount: rows.length, + }); + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + logError('Command error', { error: err.message, command: 'history' }); + await interaction.editReply('❌ Failed to fetch moderation history.'); + } +} diff --git a/src/commands/kick.js b/src/commands/kick.js new file mode 100644 index 000000000..929615138 --- /dev/null +++ b/src/commands/kick.js @@ -0,0 +1,74 @@ +/** + * Kick Command + * Kicks 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('kick') + .setDescription('Kick a user from the server') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for kick').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the kick command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const config = getConfig(); + const target = interaction.options.getMember('user'); + if (!target) { + return await interaction.editReply('❌ User is not in this server.'); + } + const reason = interaction.options.getString('reason'); + + const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); + if (hierarchyError) { + return await interaction.editReply(hierarchyError); + } + + if (shouldSendDm(config, 'kick')) { + await sendDmNotification(target, 'kick', reason, interaction.guild.name); + } + + await target.kick(reason || undefined); + + const caseData = await createCase(interaction.guild.id, { + action: 'kick', + targetId: target.id, + targetTag: target.user.tag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + info('User kicked', { target: target.user.tag, moderator: interaction.user.tag }); + await interaction.editReply( + `✅ **${target.user.tag}** has been kicked. (Case #${caseData.case_number})`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'kick' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/lock.js b/src/commands/lock.js new file mode 100644 index 000000000..40cf97675 --- /dev/null +++ b/src/commands/lock.js @@ -0,0 +1,73 @@ +/** + * Lock Command + * Lock a channel to prevent messages from @everyone + */ + +import { ChannelType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { createCase, sendModLogEmbed } from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('lock') + .setDescription('Lock a channel to prevent messages') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to lock (defaults to current)') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for locking').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the lock command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const channel = interaction.options.getChannel('channel') || interaction.channel; + const reason = interaction.options.getString('reason'); + + if (channel.type !== ChannelType.GuildText) { + return await interaction.editReply('❌ Lock can only be used in text channels.'); + } + + await channel.permissionOverwrites.edit(interaction.guild.roles.everyone, { + SendMessages: false, + }); + + const notifyEmbed = new EmbedBuilder() + .setColor(0xe67e22) + .setDescription( + `🔒 This channel has been locked by ${interaction.user}${reason ? `\n**Reason:** ${reason}` : ''}`, + ) + .setTimestamp(); + await channel.send({ embeds: [notifyEmbed] }); + + const config = getConfig(); + const caseData = await createCase(interaction.guild.id, { + action: 'lock', + targetId: channel.id, + targetTag: `#${channel.name}`, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + await sendModLogEmbed(interaction.client, config, caseData); + + info('Channel locked', { channelId: channel.id, moderator: interaction.user.tag }); + await interaction.editReply(`✅ ${channel} has been locked.`); + } catch (err) { + logError('Lock command failed', { error: err.message, command: 'lock' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/modlog.js b/src/commands/modlog.js new file mode 100644 index 000000000..c0538c751 --- /dev/null +++ b/src/commands/modlog.js @@ -0,0 +1,212 @@ +/** + * Modlog Command + * Configure moderation logging channel routing + */ + +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelSelectMenuBuilder, + ChannelType, + EmbedBuilder, + SlashCommandBuilder, + StringSelectMenuBuilder, +} from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig, setConfigValue } from '../modules/config.js'; + +export const data = new SlashCommandBuilder() + .setName('modlog') + .setDescription('Configure moderation logging') + .addSubcommand((sub) => sub.setName('setup').setDescription('Configure log channel routing')) + .addSubcommand((sub) => sub.setName('view').setDescription('View current log routing')) + .addSubcommand((sub) => sub.setName('disable').setDescription('Disable mod logging')); + +export const adminOnly = true; + +/** + * Execute the modlog command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'setup': + await handleSetup(interaction); + break; + case 'view': + await handleView(interaction); + break; + case 'disable': + await handleDisable(interaction); + break; + default: + logError('Unknown modlog subcommand', { subcommand, command: 'modlog' }); + await interaction + .reply({ content: '❌ Unknown subcommand.', ephemeral: true }) + .catch(() => {}); + } +} + +/** + * Handle /modlog setup — interactive channel routing configuration + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleSetup(interaction) { + const categorySelect = new StringSelectMenuBuilder() + .setCustomId('modlog_category') + .setPlaceholder('Select an event category to configure') + .addOptions( + { + label: 'Default (fallback)', + value: 'default', + description: 'Fallback channel for unconfigured events', + }, + { label: 'Warns', value: 'warns', description: 'Warning events' }, + { label: 'Bans', value: 'bans', description: 'Ban/unban/tempban events' }, + { label: 'Kicks', value: 'kicks', description: 'Kick events' }, + { label: 'Timeouts', value: 'timeouts', description: 'Timeout events' }, + { label: 'Purges', value: 'purges', description: 'Message purge events' }, + { label: 'Locks', value: 'locks', description: 'Channel lock/unlock events' }, + ); + + const row = new ActionRowBuilder().addComponents(categorySelect); + const doneRow = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('modlog_done').setLabel('Done').setStyle(ButtonStyle.Success), + ); + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('📋 Mod Log Setup') + .setDescription('Select an event category to configure its log channel.') + .setTimestamp(); + + const reply = await interaction.reply({ + embeds: [embed], + components: [row, doneRow], + ephemeral: true, + fetchReply: true, + }); + + const collector = reply.createMessageComponentCollector({ + filter: (i) => i.user.id === interaction.user.id, + time: 300000, + }); + + let selectedCategory = null; + + collector.on('collect', async (i) => { + try { + if (i.customId === 'modlog_done') { + await i.update({ + components: [], + embeds: [embed.setDescription('✅ Mod log setup complete.')], + }); + collector.stop(); + return; + } + + if (i.customId === 'modlog_category') { + selectedCategory = i.values[0]; + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('modlog_channel') + .setPlaceholder(`Select channel for ${selectedCategory}`) + .setChannelTypes(ChannelType.GuildText); + const channelRow = new ActionRowBuilder().addComponents(channelSelect); + await i.update({ + embeds: [embed.setDescription(`Select a channel for **${selectedCategory}** events.`)], + components: [channelRow, doneRow], + }); + return; + } + + if (i.customId === 'modlog_channel' && selectedCategory) { + const channelId = i.values[0]; + await setConfigValue(`moderation.logging.channels.${selectedCategory}`, channelId); + info('Modlog channel configured', { category: selectedCategory, channelId }); + await i.update({ + embeds: [ + embed.setDescription( + `✅ **${selectedCategory}** → <#${channelId}>\n\nSelect another category or click Done.`, + ), + ], + components: [row, doneRow], + }); + selectedCategory = null; + } + } catch (err) { + logError('Modlog setup interaction failed', { + error: err.message, + customId: i.customId, + command: 'modlog', + }); + await i + .reply({ + content: '❌ Failed to update modlog configuration. Please try again.', + ephemeral: true, + }) + .catch(() => {}); + } + }); + + collector.on('end', async (_, reason) => { + if (reason === 'time') { + await interaction + .editReply({ components: [], embeds: [embed.setDescription('⏰ Setup timed out.')] }) + .catch(() => {}); + } + }); +} + +/** + * Handle /modlog view — display current log routing configuration + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleView(interaction) { + try { + const config = getConfig(); + const channels = config.moderation?.logging?.channels || {}; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('📋 Mod Log Configuration') + .setTimestamp(); + + const lines = Object.entries(channels).map( + ([key, value]) => `**${key}:** ${value ? `<#${value}>` : '*Not set*'}`, + ); + embed.setDescription(lines.join('\n') || 'No channels configured.'); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } catch (err) { + logError('Modlog view failed', { error: err.message, command: 'modlog' }); + await interaction + .reply({ content: '❌ Failed to load mod log configuration.', ephemeral: true }) + .catch(() => {}); + } +} + +/** + * Handle /modlog disable — clear all log channel routing + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleDisable(interaction) { + await interaction.deferReply({ ephemeral: true }); + + try { + const keys = ['default', 'warns', 'bans', 'kicks', 'timeouts', 'purges', 'locks']; + for (const key of keys) { + await setConfigValue(`moderation.logging.channels.${key}`, null); + } + + info('Mod logging disabled', { moderator: interaction.user.tag }); + await interaction.editReply( + '✅ Mod logging has been disabled. All log channels have been cleared.', + ); + } catch (err) { + logError('Modlog disable failed', { error: err.message, command: 'modlog' }); + await interaction.editReply('❌ Failed to disable mod logging.').catch(() => {}); + } +} diff --git a/src/commands/purge.js b/src/commands/purge.js new file mode 100644 index 000000000..819bae1ef --- /dev/null +++ b/src/commands/purge.js @@ -0,0 +1,175 @@ +/** + * Purge Command + * Bulk delete messages with filtering subcommands + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { createCase, sendModLogEmbed } from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('purge') + .setDescription('Bulk delete messages') + .addSubcommand((sub) => + sub + .setName('all') + .setDescription('Delete recent messages') + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription('Number of recent messages to scan (1-100)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100), + ), + ) + .addSubcommand((sub) => + sub + .setName('user') + .setDescription('Delete messages from a specific user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription('Messages to scan (1-100, deletions may be fewer after filtering)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100), + ), + ) + .addSubcommand((sub) => + sub + .setName('bot') + .setDescription('Delete messages from bots') + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription('Messages to scan (1-100, deletions may be fewer after filtering)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100), + ), + ) + .addSubcommand((sub) => + sub + .setName('contains') + .setDescription('Delete messages containing text') + .addStringOption((opt) => + opt.setName('text').setDescription('Text to search for').setRequired(true), + ) + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription('Messages to scan (1-100, deletions may be fewer after filtering)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100), + ), + ) + .addSubcommand((sub) => + sub + .setName('links') + .setDescription('Delete messages containing links') + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription('Messages to scan (1-100, deletions may be fewer after filtering)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100), + ), + ) + .addSubcommand((sub) => + sub + .setName('attachments') + .setDescription('Delete messages with attachments') + .addIntegerOption((opt) => + opt + .setName('count') + .setDescription('Messages to scan (1-100, deletions may be fewer after filtering)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100), + ), + ); + +export const adminOnly = true; + +/** + * Execute the purge command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const subcommand = interaction.options.getSubcommand(); + const count = interaction.options.getInteger('count'); + const channel = interaction.channel; + const fetched = await channel.messages.fetch({ limit: count }); + + // Filter out messages older than 14 days (Discord bulk delete limit) + const fourteenDaysAgo = Date.now() - 14 * 86400 * 1000; + let filtered = fetched.filter((m) => m.createdTimestamp > fourteenDaysAgo); + + let filterDetail = subcommand; + + switch (subcommand) { + case 'user': { + const user = interaction.options.getUser('user'); + filtered = filtered.filter((m) => m.author.id === user.id); + filterDetail = `user:${user.id}`; + break; + } + case 'bot': + filtered = filtered.filter((m) => m.author.bot); + break; + case 'contains': { + const text = interaction.options.getString('text').toLowerCase(); + filtered = filtered.filter((m) => m.content.toLowerCase().includes(text)); + filterDetail = `contains:${text}`; + break; + } + case 'links': + filtered = filtered.filter((m) => /https?:\/\/\S+/i.test(m.content)); + break; + case 'attachments': + filtered = filtered.filter((m) => m.attachments.size > 0); + break; + // 'all' — no additional filter needed + } + + const deleted = await channel.bulkDelete(filtered, true); + + info('Purge executed', { + guildId: interaction.guild.id, + channelId: channel.id, + moderator: interaction.user.tag, + subcommand, + deleted: deleted.size, + scanned: fetched.size, + }); + + const config = getConfig(); + const caseData = await createCase(interaction.guild.id, { + action: 'purge', + targetId: channel.id, + targetTag: `#${channel.name}`, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason: `${deleted.size} message(s) deleted | filter=${filterDetail} | scanned=${fetched.size}`, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + await interaction.editReply( + `Deleted **${deleted.size}** message(s) from **${fetched.size}** scanned message(s).`, + ); + } catch (err) { + logError('Purge command failed', { error: err.message, command: 'purge' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/slowmode.js b/src/commands/slowmode.js new file mode 100644 index 000000000..e810cb9dd --- /dev/null +++ b/src/commands/slowmode.js @@ -0,0 +1,97 @@ +/** + * Slowmode Command + * Set channel slowmode duration + */ + +import { ChannelType, SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { createCase, sendModLogEmbed } from '../modules/moderation.js'; +import { formatDuration, parseDuration } from '../utils/duration.js'; + +export const data = new SlashCommandBuilder() + .setName('slowmode') + .setDescription('Set channel slowmode') + .addStringOption((opt) => + opt + .setName('duration') + .setDescription('Slowmode duration (0 to disable, e.g., 5s, 1m, 1h)') + .setRequired(true), + ) + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel (defaults to current)') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for changing slowmode').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the slowmode command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const channel = interaction.options.getChannel('channel') || interaction.channel; + const durationStr = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + + let seconds = 0; + + if (durationStr !== '0') { + const ms = parseDuration(durationStr); + if (!ms) { + return await interaction.editReply( + '❌ Invalid duration format. Use formats like: 5s, 1m, 1h', + ); + } + + if (ms > 6 * 60 * 60 * 1000) { + return await interaction.editReply('❌ Duration cannot exceed 6 hours.'); + } + + seconds = Math.floor(ms / 1000); + } + + await channel.setRateLimitPerUser(seconds); + + const config = getConfig(); + const caseData = await createCase(interaction.guild.id, { + action: 'slowmode', + targetId: channel.id, + targetTag: `#${channel.name}`, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason: + reason || + (seconds === 0 ? 'Slowmode disabled' : `Slowmode set to ${formatDuration(seconds * 1000)}`), + duration: seconds > 0 ? formatDuration(seconds * 1000) : null, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + if (seconds === 0) { + info('Slowmode disabled', { channelId: channel.id, moderator: interaction.user.tag }); + await interaction.editReply( + `✅ Slowmode disabled in ${channel}. (Case #${caseData.case_number})`, + ); + } else { + info('Slowmode set', { channelId: channel.id, seconds, moderator: interaction.user.tag }); + await interaction.editReply( + `✅ Slowmode set to **${formatDuration(seconds * 1000)}** in ${channel}. (Case #${caseData.case_number})`, + ); + } + } catch (err) { + logError('Slowmode command failed', { error: err.message, command: 'slowmode' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/softban.js b/src/commands/softban.js new file mode 100644 index 000000000..342e6be18 --- /dev/null +++ b/src/commands/softban.js @@ -0,0 +1,113 @@ +/** + * Softban Command + * Bans and immediately unbans a user to delete their messages. + */ + +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('softban') + .setDescription('Ban and immediately unban a user to delete their messages') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for softban').setRequired(false), + ) + .addIntegerOption((opt) => + opt + .setName('delete_messages') + .setDescription('Days of messages to delete (0-7, default 7)') + .setMinValue(0) + .setMaxValue(7) + .setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the softban command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const config = getConfig(); + const target = interaction.options.getMember('user'); + if (!target) { + return await interaction.editReply('❌ User is not in this server.'); + } + const reason = interaction.options.getString('reason'); + const deleteMessageDays = interaction.options.getInteger('delete_messages') ?? 7; + + const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); + if (hierarchyError) { + return await interaction.editReply(hierarchyError); + } + + if (shouldSendDm(config, 'softban')) { + await sendDmNotification(target, 'softban', reason, interaction.guild.name); + } + + await interaction.guild.members.ban(target.id, { + deleteMessageSeconds: deleteMessageDays * 86400, + reason: reason || undefined, + }); + + let unbanError = null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await interaction.guild.members.unban(target.id, 'Softban'); + unbanError = null; + break; + } catch (err) { + unbanError = err; + logError('Softban unban attempt failed', { + error: err.message, + targetId: target.id, + attempt, + command: 'softban', + }); + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 250 * attempt)); + } + } + } + + const caseData = await createCase(interaction.guild.id, { + action: 'softban', + targetId: target.id, + targetTag: target.user.tag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + info('User softbanned', { target: target.user.tag, moderator: interaction.user.tag }); + + if (unbanError) { + await interaction.editReply( + `⚠️ **${target.user.tag}** was banned but the unban failed — they remain banned. Please manually unban. (Case #${caseData.case_number})`, + ); + } else { + await interaction.editReply( + `✅ **${target.user.tag}** has been soft-banned. (Case #${caseData.case_number})`, + ); + } + } catch (err) { + logError('Command error', { error: err.message, command: 'softban' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/tempban.js b/src/commands/tempban.js new file mode 100644 index 000000000..522ff8adf --- /dev/null +++ b/src/commands/tempban.js @@ -0,0 +1,117 @@ +/** + * Tempban Command + * Temporarily bans a user and schedules an automatic unban. + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { + checkHierarchy, + createCase, + scheduleAction, + sendDmNotification, + sendModLogEmbed, + shouldSendDm, +} from '../modules/moderation.js'; +import { formatDuration, parseDuration } from '../utils/duration.js'; + +export const data = new SlashCommandBuilder() + .setName('tempban') + .setDescription('Temporarily ban a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('duration').setDescription('Duration (e.g. 1d, 7d, 2w)').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 tempban 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 durationStr = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + const deleteMessageDays = interaction.options.getInteger('delete_messages') || 0; + + const durationMs = parseDuration(durationStr); + if (!durationMs) { + return await interaction.editReply('❌ Invalid duration format. Use e.g. 1d, 7d, 2w.'); + } + + 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, 'tempban', reason, interaction.guild.name); + } + } + + const expiresAt = new Date(Date.now() + durationMs); + + await interaction.guild.members.ban(user.id, { + deleteMessageSeconds: deleteMessageDays * 86400, + reason: reason || undefined, + }); + + const caseData = await createCase(interaction.guild.id, { + action: 'tempban', + targetId: user.id, + targetTag: user.tag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + duration: formatDuration(durationMs), + expiresAt, + }); + + await scheduleAction(interaction.guild.id, 'unban', user.id, caseData.id, expiresAt); + + await sendModLogEmbed(interaction.client, config, caseData); + + info('User tempbanned', { + target: user.tag, + moderator: interaction.user.tag, + duration: durationStr, + }); + await interaction.editReply( + `✅ **${user.tag}** has been temporarily banned. (Case #${caseData.case_number})`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'tempban' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/timeout.js b/src/commands/timeout.js new file mode 100644 index 000000000..68f53ca47 --- /dev/null +++ b/src/commands/timeout.js @@ -0,0 +1,95 @@ +/** + * Timeout Command + * Times out a user for a specified duration 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'; +import { formatDuration, parseDuration } from '../utils/duration.js'; + +export const data = new SlashCommandBuilder() + .setName('timeout') + .setDescription('Timeout a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('duration').setDescription('Duration (e.g. 30m, 1h, 7d)').setRequired(true), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for timeout').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the timeout command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const config = getConfig(); + const target = interaction.options.getMember('user'); + if (!target) { + return await interaction.editReply('❌ User is not in this server.'); + } + const durationStr = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + + const durationMs = parseDuration(durationStr); + if (!durationMs) { + return await interaction.editReply('❌ Invalid duration format. Use e.g. 30m, 1h, 7d.'); + } + + const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000; + if (durationMs > MAX_TIMEOUT_MS) { + return await interaction.editReply('❌ Timeout duration cannot exceed 28 days.'); + } + + const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); + if (hierarchyError) { + return await interaction.editReply(hierarchyError); + } + + if (shouldSendDm(config, 'timeout')) { + await sendDmNotification(target, 'timeout', reason, interaction.guild.name); + } + + await target.timeout(durationMs, reason || undefined); + + const caseData = await createCase(interaction.guild.id, { + action: 'timeout', + targetId: target.id, + targetTag: target.user.tag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + duration: formatDuration(durationMs), + expiresAt: new Date(Date.now() + durationMs), + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + info('User timed out', { + target: target.user.tag, + moderator: interaction.user.tag, + duration: durationStr, + }); + await interaction.editReply( + `✅ **${target.user.tag}** has been timed out. (Case #${caseData.case_number})`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'timeout' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/unban.js b/src/commands/unban.js new file mode 100644 index 000000000..70f23cfa5 --- /dev/null +++ b/src/commands/unban.js @@ -0,0 +1,65 @@ +/** + * Unban Command + * Unbans 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 { createCase, sendModLogEmbed } from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('unban') + .setDescription('Unban a user from the server') + .addStringOption((opt) => + opt.setName('user_id').setDescription('User ID to unban').setRequired(true), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for unban').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the unban command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + const config = getConfig(); + const userId = interaction.options.getString('user_id'); + const reason = interaction.options.getString('reason'); + + await interaction.guild.members.unban(userId, reason || undefined); + + let targetTag = userId; + try { + const fetchedUser = await interaction.client.users.fetch(userId); + targetTag = fetchedUser.tag; + } catch { + // User no longer resolvable — keep raw ID + } + + const caseData = await createCase(interaction.guild.id, { + action: 'unban', + targetId: userId, + targetTag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + info('User unbanned', { target: userId, moderator: interaction.user.tag }); + await interaction.editReply( + `✅ **${userId}** has been unbanned. (Case #${caseData.case_number})`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'unban' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/unlock.js b/src/commands/unlock.js new file mode 100644 index 000000000..cab7403f9 --- /dev/null +++ b/src/commands/unlock.js @@ -0,0 +1,73 @@ +/** + * Unlock Command + * Unlock a channel to restore messaging permissions + */ + +import { ChannelType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { info, error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { createCase, sendModLogEmbed } from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('unlock') + .setDescription('Unlock a channel to restore messages') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to unlock (defaults to current)') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for unlocking').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the unlock command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const channel = interaction.options.getChannel('channel') || interaction.channel; + const reason = interaction.options.getString('reason'); + + if (channel.type !== ChannelType.GuildText) { + return await interaction.editReply('❌ Unlock can only be used in text channels.'); + } + + await channel.permissionOverwrites.edit(interaction.guild.roles.everyone, { + SendMessages: null, + }); + + const notifyEmbed = new EmbedBuilder() + .setColor(0x57f287) + .setDescription( + `🔓 This channel has been unlocked by ${interaction.user}${reason ? `\n**Reason:** ${reason}` : ''}`, + ) + .setTimestamp(); + await channel.send({ embeds: [notifyEmbed] }); + + const config = getConfig(); + const caseData = await createCase(interaction.guild.id, { + action: 'unlock', + targetId: channel.id, + targetTag: `#${channel.name}`, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + await sendModLogEmbed(interaction.client, config, caseData); + + info('Channel unlocked', { channelId: channel.id, moderator: interaction.user.tag }); + await interaction.editReply(`✅ ${channel} has been unlocked.`); + } catch (err) { + logError('Unlock command failed', { error: err.message, command: 'unlock' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/untimeout.js b/src/commands/untimeout.js new file mode 100644 index 000000000..51310e52f --- /dev/null +++ b/src/commands/untimeout.js @@ -0,0 +1,64 @@ +/** + * Untimeout Command + * Removes a timeout from a user 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, sendModLogEmbed } from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('untimeout') + .setDescription('Remove a timeout from a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for removing timeout').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the untimeout command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const config = getConfig(); + const target = interaction.options.getMember('user'); + if (!target) { + return await interaction.editReply('❌ User is not in this server.'); + } + const reason = interaction.options.getString('reason'); + + const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); + if (hierarchyError) { + return await interaction.editReply(hierarchyError); + } + + await target.timeout(null, reason || undefined); + + const caseData = await createCase(interaction.guild.id, { + action: 'untimeout', + targetId: target.id, + targetTag: target.user.tag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + info('User timeout removed', { target: target.user.tag, moderator: interaction.user.tag }); + await interaction.editReply( + `✅ **${target.user.tag}** has had their timeout removed. (Case #${caseData.case_number})`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'untimeout' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/commands/warn.js b/src/commands/warn.js new file mode 100644 index 000000000..2ce381bca --- /dev/null +++ b/src/commands/warn.js @@ -0,0 +1,82 @@ +/** + * Warn Command + * Issues a warning to a user 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 { + checkEscalation, + checkHierarchy, + createCase, + sendDmNotification, + sendModLogEmbed, + shouldSendDm, +} from '../modules/moderation.js'; + +export const data = new SlashCommandBuilder() + .setName('warn') + .setDescription('Warn a user') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for warning').setRequired(false), + ); + +export const adminOnly = true; + +/** + * Execute the warn command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + try { + await interaction.deferReply({ ephemeral: true }); + + const config = getConfig(); + const target = interaction.options.getMember('user'); + if (!target) { + return await interaction.editReply('❌ User is not in this server.'); + } + const reason = interaction.options.getString('reason'); + + const hierarchyError = checkHierarchy(interaction.member, target, interaction.guild.members.me); + if (hierarchyError) { + return await interaction.editReply(hierarchyError); + } + + if (shouldSendDm(config, 'warn')) { + await sendDmNotification(target, 'warn', reason, interaction.guild.name); + } + + const caseData = await createCase(interaction.guild.id, { + action: 'warn', + targetId: target.id, + targetTag: target.user.tag, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + reason, + }); + + await sendModLogEmbed(interaction.client, config, caseData); + + await checkEscalation( + interaction.client, + interaction.guild.id, + target.id, + interaction.client.user.id, + interaction.client.user.tag, + config, + ); + + info('User warned', { target: target.user.tag, moderator: interaction.user.tag }); + await interaction.editReply( + `✅ **${target.user.tag}** has been warned. (Case #${caseData.case_number})`, + ); + } catch (err) { + logError('Command error', { error: err.message, command: 'warn' }); + await interaction + .editReply('❌ An error occurred. Please try again or contact an administrator.') + .catch(() => {}); + } +} diff --git a/src/db.js b/src/db.js index 7551e9481..77cc7512d 100644 --- a/src/db.js +++ b/src/db.js @@ -117,6 +117,48 @@ export async function initDb() { ON conversations (created_at) `); + // Moderation tables + await pool.query(` + CREATE TABLE IF NOT EXISTS mod_cases ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + case_number INTEGER NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + target_tag TEXT NOT NULL, + moderator_id TEXT NOT NULL, + moderator_tag TEXT NOT NULL, + reason TEXT, + duration TEXT, + expires_at TIMESTAMPTZ, + log_message_id TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(guild_id, case_number) + ) + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_target + ON mod_cases (guild_id, target_id, created_at) + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS mod_scheduled_actions ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + case_id INTEGER REFERENCES mod_cases(id), + execute_at TIMESTAMPTZ NOT NULL, + executed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_mod_scheduled_actions_pending + ON mod_scheduled_actions (executed, execute_at) + `); + info('Database schema initialized'); } catch (err) { // Clean up the pool so getPool() doesn't return an unusable instance diff --git a/src/index.js b/src/index.js index 3137ad64b..db6be2013 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ import { } from './modules/ai.js'; import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; +import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; @@ -203,8 +204,9 @@ client.on('interactionCreate', async (interaction) => { async function gracefulShutdown(signal) { info('Shutdown initiated', { signal }); - // 1. Stop conversation cleanup timer + // 1. Stop conversation cleanup timer and tempban scheduler stopConversationCleanup(); + stopTempbanScheduler(); // 2. Save state info('Saving conversation state'); @@ -289,6 +291,11 @@ async function startup() { // Register event handlers with live config reference registerEventHandlers(client, config, healthMonitor); + // Start tempban scheduler for automatic unbans (DB required) + if (dbPool) { + startTempbanScheduler(client); + } + // Load commands and login await loadCommands(); await client.login(token); diff --git a/src/modules/moderation.js b/src/modules/moderation.js new file mode 100644 index 000000000..eac1841c9 --- /dev/null +++ b/src/modules/moderation.js @@ -0,0 +1,467 @@ +/** + * Moderation Module + * Shared logic for case management, DM notifications, mod log posting, + * auto-escalation, and tempban scheduling. + */ + +import { EmbedBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, error as logError } from '../logger.js'; +import { parseDuration } from '../utils/duration.js'; +import { getConfig } from './config.js'; + +/** + * Color map for mod log embeds by action type. + * @type {Record} + */ +export const ACTION_COLORS = { + warn: 0xfee75c, + kick: 0xed4245, + timeout: 0xe67e22, + untimeout: 0x57f287, + ban: 0xed4245, + tempban: 0xed4245, + unban: 0x57f287, + softban: 0xed4245, + purge: 0x5865f2, + lock: 0xe67e22, + unlock: 0x57f287, + slowmode: 0x5865f2, +}; + +/** + * Past-tense label for DM notifications by action type. + * @type {Record} + */ +const ACTION_PAST_TENSE = { + warn: 'warned', + kick: 'kicked', + timeout: 'timed out', + untimeout: 'had their timeout removed', + ban: 'banned', + tempban: 'temporarily banned', + unban: 'unbanned', + softban: 'soft-banned', +}; + +/** + * Channel config key for each action type (maps to moderation.logging.channels.*). + * @type {Record} + */ +export const ACTION_LOG_CHANNEL_KEY = { + warn: 'warns', + kick: 'kicks', + timeout: 'timeouts', + untimeout: 'timeouts', + ban: 'bans', + tempban: 'bans', + unban: 'bans', + softban: 'bans', + purge: 'purges', + lock: 'locks', + unlock: 'locks', + slowmode: 'locks', +}; + +/** @type {ReturnType | null} */ +let schedulerInterval = null; + +/** @type {boolean} */ +let schedulerPollInFlight = false; + +/** + * Create a moderation case in the database. + * Uses a per-guild advisory lock to atomically assign sequential case numbers. + * @param {string} guildId - Discord guild ID + * @param {Object} data - Case data + * @param {string} data.action - Action type (warn, kick, ban, etc.) + * @param {string} data.targetId - Target user ID + * @param {string} data.targetTag - Target user tag + * @param {string} data.moderatorId - Moderator user ID + * @param {string} data.moderatorTag - Moderator user tag + * @param {string} [data.reason] - Reason for action + * @param {string} [data.duration] - Duration string (for timeout/tempban) + * @param {Date} [data.expiresAt] - Expiration timestamp + * @returns {Promise} Created case row + */ +export async function createCase(guildId, data) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Serialize case-number generation per guild to prevent race conditions. + await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [guildId]); + + const { rows } = await client.query( + `INSERT INTO mod_cases + ( + guild_id, + case_number, + action, + target_id, + target_tag, + moderator_id, + moderator_tag, + reason, + duration, + expires_at + ) + VALUES ( + $1, + COALESCE((SELECT MAX(case_number) FROM mod_cases WHERE guild_id = $1), 0) + 1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 + ) + RETURNING *`, + [ + guildId, + data.action, + data.targetId, + data.targetTag, + data.moderatorId, + data.moderatorTag, + data.reason || null, + data.duration || null, + data.expiresAt || null, + ], + ); + + await client.query('COMMIT'); + + const createdCase = rows[0]; + info('Moderation case created', { + guildId, + caseNumber: createdCase.case_number, + action: data.action, + target: data.targetTag, + moderator: data.moderatorTag, + }); + + return createdCase; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } +} + +/** + * Schedule a moderation action for future execution. + * @param {string} guildId - Discord guild ID + * @param {string} action - Action type (e.g. unban) + * @param {string} targetId - Target user ID + * @param {number|null} caseId - Related case ID (if any) + * @param {Date} executeAt - When to execute the action + * @returns {Promise} Created scheduled action row + */ +export async function scheduleAction(guildId, action, targetId, caseId, executeAt) { + const pool = getPool(); + const { rows } = await pool.query( + `INSERT INTO mod_scheduled_actions + (guild_id, action, target_id, case_id, execute_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [guildId, action, targetId, caseId || null, executeAt], + ); + + return rows[0]; +} + +/** + * Send a DM notification to a member before a moderation action. + * Silently fails if the user has DMs disabled. + * @param {import('discord.js').GuildMember} member - Target member + * @param {string} action - Action type + * @param {string|null} reason - Reason for the action + * @param {string} guildName - Server name + */ +export async function sendDmNotification(member, action, reason, guildName) { + const pastTense = ACTION_PAST_TENSE[action] || action; + const embed = new EmbedBuilder() + .setColor(ACTION_COLORS[action] || 0x5865f2) + .setTitle(`You have been ${pastTense} in ${guildName}`) + .addFields({ name: 'Reason', value: reason || 'No reason provided' }) + .setTimestamp(); + + try { + await member.send({ embeds: [embed] }); + } catch { + // User has DMs disabled — silently continue + } +} + +/** + * Send a mod log embed to the configured channel. + * @param {import('discord.js').Client} client - Discord client + * @param {Object} config - Bot configuration + * @param {Object} caseData - Case data from createCase() + * @returns {Promise} Sent message or null + */ +export async function sendModLogEmbed(client, config, caseData) { + const channels = config.moderation?.logging?.channels; + if (!channels) return null; + + const actionKey = ACTION_LOG_CHANNEL_KEY[caseData.action]; + const channelId = channels[actionKey] || channels.default; + if (!channelId) return null; + + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel) return null; + + const embed = new EmbedBuilder() + .setColor(ACTION_COLORS[caseData.action] || 0x5865f2) + .setTitle(`Case #${caseData.case_number} — ${caseData.action.toUpperCase()}`) + .addFields( + { name: 'Target', value: `<@${caseData.target_id}> (${caseData.target_tag})`, inline: true }, + { + name: 'Moderator', + value: `<@${caseData.moderator_id}> (${caseData.moderator_tag})`, + inline: true, + }, + { name: 'Reason', value: caseData.reason || 'No reason provided' }, + ) + .setTimestamp(caseData.created_at ? new Date(caseData.created_at) : new Date()) + .setFooter({ text: `Case #${caseData.case_number}` }); + + if (caseData.duration) { + embed.addFields({ name: 'Duration', value: caseData.duration, inline: true }); + } + + try { + const sentMessage = await channel.send({ embeds: [embed] }); + + // Store log message ID for future editing + try { + const pool = getPool(); + await pool.query('UPDATE mod_cases SET log_message_id = $1 WHERE id = $2', [ + sentMessage.id, + caseData.id, + ]); + } catch (err) { + logError('Failed to store log message ID', { + caseId: caseData.id, + messageId: sentMessage.id, + error: err.message, + }); + } + + return sentMessage; + } catch (err) { + logError('Failed to send mod log embed', { error: err.message, channelId }); + return null; + } +} + +/** + * Check auto-escalation thresholds after a warn. + * Evaluates thresholds in order; first match triggers. + * @param {import('discord.js').Client} client - Discord client + * @param {string} guildId - Discord guild ID + * @param {string} targetId - Target user ID + * @param {string} moderatorId - Moderator user ID (bot for auto-escalation) + * @param {string} moderatorTag - Moderator tag + * @param {Object} config - Bot configuration + * @returns {Promise} Escalation result or null + */ +export async function checkEscalation( + client, + guildId, + targetId, + moderatorId, + moderatorTag, + config, +) { + if (!config.moderation?.escalation?.enabled) return null; + + const thresholds = config.moderation.escalation.thresholds; + if (!thresholds?.length) return null; + + const pool = getPool(); + + for (const threshold of thresholds) { + const { rows } = await pool.query( + `SELECT COUNT(*)::integer AS count FROM mod_cases + WHERE guild_id = $1 AND target_id = $2 AND action = 'warn' + AND created_at > NOW() - INTERVAL '1 day' * $3`, + [guildId, targetId, threshold.withinDays], + ); + + const warnCount = rows[0]?.count || 0; + if (warnCount < threshold.warns) continue; + + const reason = `Auto-escalation: ${warnCount} warns in ${threshold.withinDays} days`; + info('Escalation triggered', { guildId, targetId, warnCount, threshold }); + + try { + const guild = await client.guilds.fetch(guildId); + const member = await guild.members.fetch(targetId).catch(() => null); + + if (threshold.action === 'timeout' && member) { + const ms = parseDuration(threshold.duration); + if (ms) { + await member.timeout(ms, reason); + } + } else if (threshold.action === 'ban') { + await guild.members.ban(targetId, { reason }); + } + + const escalationCase = await createCase(guildId, { + action: threshold.action, + targetId, + targetTag: member?.user?.tag || targetId, + moderatorId, + moderatorTag, + reason, + duration: threshold.duration || null, + }); + + await sendModLogEmbed(client, config, escalationCase); + + return escalationCase; + } catch (err) { + logError('Escalation action failed', { error: err.message, guildId, targetId, threshold }); + return null; + } + } + + return null; +} + +/** + * Poll for expired tempbans and execute unbans. + * @param {import('discord.js').Client} client - Discord client + */ +async function pollTempbans(client) { + if (schedulerPollInFlight) { + return; + } + + schedulerPollInFlight = true; + + try { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT * FROM mod_scheduled_actions + WHERE executed = FALSE AND execute_at <= NOW() + ORDER BY execute_at ASC + LIMIT 50`, + ); + + for (const row of rows) { + // Claim this row to prevent concurrent polls from processing it twice. + const claim = await pool.query( + 'UPDATE mod_scheduled_actions SET executed = TRUE WHERE id = $1 AND executed = FALSE RETURNING id', + [row.id], + ); + if (claim.rows.length === 0) { + continue; + } + + try { + const guild = await client.guilds.fetch(row.guild_id); + await guild.members.unban(row.target_id, 'Tempban expired'); + + const targetUser = await client.users.fetch(row.target_id).catch(() => null); + + // Create unban case + const config = getConfig(); + const unbanCase = await createCase(row.guild_id, { + action: 'unban', + targetId: row.target_id, + targetTag: targetUser?.tag || row.target_id, + moderatorId: client.user?.id || 'system', + moderatorTag: client.user?.tag || 'System', + reason: `Tempban expired (case #${row.case_id ? row.case_id : 'unknown'})`, + }); + + await sendModLogEmbed(client, config, unbanCase); + + info('Tempban expired, user unbanned', { + guildId: row.guild_id, + targetId: row.target_id, + }); + } catch (err) { + logError('Failed to process expired tempban', { + error: err.message, + id: row.id, + guildId: row.guild_id, + targetId: row.target_id, + }); + } + } + } catch (err) { + logError('Tempban scheduler poll error', { error: err.message }); + } finally { + schedulerPollInFlight = false; + } +} + +/** + * Start the tempban scheduler polling interval. + * Polls every 60 seconds for expired tempbans. + * Runs an immediate check on startup to catch missed unbans. + * @param {import('discord.js').Client} client - Discord client + */ +export function startTempbanScheduler(client) { + if (schedulerInterval) return; + + // Immediate check on startup + pollTempbans(client).catch((err) => { + logError('Initial tempban poll failed', { error: err.message }); + }); + + schedulerInterval = setInterval(() => { + pollTempbans(client).catch((err) => { + logError('Tempban poll failed', { error: err.message }); + }); + }, 60000); + + info('Tempban scheduler started'); +} + +/** + * Stop the tempban scheduler. + */ +export function stopTempbanScheduler() { + if (schedulerInterval) { + clearInterval(schedulerInterval); + schedulerInterval = null; + info('Tempban scheduler stopped'); + } +} + +/** + * Check if the moderator (and optionally the bot) can moderate a target member. + * @param {import('discord.js').GuildMember} moderator - The moderator + * @param {import('discord.js').GuildMember} target - The target member + * @param {import('discord.js').GuildMember|null} [botMember=null] - The bot's own guild member + * @returns {string|null} Error message if cannot moderate, null if OK + */ +export function checkHierarchy(moderator, target, botMember = null) { + if (target.roles.highest.position >= moderator.roles.highest.position) { + return '❌ You cannot moderate a member with an equal or higher role than yours.'; + } + if (botMember && target.roles.highest.position >= botMember.roles.highest.position) { + return '❌ I cannot moderate this member — my role is not high enough.'; + } + return null; +} + +/** + * Check if DM notification is enabled for an action type. + * @param {Object} config - Bot configuration + * @param {string} action - Action type + * @returns {boolean} True if DM should be sent + */ +export function shouldSendDm(config, action) { + return config.moderation?.dmNotifications?.[action] === true; +} diff --git a/src/utils/duration.js b/src/utils/duration.js new file mode 100644 index 000000000..33f8ccb36 --- /dev/null +++ b/src/utils/duration.js @@ -0,0 +1,71 @@ +/** + * Duration Parser Utility + * + * Provides functions to parse human-readable duration strings + * into milliseconds and format milliseconds back into readable strings. + */ + +const UNITS = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, +}; + +const DURATION_RE = /^\s*(\d+)\s*([smhdw])\s*$/i; + +/** + * Parse a duration string into milliseconds. + * @param {string} str - Duration string (e.g. "30s", "5m", "1h", "7d", "2w") + * @returns {number|null} Duration in milliseconds, or null if invalid + */ +export function parseDuration(str) { + if (typeof str !== 'string') return null; + + const match = str.match(DURATION_RE); + if (!match) return null; + + const value = Number(match[1]); + if (value <= 0 || !Number.isSafeInteger(value)) return null; + + const unit = match[2].toLowerCase(); + const ms = value * UNITS[unit]; + if (!Number.isFinite(ms)) return null; + return ms; +} + +const UNIT_LIST = [ + { ms: UNITS.w, singular: 'week', plural: 'weeks' }, + { ms: UNITS.d, singular: 'day', plural: 'days' }, + { ms: UNITS.h, singular: 'hour', plural: 'hours' }, + { ms: UNITS.m, singular: 'minute', plural: 'minutes' }, + { ms: UNITS.s, singular: 'second', plural: 'seconds' }, +]; + +/** + * Format milliseconds into a human-readable duration string. + * Contract: + * - Accepts a number of milliseconds. + * - Only returns exact single-unit values from UNIT_LIST (weeks/days/hours/minutes/seconds). + * - For exact matches, returns singular/plural form (e.g. "1 hour", "2 days"). + * - For non-exact or invalid inputs, returns "0 seconds". + * + * This pairs cleanly with parseDuration(): values produced by parseDuration() round-trip + * through formatDuration() as long as they remain unchanged. + * + * @param {number} ms - Duration in milliseconds + * @returns {string} Human-readable string (e.g. "1 hour", "2 days") + */ +export function formatDuration(ms) { + if (typeof ms !== 'number' || ms <= 0) return '0 seconds'; + + for (const unit of UNIT_LIST) { + if (ms >= unit.ms && ms % unit.ms === 0) { + const count = ms / unit.ms; + return `${count} ${count === 1 ? unit.singular : unit.plural}`; + } + } + + return '0 seconds'; +} diff --git a/tests/commands/ban.test.js b/tests/commands/ban.test.js new file mode 100644 index 000000000..91de2b39b --- /dev/null +++ b/tests/commands/ban.test.js @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'ban', id: 1 }), + sendDmNotification: vi.fn().mockResolvedValue(undefined), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkHierarchy: vi.fn().mockReturnValue(null), + shouldSendDm: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/ban.js'; +import { checkHierarchy, createCase, sendDmNotification } from '../../src/modules/moderation.js'; + +describe('ban command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockUser = { id: 'user1', tag: 'User#0001' }; + const mockMember = { + id: 'user1', + user: mockUser, + roles: { highest: { position: 5 } }, + }; + + const createInteraction = () => ({ + options: { + getUser: vi.fn().mockReturnValue(mockUser), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + return null; + }), + getInteger: vi.fn().mockReturnValue(0), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { + ban: vi.fn().mockResolvedValue(undefined), + fetch: vi.fn().mockResolvedValue(mockMember), + me: { + roles: { highest: { position: 10 } }, + }, + }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }); + + it('should export data with name "ban"', () => { + expect(data.name).toBe('ban'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should ban a user successfully', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(sendDmNotification).toHaveBeenCalled(); + expect(interaction.guild.members.ban).toHaveBeenCalledWith('user1', { + deleteMessageSeconds: 0, + reason: 'test reason', + }); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'ban', + targetId: 'user1', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('has been banned')); + }); + + it('should skip hierarchy check for users not in guild', async () => { + const interaction = createInteraction(); + interaction.guild.members.fetch.mockRejectedValueOnce(new Error('Unknown Member')); + + await execute(interaction); + + expect(checkHierarchy).not.toHaveBeenCalled(); + expect(interaction.guild.members.ban).toHaveBeenCalled(); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(interaction.guild.members.ban).not.toHaveBeenCalled(); + }); + + it('should reject when bot role is too low', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ I cannot moderate this member — my role is not high enough.', + ); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('my role is not high enough'), + ); + expect(interaction.guild.members.ban).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/case.test.js b/tests/commands/case.test.js new file mode 100644 index 000000000..aae4c001b --- /dev/null +++ b/tests/commands/case.test.js @@ -0,0 +1,296 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { logging: { channels: { default: '123', warns: '456' } } }, + }), +})); +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +import { adminOnly, data, execute } from '../../src/commands/case.js'; +import { getPool } from '../../src/db.js'; + +const mockCaseRow = { + id: 1, + guild_id: 'guild1', + case_number: 1, + action: 'warn', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'Test reason', + created_at: '2026-01-01T00:00:00Z', +}; + +function createInteraction(subcommand, overrides = {}) { + return { + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + getInteger: vi.fn().mockReturnValue(1), + getUser: vi.fn().mockReturnValue({ id: 'user1', tag: 'User#0001' }), + getString: vi.fn().mockReturnValue(null), + }, + guild: { id: 'guild1' }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { channels: { fetch: vi.fn().mockResolvedValue(null) } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('case command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with correct name', () => { + expect(data.name).toBe('case'); + }); + + it('should export adminOnly flag', () => { + expect(adminOnly).toBe(true); + }); + + describe('view subcommand', () => { + it('should display a case by number', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [mockCaseRow] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('view'); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('SELECT'), ['guild1', 1]); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should handle case not found', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('view'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith('Case #1 not found.'); + }); + }); + + describe('list subcommand', () => { + it('should list recent cases', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [mockCaseRow] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should filter by user', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [mockCaseRow] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + interaction.options.getUser = vi.fn().mockReturnValue({ id: 'user1' }); + interaction.options.getString = vi.fn().mockReturnValue(null); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('target_id'), + expect.arrayContaining(['user1']), + ); + }); + + it('should filter by type', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [mockCaseRow] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + interaction.options.getUser = vi.fn().mockReturnValue(null); + interaction.options.getString = vi.fn().mockReturnValue('warn'); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('action'), + expect.arrayContaining(['warn']), + ); + }); + + it('should filter by both user and type', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [mockCaseRow] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + interaction.options.getUser = vi.fn().mockReturnValue({ id: 'user1' }); + interaction.options.getString = vi.fn().mockReturnValue('warn'); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('target_id'), + expect.arrayContaining(['user1', 'warn']), + ); + }); + + it('should handle no cases found', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('No cases found')); + }); + + it('should truncate long reasons', async () => { + const longReasonCase = { ...mockCaseRow, reason: 'A'.repeat(60) }; + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [longReasonCase] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should handle cases with no reason', async () => { + const noReasonCase = { ...mockCaseRow, reason: null }; + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [noReasonCase] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('list'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + }); + + describe('reason subcommand', () => { + it('should update case reason', async () => { + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [{ ...mockCaseRow, log_message_id: null }] }), + }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('reason'); + interaction.options.getString = vi.fn().mockReturnValue('New reason'); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE'), [ + 'New reason', + 'guild1', + 1, + ]); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Updated reason')); + }); + + it('should edit log message when log_message_id exists', async () => { + const caseWithLog = { + ...mockCaseRow, + log_message_id: 'logmsg1', + action: 'warn', + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [caseWithLog] }), + }; + getPool.mockReturnValue(mockPool); + + const mockMessage = { edit: vi.fn().mockResolvedValue(undefined) }; + const mockLogChannel = { messages: { fetch: vi.fn().mockResolvedValue(mockMessage) } }; + const interaction = createInteraction('reason'); + interaction.options.getString = vi.fn().mockReturnValue('Updated reason'); + interaction.client = { channels: { fetch: vi.fn().mockResolvedValue(mockLogChannel) } }; + await execute(interaction); + + expect(mockLogChannel.messages.fetch).toHaveBeenCalledWith('logmsg1'); + expect(mockMessage.edit).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Updated reason')); + }); + + it('should handle log message edit failure gracefully', async () => { + const caseWithLog = { + ...mockCaseRow, + log_message_id: 'logmsg1', + action: 'warn', + }; + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [caseWithLog] }), + }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('reason'); + interaction.options.getString = vi.fn().mockReturnValue('Updated reason'); + interaction.client = { + channels: { fetch: vi.fn().mockRejectedValue(new Error('Not found')) }, + }; + await execute(interaction); + + // Should still succeed even if log edit fails + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Updated reason')); + }); + + it('should handle case not found on reason update', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('reason'); + interaction.options.getString = vi.fn().mockReturnValue('New reason'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith('Case #1 not found.'); + }); + }); + + describe('delete subcommand', () => { + it('should delete a case', async () => { + const mockPool = { + query: vi.fn().mockResolvedValue({ rows: [mockCaseRow] }), + }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('delete'); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('DELETE'), ['guild1', 1]); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Deleted case')); + }); + + it('should handle case not found on delete', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction('delete'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith('Case #1 not found.'); + }); + }); + + it('should handle errors gracefully', async () => { + getPool.mockReturnValue({ + query: vi.fn().mockRejectedValue(new Error('DB error')), + }); + + const interaction = createInteraction('view'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('Failed to execute'), + ); + }); +}); diff --git a/tests/commands/history.test.js b/tests/commands/history.test.js new file mode 100644 index 000000000..9bfb07928 --- /dev/null +++ b/tests/commands/history.test.js @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +import { adminOnly, data, execute } from '../../src/commands/history.js'; +import { getPool } from '../../src/db.js'; + +const mockCaseRows = [ + { + case_number: 2, + action: 'timeout', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'Second offense', + created_at: '2026-01-02T00:00:00Z', + }, + { + case_number: 1, + action: 'warn', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'First warning', + created_at: '2026-01-01T00:00:00Z', + }, +]; + +function createInteraction() { + return { + options: { + getUser: vi.fn().mockReturnValue({ + id: 'user1', + tag: 'User#0001', + displayAvatarURL: vi.fn().mockReturnValue('https://cdn.discordapp.com/avatar.png'), + }), + }, + guild: { id: 'guild1' }, + user: { id: 'mod1', tag: 'Mod#0001' }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('history command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with correct name', () => { + expect(data.name).toBe('history'); + }); + + it('should export adminOnly flag', () => { + expect(adminOnly).toBe(true); + }); + + it('should display moderation history for a user', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: mockCaseRows }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction(); + await execute(interaction); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('SELECT'), [ + 'guild1', + 'user1', + ]); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should handle no history found', async () => { + const mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + + const interaction = createInteraction(); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No moderation history'), + ); + }); + + it('should handle errors gracefully', async () => { + getPool.mockReturnValue({ + query: vi.fn().mockRejectedValue(new Error('DB error')), + }); + + const interaction = createInteraction(); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch')); + }); +}); diff --git a/tests/commands/kick.test.js b/tests/commands/kick.test.js new file mode 100644 index 000000000..f96e1842f --- /dev/null +++ b/tests/commands/kick.test.js @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'kick', id: 1 }), + sendDmNotification: vi.fn().mockResolvedValue(undefined), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkHierarchy: vi.fn().mockReturnValue(null), + shouldSendDm: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +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'; + +describe('kick command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createInteraction = () => { + const mockMember = { + id: 'user1', + user: { id: 'user1', tag: 'User#0001' }, + roles: { highest: { position: 5 } }, + kick: vi.fn().mockResolvedValue(undefined), + }; + + return { + interaction: { + options: { + getMember: vi.fn().mockReturnValue(mockMember), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + return null; + }), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { me: { roles: { highest: { position: 10 } } } }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }, + mockMember, + }; + }; + + it('should export data with name "kick"', () => { + expect(data.name).toBe('kick'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should kick a user successfully', async () => { + const { interaction, mockMember } = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(sendDmNotification).toHaveBeenCalled(); + expect(mockMember.kick).toHaveBeenCalledWith('test reason'); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'kick', + targetId: 'user1', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('has been kicked')); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const { interaction, mockMember } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(mockMember.kick).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const { interaction } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/lock.test.js b/tests/commands/lock.test.js new file mode 100644 index 000000000..9d71d39e0 --- /dev/null +++ b/tests/commands/lock.test.js @@ -0,0 +1,146 @@ +import { ChannelType } from 'discord.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'lock', id: 1 }), + sendModLogEmbed: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { logging: { channels: { default: '123' } } }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/lock.js'; +import { createCase, sendModLogEmbed } from '../../src/modules/moderation.js'; + +describe('lock command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createInteraction = (overrides = {}) => ({ + options: { + getChannel: vi.fn().mockReturnValue(null), + getString: vi.fn().mockReturnValue(null), + }, + channel: { + id: 'chan1', + name: 'general', + type: ChannelType.GuildText, + permissionOverwrites: { edit: vi.fn().mockResolvedValue(undefined) }, + send: vi.fn().mockResolvedValue(undefined), + }, + guild: { + id: 'guild1', + roles: { everyone: { id: 'everyone-role' } }, + }, + user: { id: 'mod1', tag: 'Mod#0001', toString: () => '<@mod1>' }, + client: { channels: { fetch: vi.fn() } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + ...overrides, + }); + + it('should export data with name "lock"', () => { + expect(data.name).toBe('lock'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should lock the current channel when no channel option provided', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(interaction.channel.permissionOverwrites.edit).toHaveBeenCalledWith( + interaction.guild.roles.everyone, + { SendMessages: false }, + ); + expect(interaction.channel.send).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'lock', + targetId: 'chan1', + targetTag: '#general', + }), + ); + expect(sendModLogEmbed).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('has been locked')); + }); + + it('should lock a specified channel', async () => { + const targetChannel = { + id: 'chan2', + name: 'announcements', + type: ChannelType.GuildText, + permissionOverwrites: { edit: vi.fn().mockResolvedValue(undefined) }, + send: vi.fn().mockResolvedValue(undefined), + }; + const interaction = createInteraction(); + interaction.options.getChannel.mockReturnValue(targetChannel); + + await execute(interaction); + + expect(targetChannel.permissionOverwrites.edit).toHaveBeenCalledWith( + interaction.guild.roles.everyone, + { SendMessages: false }, + ); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + targetId: 'chan2', + targetTag: '#announcements', + }), + ); + }); + + it('should include reason in notification and case', async () => { + const interaction = createInteraction(); + interaction.options.getString.mockReturnValue('raid in progress'); + + await execute(interaction); + + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + reason: 'raid in progress', + }), + ); + expect(interaction.channel.send).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should reject non-text channels', async () => { + const interaction = createInteraction(); + interaction.channel.type = ChannelType.GuildVoice; + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('text channels')); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const interaction = createInteraction(); + interaction.channel.permissionOverwrites.edit.mockRejectedValueOnce(new Error('Missing perms')); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/modlog.test.js b/tests/commands/modlog.test.js new file mode 100644 index 000000000..8a117ebc2 --- /dev/null +++ b/tests/commands/modlog.test.js @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + logging: { + channels: { + default: '111', + warns: '222', + bans: null, + kicks: null, + timeouts: null, + purges: null, + locks: null, + }, + }, + }, + }), + setConfigValue: vi.fn().mockResolvedValue({}), +})); +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +import { adminOnly, data, execute } from '../../src/commands/modlog.js'; +import { getConfig, setConfigValue } from '../../src/modules/config.js'; + +function createInteraction(subcommand) { + const collectHandlers = {}; + return { + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + }, + user: { id: 'mod1', tag: 'Mod#0001' }, + reply: vi.fn().mockResolvedValue({ + createMessageComponentCollector: vi.fn().mockReturnValue({ + on: vi.fn().mockImplementation((event, handler) => { + collectHandlers[event] = handler; + }), + stop: vi.fn(), + }), + }), + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + _collectHandlers: collectHandlers, + }; +} + +describe('modlog command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with correct name', () => { + expect(data.name).toBe('modlog'); + }); + + it('should export adminOnly flag', () => { + expect(adminOnly).toBe(true); + }); + + it('should reply for unknown subcommand', async () => { + const interaction = createInteraction('wat'); + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Unknown subcommand') }), + ); + }); + + describe('view subcommand', () => { + it('should display current log routing config', async () => { + const interaction = createInteraction('view'); + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.any(Array), + ephemeral: true, + }), + ); + }); + + it('should handle missing logging config', async () => { + getConfig.mockReturnValueOnce({ moderation: {} }); + const interaction = createInteraction('view'); + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.any(Array), + ephemeral: true, + }), + ); + }); + }); + + describe('disable subcommand', () => { + it('should clear all log channels', async () => { + const interaction = createInteraction('disable'); + await execute(interaction); + + expect(setConfigValue).toHaveBeenCalledTimes(7); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.default', null); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.warns', null); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.bans', null); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.kicks', null); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.timeouts', null); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.purges', null); + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.locks', null); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('disabled')); + }); + }); + + describe('setup subcommand', () => { + it('should send reply with components and create collector', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.any(Array), + components: expect.any(Array), + ephemeral: true, + fetchReply: true, + }), + ); + }); + + it('should handle "done" button click', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + const collectHandler = interaction._collectHandlers.collect; + expect(collectHandler).toBeDefined(); + + const doneInteraction = { + customId: 'modlog_done', + update: vi.fn().mockResolvedValue(undefined), + }; + await collectHandler(doneInteraction); + expect(doneInteraction.update).toHaveBeenCalledWith( + expect.objectContaining({ components: [] }), + ); + }); + + it('should handle category selection', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + const collectHandler = interaction._collectHandlers.collect; + + const categoryInteraction = { + customId: 'modlog_category', + values: ['warns'], + update: vi.fn().mockResolvedValue(undefined), + }; + await collectHandler(categoryInteraction); + expect(categoryInteraction.update).toHaveBeenCalledWith( + expect.objectContaining({ components: expect.any(Array) }), + ); + }); + + it('should handle channel selection after category', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + const collectHandler = interaction._collectHandlers.collect; + + // First select category + const categoryInteraction = { + customId: 'modlog_category', + values: ['warns'], + update: vi.fn().mockResolvedValue(undefined), + }; + await collectHandler(categoryInteraction); + + // Then select channel + const channelInteraction = { + customId: 'modlog_channel', + values: ['999'], + update: vi.fn().mockResolvedValue(undefined), + }; + await collectHandler(channelInteraction); + + expect(setConfigValue).toHaveBeenCalledWith('moderation.logging.channels.warns', '999'); + expect(channelInteraction.update).toHaveBeenCalled(); + }); + + it('should ignore channel selection without prior category', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + const collectHandler = interaction._collectHandlers.collect; + + // Channel selection without prior category selection + const channelInteraction = { + customId: 'modlog_channel', + values: ['999'], + update: vi.fn().mockResolvedValue(undefined), + }; + await collectHandler(channelInteraction); + + expect(setConfigValue).not.toHaveBeenCalled(); + }); + + it('should handle collector timeout', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + const endHandler = interaction._collectHandlers.end; + expect(endHandler).toBeDefined(); + + await endHandler([], 'time'); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ components: [] }), + ); + }); + + it('should not edit reply on non-timeout end', async () => { + const interaction = createInteraction('setup'); + await execute(interaction); + + const endHandler = interaction._collectHandlers.end; + await endHandler([], 'user'); + + // editReply should NOT be called since reason is not 'time' + expect(interaction.editReply).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/commands/purge.test.js b/tests/commands/purge.test.js new file mode 100644 index 000000000..36b2cffd4 --- /dev/null +++ b/tests/commands/purge.test.js @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), +})); + +// Mock config module +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + logging: { + channels: { + default: '111', + purges: '222', + }, + }, + }, + }), +})); + +// Mock moderation module +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 42, id: 42, action: 'purge' }), + sendModLogEmbed: vi.fn().mockResolvedValue(null), +})); + +import { adminOnly, data, execute } from '../../src/commands/purge.js'; +import { createCase, sendModLogEmbed } from '../../src/modules/moderation.js'; + +/** + * Helper to create a mock message with the given properties. + * @param {Object} opts + * @returns {[string, Object]} + */ +function mockMessage(opts = {}) { + const id = opts.id || String(Math.random()); + return [ + id, + { + id, + content: opts.content || '', + author: { id: opts.authorId || '100', bot: opts.bot || false }, + createdTimestamp: opts.createdTimestamp || Date.now(), + attachments: { size: opts.attachmentCount || 0 }, + }, + ]; +} + +/** + * Create a Map that behaves like Discord's Collection with a filter method. + * @param {Array} entries + * @returns {Map} + */ +function mockCollection(entries) { + const map = new Map(entries); + map.filter = function (fn) { + const filtered = new Map(); + for (const [k, v] of this) { + if (fn(v, k, this)) filtered.set(k, v); + } + filtered.filter = map.filter.bind(filtered); + return filtered; + }; + return map; +} + +describe('purge command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with correct name', () => { + expect(data.name).toBe('purge'); + }); + + it('should export adminOnly flag', () => { + expect(adminOnly).toBe(true); + }); + + it('should have all 6 subcommands', () => { + const subcommands = data.options.map((opt) => opt.name); + expect(subcommands).toContain('all'); + expect(subcommands).toContain('user'); + expect(subcommands).toContain('bot'); + expect(subcommands).toContain('contains'); + expect(subcommands).toContain('links'); + expect(subcommands).toContain('attachments'); + expect(subcommands).toHaveLength(6); + }); + + describe('execute', () => { + /** + * Build a mock interaction for purge tests. + */ + function buildInteraction(subcommand, opts = {}) { + const deletedCollection = mockCollection([]); + const fetchedMessages = + opts.messages || + mockCollection([mockMessage({ content: 'hello' }), mockMessage({ content: 'world' })]); + + return { + interaction: { + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + getInteger: vi.fn().mockReturnValue(opts.count || 10), + getUser: vi.fn().mockReturnValue(opts.user || { id: '100', tag: 'User#0001' }), + getString: vi.fn().mockReturnValue(opts.text || 'test'), + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + channel: { + id: '999', + name: 'general', + messages: { fetch: vi.fn().mockResolvedValue(fetchedMessages) }, + bulkDelete: vi.fn().mockResolvedValue(opts.deletedResult || deletedCollection), + }, + guild: { id: '123' }, + user: { id: '456', tag: 'Mod#0001' }, + client: { + channels: { fetch: vi.fn() }, + }, + }, + }; + } + + it('should delete all messages with "all" subcommand', async () => { + const messages = mockCollection([ + mockMessage({ content: 'msg1' }), + mockMessage({ content: 'msg2' }), + ]); + const deleted = mockCollection([...messages]); + const { interaction } = buildInteraction('all', { messages, deletedResult: deleted }); + + await execute(interaction); + + expect(interaction.channel.bulkDelete).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('2')); + }); + + it('should filter by user with "user" subcommand', async () => { + const messages = mockCollection([ + mockMessage({ authorId: '100', content: 'from target' }), + mockMessage({ authorId: '200', content: 'from other' }), + ]); + const { interaction } = buildInteraction('user', { + messages, + user: { id: '100', tag: 'Target#0001' }, + }); + + await execute(interaction); + + const bulkDeleteCall = interaction.channel.bulkDelete.mock.calls[0][0]; + expect(bulkDeleteCall.size).toBe(1); + for (const [, msg] of bulkDeleteCall) { + expect(msg.author.id).toBe('100'); + } + }); + + it('should filter bot messages with "bot" subcommand', async () => { + const messages = mockCollection([ + mockMessage({ bot: true, content: 'bot message' }), + mockMessage({ bot: false, content: 'human message' }), + ]); + const { interaction } = buildInteraction('bot', { messages }); + + await execute(interaction); + + const bulkDeleteCall = interaction.channel.bulkDelete.mock.calls[0][0]; + expect(bulkDeleteCall.size).toBe(1); + }); + + it('should filter by text with "contains" subcommand', async () => { + const messages = mockCollection([ + mockMessage({ content: 'this has TEST word' }), + mockMessage({ content: 'no match here' }), + ]); + const { interaction } = buildInteraction('contains', { messages, text: 'test' }); + + await execute(interaction); + + const bulkDeleteCall = interaction.channel.bulkDelete.mock.calls[0][0]; + expect(bulkDeleteCall.size).toBe(1); + }); + + it('should filter links with "links" subcommand', async () => { + const messages = mockCollection([ + mockMessage({ content: 'check https://example.com' }), + mockMessage({ content: 'no link here' }), + ]); + const { interaction } = buildInteraction('links', { messages }); + + await execute(interaction); + + const bulkDeleteCall = interaction.channel.bulkDelete.mock.calls[0][0]; + expect(bulkDeleteCall.size).toBe(1); + }); + + it('should filter attachments with "attachments" subcommand', async () => { + const messages = mockCollection([ + mockMessage({ attachmentCount: 2, content: 'has file' }), + mockMessage({ attachmentCount: 0, content: 'no file' }), + ]); + const { interaction } = buildInteraction('attachments', { messages }); + + await execute(interaction); + + const bulkDeleteCall = interaction.channel.bulkDelete.mock.calls[0][0]; + expect(bulkDeleteCall.size).toBe(1); + }); + + it('should filter out messages older than 14 days', async () => { + const old = Date.now() - 15 * 86400 * 1000; + const messages = mockCollection([ + mockMessage({ content: 'recent' }), + mockMessage({ content: 'old', createdTimestamp: old }), + ]); + const { interaction } = buildInteraction('all', { messages }); + + await execute(interaction); + + const bulkDeleteCall = interaction.channel.bulkDelete.mock.calls[0][0]; + expect(bulkDeleteCall.size).toBe(1); + }); + + it('should create a case and send shared mod log embed on success', async () => { + const messages = mockCollection([mockMessage({ content: 'msg' })]); + const deleted = mockCollection([...messages]); + const { interaction } = buildInteraction('all', { + messages, + deletedResult: deleted, + }); + + await execute(interaction); + + expect(createCase).toHaveBeenCalledWith( + '123', + expect.objectContaining({ + action: 'purge', + targetId: '999', + targetTag: '#general', + }), + ); + expect(sendModLogEmbed).toHaveBeenCalled(); + }); + + it('should handle bulkDelete error gracefully', async () => { + const messages = mockCollection([mockMessage({ content: 'msg' })]); + const { interaction } = buildInteraction('all', { messages }); + interaction.channel.bulkDelete.mockRejectedValue(new Error('API error')); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); + }); +}); diff --git a/tests/commands/slowmode.test.js b/tests/commands/slowmode.test.js new file mode 100644 index 000000000..421c12b1d --- /dev/null +++ b/tests/commands/slowmode.test.js @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/duration.js', () => ({ + parseDuration: vi.fn(), + formatDuration: vi.fn().mockImplementation((ms) => { + if (ms === 300000) return '5 minutes'; + if (ms === 21600000) return '6 hours'; + if (ms === 86400000) return '1 day'; + if (ms === 60000) return '1 minute'; + return 'duration'; + }), +})); +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ moderation: { logging: { channels: { default: '123' } } } }), +})); +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 12, action: 'slowmode', id: 12 }), + sendModLogEmbed: vi.fn().mockResolvedValue(null), +})); +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +import { adminOnly, data, execute } from '../../src/commands/slowmode.js'; +import { createCase } from '../../src/modules/moderation.js'; +import { parseDuration } from '../../src/utils/duration.js'; + +function createInteraction(duration = '5m', channel = null) { + const mockChannel = channel || { + id: 'ch1', + name: 'general', + setRateLimitPerUser: vi.fn().mockResolvedValue(undefined), + toString: () => '<#ch1>', + }; + + return { + options: { + getString: vi.fn().mockImplementation((name) => { + if (name === 'duration') return duration; + if (name === 'reason') return null; + return null; + }), + getChannel: vi.fn().mockReturnValue(null), + }, + channel: mockChannel, + guild: { id: 'guild1' }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: {}, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('slowmode command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should export data with correct name', () => { + expect(data.name).toBe('slowmode'); + }); + + it('should export adminOnly flag', () => { + expect(adminOnly).toBe(true); + }); + + it('should set slowmode with valid duration and create case', async () => { + parseDuration.mockReturnValue(300000); // 5 minutes + + const interaction = createInteraction('5m'); + await execute(interaction); + + expect(interaction.channel.setRateLimitPerUser).toHaveBeenCalledWith(300); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ action: 'slowmode', targetId: 'ch1' }), + ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Slowmode set to')); + }); + + it('should disable slowmode with "0"', async () => { + const interaction = createInteraction('0'); + await execute(interaction); + + expect(interaction.channel.setRateLimitPerUser).toHaveBeenCalledWith(0); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('Slowmode disabled'), + ); + }); + + it('should reject invalid duration', async () => { + parseDuration.mockReturnValue(null); + + const interaction = createInteraction('abc'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Invalid duration')); + }); + + it('should reject duration exceeding 6 hours', async () => { + parseDuration.mockReturnValue(7 * 60 * 60 * 1000); // 7 hours + + const interaction = createInteraction('7h'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('cannot exceed 6 hours'), + ); + expect(interaction.channel.setRateLimitPerUser).not.toHaveBeenCalled(); + }); + + it('should use specified channel when provided', async () => { + parseDuration.mockReturnValue(60000); // 1 minute + const targetChannel = { + id: 'ch2', + name: 'other', + setRateLimitPerUser: vi.fn().mockResolvedValue(undefined), + toString: () => '<#ch2>', + }; + + const interaction = createInteraction('1m'); + interaction.options.getChannel = vi.fn().mockReturnValue(targetChannel); + await execute(interaction); + + expect(targetChannel.setRateLimitPerUser).toHaveBeenCalledWith(60); + }); + + it('should handle errors gracefully', async () => { + parseDuration.mockReturnValue(60000); + + const interaction = createInteraction('1m'); + interaction.channel.setRateLimitPerUser = vi + .fn() + .mockRejectedValue(new Error('Missing permissions')); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/softban.test.js b/tests/commands/softban.test.js new file mode 100644 index 000000000..df63298f9 --- /dev/null +++ b/tests/commands/softban.test.js @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'softban', id: 1 }), + sendDmNotification: vi.fn().mockResolvedValue(undefined), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkHierarchy: vi.fn().mockReturnValue(null), + shouldSendDm: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true, softban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/softban.js'; +import { checkHierarchy, createCase, sendDmNotification } from '../../src/modules/moderation.js'; + +describe('softban command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createInteraction = () => { + const mockMember = { + id: 'user1', + user: { id: 'user1', tag: 'User#0001' }, + roles: { highest: { position: 5 } }, + }; + + return { + interaction: { + options: { + getMember: vi.fn().mockReturnValue(mockMember), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + return null; + }), + getInteger: vi.fn().mockReturnValue(null), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { + ban: vi.fn().mockResolvedValue(undefined), + unban: vi.fn().mockResolvedValue(undefined), + me: { roles: { highest: { position: 10 } } }, + }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }, + mockMember, + }; + }; + + it('should export data with name "softban"', () => { + expect(data.name).toBe('softban'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should softban a user successfully', async () => { + const { interaction } = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(sendDmNotification).toHaveBeenCalled(); + expect(interaction.guild.members.ban).toHaveBeenCalledWith('user1', { + deleteMessageSeconds: 7 * 86400, + reason: 'test reason', + }); + expect(interaction.guild.members.unban).toHaveBeenCalledWith('user1', 'Softban'); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'softban', + targetId: 'user1', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has been soft-banned'), + ); + }); + + it('should retry unban when first attempt fails', async () => { + const { interaction } = createInteraction(); + interaction.guild.members.unban + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(undefined); + + vi.useFakeTimers(); + const run = execute(interaction); + await vi.runAllTimersAsync(); + await run; + vi.useRealTimers(); + + expect(interaction.guild.members.unban).toHaveBeenCalledTimes(2); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has been soft-banned'), + ); + }); + + it('should handle getMember returning null', async () => { + const { interaction } = createInteraction(); + interaction.options.getMember.mockReturnValueOnce(null); + + await execute(interaction); + + expect(interaction.guild.members.ban).not.toHaveBeenCalled(); + expect(interaction.guild.members.unban).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith('❌ User is not in this server.'); + }); + + it('should warn moderator if unban keeps failing but still create case', async () => { + const { interaction } = createInteraction(); + interaction.guild.members.unban.mockRejectedValue(new Error('still failing')); + + vi.useFakeTimers(); + const run = execute(interaction); + await vi.runAllTimersAsync(); + await run; + vi.useRealTimers(); + + expect(interaction.guild.members.unban).toHaveBeenCalledTimes(3); + expect(createCase).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('unban failed')); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const { interaction } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(interaction.guild.members.ban).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const { interaction } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/tempban.test.js b/tests/commands/tempban.test.js new file mode 100644 index 000000000..ea7b970be --- /dev/null +++ b/tests/commands/tempban.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'tempban', id: 1 }), + scheduleAction: vi.fn().mockResolvedValue({ id: 10 }), + sendDmNotification: vi.fn().mockResolvedValue(undefined), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkHierarchy: vi.fn().mockReturnValue(null), + shouldSendDm: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/utils/duration.js', () => ({ + parseDuration: vi.fn().mockReturnValue(86400000), + formatDuration: vi.fn().mockReturnValue('1 day'), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/tempban.js'; +import { + checkHierarchy, + createCase, + scheduleAction, + sendDmNotification, +} from '../../src/modules/moderation.js'; +import { parseDuration } from '../../src/utils/duration.js'; + +describe('tempban command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockUser = { id: 'user1', tag: 'User#0001' }; + const mockMember = { + id: 'user1', + user: mockUser, + roles: { highest: { position: 5 } }, + }; + + const createInteraction = () => ({ + options: { + getUser: vi.fn().mockReturnValue(mockUser), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + if (name === 'duration') return '1d'; + return null; + }), + getInteger: vi.fn().mockReturnValue(0), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { + ban: vi.fn().mockResolvedValue(undefined), + fetch: vi.fn().mockResolvedValue(mockMember), + me: { roles: { highest: { position: 10 } } }, + }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }); + + it('should export data with name "tempban"', () => { + expect(data.name).toBe('tempban'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should tempban a user successfully', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(sendDmNotification).toHaveBeenCalled(); + expect(interaction.guild.members.ban).toHaveBeenCalledWith('user1', { + deleteMessageSeconds: 0, + reason: 'test reason', + }); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'tempban', + targetId: 'user1', + duration: '1 day', + }), + ); + expect(scheduleAction).toHaveBeenCalledWith('guild1', 'unban', 'user1', 1, expect.any(Date)); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has been temporarily banned'), + ); + }); + + it('should reject invalid duration', async () => { + parseDuration.mockReturnValueOnce(null); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('Invalid duration format'), + ); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(interaction.guild.members.ban).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/timeout.test.js b/tests/commands/timeout.test.js new file mode 100644 index 000000000..f54a50114 --- /dev/null +++ b/tests/commands/timeout.test.js @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'timeout', id: 1 }), + sendDmNotification: vi.fn().mockResolvedValue(undefined), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkHierarchy: vi.fn().mockReturnValue(null), + shouldSendDm: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/utils/duration.js', () => ({ + parseDuration: vi.fn().mockReturnValue(3600000), + formatDuration: vi.fn().mockReturnValue('1 hour'), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/timeout.js'; +import { checkHierarchy, createCase, sendDmNotification } from '../../src/modules/moderation.js'; +import { parseDuration } from '../../src/utils/duration.js'; + +describe('timeout command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockMember = { + id: 'user1', + user: { id: 'user1', tag: 'User#0001' }, + roles: { highest: { position: 5 } }, + timeout: vi.fn().mockResolvedValue(undefined), + }; + + const createInteraction = () => ({ + options: { + getMember: vi.fn().mockReturnValue(mockMember), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + if (name === 'duration') return '1h'; + return null; + }), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { me: { roles: { highest: { position: 10 } } } }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }); + + it('should export data with name "timeout"', () => { + expect(data.name).toBe('timeout'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should timeout a user successfully', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(sendDmNotification).toHaveBeenCalled(); + expect(mockMember.timeout).toHaveBeenCalledWith(3600000, 'test reason'); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'timeout', + targetId: 'user1', + duration: '1 hour', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has been timed out'), + ); + }); + + it('should reject invalid duration', async () => { + parseDuration.mockReturnValueOnce(null); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('Invalid duration format'), + ); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should reject durations above 28 days', async () => { + parseDuration.mockReturnValueOnce(29 * 24 * 60 * 60 * 1000); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + '❌ Timeout duration cannot exceed 28 days.', + ); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(mockMember.timeout).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/unban.test.js b/tests/commands/unban.test.js new file mode 100644 index 000000000..92c223475 --- /dev/null +++ b/tests/commands/unban.test.js @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'unban', id: 1 }), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/unban.js'; +import { createCase } from '../../src/modules/moderation.js'; + +describe('unban command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createInteraction = () => ({ + options: { + getString: vi.fn().mockImplementation((name) => { + if (name === 'user_id') return '123456789'; + if (name === 'reason') return 'test reason'; + return null; + }), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { + unban: vi.fn().mockResolvedValue(undefined), + }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { + user: { id: 'bot1', tag: 'Bot#0001' }, + users: { + fetch: vi.fn().mockResolvedValue({ tag: 'User#0001' }), + }, + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }); + + it('should export data with name "unban"', () => { + expect(data.name).toBe('unban'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should unban a user successfully', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(interaction.guild.members.unban).toHaveBeenCalledWith('123456789', 'test reason'); + expect(interaction.client.users.fetch).toHaveBeenCalledWith('123456789'); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'unban', + targetId: '123456789', + targetTag: 'User#0001', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has been unbanned'), + ); + }); + + it('should fall back to raw user id when user fetch fails', async () => { + const interaction = createInteraction(); + interaction.client.users.fetch.mockRejectedValueOnce(new Error('not found')); + + await execute(interaction); + + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + targetTag: '123456789', + }), + ); + }); + + it('should handle unban API failure gracefully', async () => { + const interaction = createInteraction(); + interaction.guild.members.unban.mockRejectedValueOnce(new Error('unban failed')); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/unlock.test.js b/tests/commands/unlock.test.js new file mode 100644 index 000000000..63ac320ec --- /dev/null +++ b/tests/commands/unlock.test.js @@ -0,0 +1,145 @@ +import { ChannelType } from 'discord.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 2, action: 'unlock', id: 2 }), + sendModLogEmbed: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { logging: { channels: { default: '123' } } }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/unlock.js'; +import { createCase, sendModLogEmbed } from '../../src/modules/moderation.js'; + +describe('unlock command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createInteraction = (overrides = {}) => ({ + options: { + getChannel: vi.fn().mockReturnValue(null), + getString: vi.fn().mockReturnValue(null), + }, + channel: { + id: 'chan1', + name: 'general', + type: ChannelType.GuildText, + permissionOverwrites: { edit: vi.fn().mockResolvedValue(undefined) }, + send: vi.fn().mockResolvedValue(undefined), + }, + guild: { + id: 'guild1', + roles: { everyone: { id: 'everyone-role' } }, + }, + user: { id: 'mod1', tag: 'Mod#0001', toString: () => '<@mod1>' }, + client: { channels: { fetch: vi.fn() } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + ...overrides, + }); + + it('should export data with name "unlock"', () => { + expect(data.name).toBe('unlock'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should unlock the current channel with SendMessages reset to null', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(interaction.channel.permissionOverwrites.edit).toHaveBeenCalledWith( + interaction.guild.roles.everyone, + { SendMessages: null }, + ); + expect(interaction.channel.send).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'unlock', + targetId: 'chan1', + targetTag: '#general', + }), + ); + expect(sendModLogEmbed).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has been unlocked'), + ); + }); + + it('should unlock a specified channel', async () => { + const targetChannel = { + id: 'chan2', + name: 'announcements', + type: ChannelType.GuildText, + permissionOverwrites: { edit: vi.fn().mockResolvedValue(undefined) }, + send: vi.fn().mockResolvedValue(undefined), + }; + const interaction = createInteraction(); + interaction.options.getChannel.mockReturnValue(targetChannel); + + await execute(interaction); + + expect(targetChannel.permissionOverwrites.edit).toHaveBeenCalledWith( + interaction.guild.roles.everyone, + { SendMessages: null }, + ); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + targetId: 'chan2', + targetTag: '#announcements', + }), + ); + }); + + it('should include reason in notification and case', async () => { + const interaction = createInteraction(); + interaction.options.getString.mockReturnValue('raid is over'); + + await execute(interaction); + + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + reason: 'raid is over', + }), + ); + }); + + it('should reject non-text channels', async () => { + const interaction = createInteraction(); + interaction.channel.type = ChannelType.GuildVoice; + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('text channels')); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const interaction = createInteraction(); + interaction.channel.permissionOverwrites.edit.mockRejectedValueOnce(new Error('Missing perms')); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/untimeout.test.js b/tests/commands/untimeout.test.js new file mode 100644 index 000000000..6ab2e02ab --- /dev/null +++ b/tests/commands/untimeout.test.js @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'untimeout', id: 1 }), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkHierarchy: vi.fn().mockReturnValue(null), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/untimeout.js'; +import { checkHierarchy, createCase } from '../../src/modules/moderation.js'; + +describe('untimeout command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createInteraction = () => { + const mockMember = { + id: 'user1', + user: { id: 'user1', tag: 'User#0001' }, + roles: { highest: { position: 5 } }, + timeout: vi.fn().mockResolvedValue(undefined), + }; + + return { + interaction: { + options: { + getMember: vi.fn().mockReturnValue(mockMember), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + return null; + }), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { me: { roles: { highest: { position: 10 } } } }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }, + mockMember, + }; + }; + + it('should export data with name "untimeout"', () => { + expect(data.name).toBe('untimeout'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should remove timeout from a user successfully', async () => { + const { interaction, mockMember } = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(mockMember.timeout).toHaveBeenCalledWith(null, 'test reason'); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'untimeout', + targetId: 'user1', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('has had their timeout removed'), + ); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const { interaction, mockMember } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(mockMember.timeout).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const { interaction } = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/commands/warn.test.js b/tests/commands/warn.test.js new file mode 100644 index 000000000..8d132ba5f --- /dev/null +++ b/tests/commands/warn.test.js @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/modules/moderation.js', () => ({ + createCase: vi.fn().mockResolvedValue({ case_number: 1, action: 'warn', id: 1 }), + sendDmNotification: vi.fn().mockResolvedValue(undefined), + sendModLogEmbed: vi.fn().mockResolvedValue({ id: 'msg1' }), + checkEscalation: vi.fn().mockResolvedValue(null), + checkHierarchy: vi.fn().mockReturnValue(null), + shouldSendDm: vi.fn().mockReturnValue(true), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + logging: { channels: { default: '123' } }, + }, + }), +})); + +vi.mock('../../src/logger.js', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +import { adminOnly, data, execute } from '../../src/commands/warn.js'; +import { + checkEscalation, + checkHierarchy, + createCase, + sendDmNotification, + sendModLogEmbed, +} from '../../src/modules/moderation.js'; + +describe('warn command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockMember = { + id: 'user1', + user: { id: 'user1', tag: 'User#0001' }, + roles: { highest: { position: 5 } }, + }; + + const createInteraction = () => ({ + options: { + getMember: vi.fn().mockReturnValue(mockMember), + getString: vi.fn().mockImplementation((name) => { + if (name === 'reason') return 'test reason'; + return null; + }), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { me: { roles: { highest: { position: 10 } } } }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + client: { user: { id: 'bot1', tag: 'Bot#0001' } }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferred: true, + }); + + it('should export data with name "warn"', () => { + expect(data.name).toBe('warn'); + }); + + it('should export adminOnly as true', () => { + expect(adminOnly).toBe(true); + }); + + it('should warn a user successfully', async () => { + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(sendDmNotification).toHaveBeenCalled(); + expect(createCase).toHaveBeenCalledWith( + 'guild1', + expect.objectContaining({ + action: 'warn', + targetId: 'user1', + targetTag: 'User#0001', + }), + ); + expect(sendModLogEmbed).toHaveBeenCalled(); + expect(checkEscalation).toHaveBeenCalledWith( + interaction.client, + 'guild1', + 'user1', + 'bot1', + 'Bot#0001', + expect.objectContaining({ + moderation: expect.any(Object), + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('has been warned')); + }); + + it('should reject when hierarchy check fails', async () => { + checkHierarchy.mockReturnValueOnce( + '❌ You cannot moderate a member with an equal or higher role than yours.', + ); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('cannot moderate')); + expect(createCase).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + createCase.mockRejectedValueOnce(new Error('DB error')); + const interaction = createInteraction(); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('An error occurred'), + ); + }); +}); diff --git a/tests/db.test.js b/tests/db.test.js index c2a8bef8f..0a92e71df 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -70,7 +70,7 @@ describe('db module', () => { afterEach(async () => { try { await dbModule.closeDb(); - } catch (err) { + } catch { // ignore cleanup failures } @@ -106,6 +106,14 @@ describe('db module', () => { ); expect(queries.some((q) => q.includes('idx_conversations_channel_created'))).toBe(true); expect(queries.some((q) => q.includes('idx_conversations_created_at'))).toBe(true); + + // Moderation tables + expect(queries.some((q) => q.includes('CREATE TABLE IF NOT EXISTS mod_cases'))).toBe(true); + expect( + queries.some((q) => q.includes('CREATE TABLE IF NOT EXISTS mod_scheduled_actions')), + ).toBe(true); + expect(queries.some((q) => q.includes('idx_mod_cases_guild_target'))).toBe(true); + expect(queries.some((q) => q.includes('idx_mod_scheduled_actions_pending'))).toBe(true); }); it('should return existing pool on second call', async () => { diff --git a/tests/index.test.js b/tests/index.test.js index 0ecfb612f..eb753db9c 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -42,6 +42,11 @@ const mocks = vi.hoisted(() => ({ registerEventHandlers: vi.fn(), }, + moderation: { + startTempbanScheduler: vi.fn(), + stopTempbanScheduler: vi.fn(), + }, + health: { instance: {}, getInstance: vi.fn(), @@ -137,6 +142,11 @@ vi.mock('../src/modules/events.js', () => ({ registerEventHandlers: mocks.events.registerEventHandlers, })); +vi.mock('../src/modules/moderation.js', () => ({ + startTempbanScheduler: mocks.moderation.startTempbanScheduler, + stopTempbanScheduler: mocks.moderation.stopTempbanScheduler, +})); + vi.mock('../src/utils/health.js', () => ({ HealthMonitor: { getInstance: mocks.health.getInstance, @@ -152,6 +162,16 @@ vi.mock('../src/utils/registerCommands.js', () => ({ registerCommands: mocks.registerCommands, })); +async function settleStartupHops() { + // startup() currently requires 3 microtask hops plus 1 macrotask hop + // to settle async initialization side-effects in this test harness. + // If startup() adds/removes awaits, update this helper's hop count. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); +} + async function importIndex({ token = 'test-token', databaseUrl = 'postgres://db', @@ -186,7 +206,7 @@ async function importIndex({ mocks.logger.warn.mockReset(); mocks.logger.error.mockReset(); - mocks.db.initDb.mockReset().mockResolvedValue(undefined); + mocks.db.initDb.mockReset().mockResolvedValue({ query: vi.fn() }); mocks.db.closeDb.mockReset().mockResolvedValue(undefined); mocks.ai.getConversationHistory.mockReset().mockReturnValue(new Map()); @@ -209,6 +229,8 @@ async function importIndex({ }); mocks.events.registerEventHandlers.mockReset(); + mocks.moderation.startTempbanScheduler.mockReset(); + mocks.moderation.stopTempbanScheduler.mockReset(); mocks.health.getInstance.mockReset().mockReturnValue({}); mocks.permissions.hasPermission.mockReset().mockReturnValue(true); mocks.permissions.getPermissionError.mockReset().mockReturnValue('nope'); @@ -240,13 +262,7 @@ async function importIndex({ }); const mod = await import('../src/index.js'); - // Pragmatic workaround: settle async microtasks from startup(). - // The 3 hops (2x Promise.resolve + 1x setImmediate) are coupled to - // the current async hop count in startup(). If startup() gains more - // awaits, this settling sequence may need to be extended. - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await settleStartupHops(); return mod; } @@ -273,6 +289,7 @@ describe('index.js', () => { expect(mocks.db.initDb).toHaveBeenCalled(); expect(mocks.config.loadConfig).toHaveBeenCalled(); expect(mocks.events.registerEventHandlers).toHaveBeenCalled(); + expect(mocks.moderation.startTempbanScheduler).toHaveBeenCalled(); expect(mocks.client.login).toHaveBeenCalledWith('abc'); }); @@ -283,6 +300,7 @@ describe('index.js', () => { expect(mocks.logger.warn).toHaveBeenCalledWith( 'DATABASE_URL not set — using config.json only (no persistence)', ); + expect(mocks.moderation.startTempbanScheduler).not.toHaveBeenCalled(); expect(mocks.client.login).toHaveBeenCalledWith('abc'); }); diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index d28eea5e1..ca430eb20 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -10,8 +10,8 @@ vi.mock('../../src/modules/config.js', () => ({ })), })); -import { info, error as logError, warn as logWarn } from '../../src/logger.js'; import { + _setPoolGetter, addToHistory, generateResponse, getConversationHistory, @@ -21,7 +21,6 @@ import { setPool, startConversationCleanup, stopConversationCleanup, - _setPoolGetter, } from '../../src/modules/ai.js'; import { getConfig } from '../../src/modules/config.js'; @@ -116,10 +115,10 @@ describe('ai module', () => { // After reversing, oldest comes first expect(history[0].content).toBe('from db'); expect(history[1].content).toBe('response'); - expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT role, content FROM conversations'), [ - 'ch-new', - 20, - ]); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('SELECT role, content FROM conversations'), + ['ch-new', 20], + ); }); }); @@ -236,7 +235,10 @@ describe('ai module', () => { startConversationCleanup(); await vi.waitFor(() => { - expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM conversations'), [30]); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM conversations'), + [30], + ); }); stopConversationCleanup(); diff --git a/tests/modules/moderation.test.js b/tests/modules/moderation.test.js new file mode 100644 index 000000000..2f2c2871b --- /dev/null +++ b/tests/modules/moderation.test.js @@ -0,0 +1,729 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + moderation: { + dmNotifications: { warn: true, kick: true, timeout: true, ban: true }, + escalation: { enabled: false, thresholds: [] }, + logging: { channels: { default: '123', warns: null, bans: '456' } }, + }, + }), +})); + +vi.mock('../../src/utils/duration.js', () => ({ + parseDuration: vi.fn().mockReturnValue(3600000), + formatDuration: vi.fn().mockReturnValue('1 hour'), +})); + +import { getPool } from '../../src/db.js'; +import { error as loggerError } from '../../src/logger.js'; +import { + checkEscalation, + checkHierarchy, + createCase, + scheduleAction, + sendDmNotification, + sendModLogEmbed, + shouldSendDm, + startTempbanScheduler, + stopTempbanScheduler, +} from '../../src/modules/moderation.js'; + +describe('moderation module', () => { + let mockPool; + let mockConnection; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConnection = { + query: vi.fn(), + release: vi.fn(), + }; + + mockPool = { + query: vi.fn(), + connect: vi.fn().mockResolvedValue(mockConnection), + }; + + getPool.mockReturnValue(mockPool); + }); + + afterEach(() => { + stopTempbanScheduler(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('createCase', () => { + it('should insert a case atomically and return it', async () => { + mockConnection.query + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({}) // advisory lock + .mockResolvedValueOnce({ + rows: [ + { + id: 1, + guild_id: 'guild1', + case_number: 4, + action: 'warn', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test reason', + duration: null, + expires_at: null, + created_at: new Date().toISOString(), + }, + ], + }) + .mockResolvedValueOnce({}); // COMMIT + + const result = await createCase('guild1', { + action: 'warn', + targetId: 'user1', + targetTag: 'User#0001', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + reason: 'test reason', + }); + + expect(result.case_number).toBe(4); + expect(mockConnection.query).toHaveBeenCalledWith('BEGIN'); + expect(mockConnection.query).toHaveBeenCalledWith( + 'SELECT pg_advisory_xact_lock(hashtext($1))', + ['guild1'], + ); + expect(mockConnection.query).toHaveBeenCalledWith('COMMIT'); + expect(mockConnection.release).toHaveBeenCalled(); + }); + + it('should rollback transaction when insert fails', async () => { + mockConnection.query + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({}) // advisory lock + .mockRejectedValueOnce(new Error('insert failed')) // INSERT + .mockResolvedValueOnce({}); // ROLLBACK + + await expect( + createCase('guild1', { + action: 'warn', + targetId: 'user1', + targetTag: 'User#0001', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + }), + ).rejects.toThrow('insert failed'); + + expect(mockConnection.query).toHaveBeenCalledWith('ROLLBACK'); + expect(mockConnection.release).toHaveBeenCalled(); + }); + }); + + describe('scheduleAction', () => { + it('should insert a scheduled action row', async () => { + mockPool.query.mockResolvedValue({ rows: [{ id: 1, action: 'unban' }] }); + + const result = await scheduleAction('guild1', 'unban', 'user1', 10, new Date()); + + expect(result).toEqual({ id: 1, action: 'unban' }); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO mod_scheduled_actions'), + expect.arrayContaining(['guild1', 'unban', 'user1', 10]), + ); + }); + }); + + describe('sendDmNotification', () => { + it('should send DM embed to member', async () => { + const mockSend = vi.fn().mockResolvedValue(undefined); + const member = { send: mockSend }; + + await sendDmNotification(member, 'warn', 'test reason', 'Test Server'); + + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ embeds: expect.any(Array) })); + }); + + it('should use fallback reason when none provided', async () => { + const mockSend = vi.fn().mockResolvedValue(undefined); + const member = { send: mockSend }; + + await sendDmNotification(member, 'kick', null, 'Test Server'); + + const embed = mockSend.mock.calls[0][0].embeds[0]; + const fields = embed.toJSON().fields; + expect(fields[0].value).toBe('No reason provided'); + }); + + it('should silently catch DM failures', async () => { + const member = { send: vi.fn().mockRejectedValue(new Error('DMs disabled')) }; + + await sendDmNotification(member, 'ban', 'reason', 'Server'); + }); + }); + + describe('sendModLogEmbed', () => { + it('should send embed to action-specific channel', async () => { + const mockSendMessage = vi.fn().mockResolvedValue({ id: 'msg1' }); + const mockChannel = { send: mockSendMessage }; + const client = { + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + }; + const config = { + moderation: { + logging: { channels: { default: '123', bans: '456' } }, + }, + }; + mockPool.query.mockResolvedValue({ rows: [] }); // update log_message_id + + const caseData = { + id: 1, + case_number: 1, + action: 'ban', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test', + created_at: new Date().toISOString(), + }; + + const result = await sendModLogEmbed(client, config, caseData); + + expect(client.channels.fetch).toHaveBeenCalledWith('456'); + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE mod_cases SET log_message_id = $1 WHERE id = $2', + ['msg1', 1], + ); + expect(result).toEqual({ id: 'msg1' }); + }); + + it('should fall back to default channel when action-specific channel is missing', async () => { + const mockSendMessage = vi.fn().mockResolvedValue({ id: 'msg-default' }); + const mockChannel = { send: mockSendMessage }; + const client = { channels: { fetch: vi.fn().mockResolvedValue(mockChannel) } }; + const config = { + moderation: { + logging: { channels: { default: '123', warns: null } }, + }, + }; + mockPool.query.mockResolvedValue({ rows: [] }); + + await sendModLogEmbed(client, config, { + id: 2, + case_number: 2, + action: 'warn', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test', + }); + + expect(client.channels.fetch).toHaveBeenCalledWith('123'); + }); + + it('should include duration field when provided', async () => { + const mockSend = vi.fn().mockResolvedValue({ id: 'msg3' }); + const mockChannel = { send: mockSend }; + const client = { + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + }; + const config = { + moderation: { logging: { channels: { default: '123' } } }, + }; + mockPool.query.mockResolvedValue({ rows: [] }); + + await sendModLogEmbed(client, config, { + id: 3, + case_number: 3, + action: 'timeout', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test', + duration: '1h', + created_at: new Date().toISOString(), + }); + + const embed = mockSend.mock.calls[0][0].embeds[0]; + const fields = embed.toJSON().fields; + expect(fields.some((f) => f.name === 'Duration')).toBe(true); + }); + + it('should log when storing log_message_id fails', async () => { + const mockChannel = { send: vi.fn().mockResolvedValue({ id: 'msg1' }) }; + const client = { channels: { fetch: vi.fn().mockResolvedValue(mockChannel) } }; + const config = { moderation: { logging: { channels: { default: '123' } } } }; + mockPool.query.mockRejectedValue(new Error('db write failed')); + + await sendModLogEmbed(client, config, { + id: 4, + case_number: 4, + action: 'warn', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test', + }); + + expect(loggerError).toHaveBeenCalledWith( + 'Failed to store log message ID', + expect.objectContaining({ caseId: 4, messageId: 'msg1' }), + ); + }); + + it('should return null when no log channels are configured', async () => { + const result = await sendModLogEmbed( + { channels: { fetch: vi.fn() } }, + { moderation: {} }, + { action: 'warn' }, + ); + + expect(result).toBeNull(); + }); + + it('should return null when action channel and default channel are both missing', async () => { + const result = await sendModLogEmbed( + { channels: { fetch: vi.fn() } }, + { moderation: { logging: { channels: { warns: null, default: null } } } }, + { action: 'warn' }, + ); + + expect(result).toBeNull(); + }); + + it('should return null when channel cannot be fetched', async () => { + const client = { channels: { fetch: vi.fn().mockRejectedValue(new Error('no channel')) } }; + const config = { moderation: { logging: { channels: { default: '123' } } } }; + + const result = await sendModLogEmbed(client, config, { + action: 'warn', + case_number: 1, + }); + + expect(result).toBeNull(); + }); + + it('should return null when sending embed fails', async () => { + const mockChannel = { send: vi.fn().mockRejectedValue(new Error('cannot send')) }; + const client = { channels: { fetch: vi.fn().mockResolvedValue(mockChannel) } }; + const config = { moderation: { logging: { channels: { default: '123' } } } }; + + const result = await sendModLogEmbed(client, config, { + id: 9, + action: 'warn', + case_number: 9, + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'test', + }); + + expect(result).toBeNull(); + expect(loggerError).toHaveBeenCalledWith( + 'Failed to send mod log embed', + expect.objectContaining({ channelId: '123' }), + ); + }); + }); + + describe('checkEscalation', () => { + it('should return null when escalation is disabled', async () => { + const config = { moderation: { escalation: { enabled: false } } }; + const result = await checkEscalation(null, 'guild1', 'user1', 'mod1', 'Mod#0001', config); + expect(result).toBeNull(); + }); + + it('should return null when no thresholds are configured', async () => { + const config = { moderation: { escalation: { enabled: true, thresholds: [] } } }; + const result = await checkEscalation(null, 'guild1', 'user1', 'mod1', 'Mod#0001', config); + expect(result).toBeNull(); + }); + + it('should return null when warn count is below threshold', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const config = { + moderation: { + escalation: { + enabled: true, + thresholds: [{ warns: 3, withinDays: 7, action: 'timeout', duration: '1h' }], + }, + }, + }; + + const result = await checkEscalation( + { guilds: { fetch: vi.fn() } }, + 'guild1', + 'user1', + 'mod1', + 'Mod#0001', + config, + ); + + expect(result).toBeNull(); + }); + + it('should trigger escalation when threshold is met', async () => { + const mockMember = { + timeout: vi.fn().mockResolvedValue(undefined), + user: { tag: 'User#0001' }, + }; + const mockGuild = { + members: { + fetch: vi.fn().mockResolvedValue(mockMember), + ban: vi.fn(), + }, + }; + const mockClient = { + guilds: { fetch: vi.fn().mockResolvedValue(mockGuild) }, + channels: { + fetch: vi.fn().mockResolvedValue({ send: vi.fn().mockResolvedValue({ id: 'msg' }) }), + }, + }; + + // warn count query, then log_message_id update from sendModLogEmbed + mockPool.query + .mockResolvedValueOnce({ rows: [{ count: 3 }] }) + .mockResolvedValueOnce({ rows: [] }); + + // createCase transaction queries + mockConnection.query + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({}) // advisory lock + .mockResolvedValueOnce({ + rows: [ + { + id: 6, + case_number: 6, + action: 'timeout', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'Auto-escalation: 3 warns in 7 days', + duration: '1h', + created_at: new Date().toISOString(), + }, + ], + }) + .mockResolvedValueOnce({}); // COMMIT + + const config = { + moderation: { + escalation: { + enabled: true, + thresholds: [{ warns: 3, withinDays: 7, action: 'timeout', duration: '1h' }], + }, + logging: { channels: { default: '123' } }, + }, + }; + + const result = await checkEscalation( + mockClient, + 'guild1', + 'user1', + 'mod1', + 'Mod#0001', + config, + ); + + expect(result).toBeTruthy(); + expect(result.action).toBe('timeout'); + expect(mockMember.timeout).toHaveBeenCalled(); + }); + + it('should support ban escalation action', async () => { + const mockGuild = { + members: { + fetch: vi.fn().mockResolvedValue({ user: { tag: 'User#0001' } }), + ban: vi.fn().mockResolvedValue(undefined), + }, + }; + const mockClient = { + guilds: { fetch: vi.fn().mockResolvedValue(mockGuild) }, + channels: { + fetch: vi.fn().mockResolvedValue({ send: vi.fn().mockResolvedValue({ id: 'msg' }) }), + }, + }; + + mockPool.query + .mockResolvedValueOnce({ rows: [{ count: 5 }] }) + .mockResolvedValueOnce({ rows: [] }); + + mockConnection.query + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({}) // advisory lock + .mockResolvedValueOnce({ + rows: [ + { + id: 11, + case_number: 11, + action: 'ban', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'mod1', + moderator_tag: 'Mod#0001', + reason: 'Auto-escalation: 5 warns in 30 days', + created_at: new Date().toISOString(), + }, + ], + }) + .mockResolvedValueOnce({}); // COMMIT + + const config = { + moderation: { + escalation: { + enabled: true, + thresholds: [{ warns: 5, withinDays: 30, action: 'ban' }], + }, + logging: { channels: { default: '123' } }, + }, + }; + + const result = await checkEscalation( + mockClient, + 'guild1', + 'user1', + 'mod1', + 'Mod#0001', + config, + ); + + expect(result).toBeTruthy(); + expect(mockGuild.members.ban).toHaveBeenCalledWith('user1', { reason: expect.any(String) }); + }); + }); + + describe('checkHierarchy', () => { + it('should return null when moderator is higher', () => { + const moderator = { roles: { highest: { position: 10 } } }; + const target = { roles: { highest: { position: 5 } } }; + expect(checkHierarchy(moderator, target)).toBeNull(); + }); + + it('should return error when target is equal or higher', () => { + const moderator = { roles: { highest: { position: 5 } } }; + const target = { roles: { highest: { position: 5 } } }; + expect(checkHierarchy(moderator, target)).toContain('cannot moderate'); + }); + + it('should return error when target is higher', () => { + const moderator = { roles: { highest: { position: 3 } } }; + const target = { roles: { highest: { position: 10 } } }; + expect(checkHierarchy(moderator, target)).toContain('cannot moderate'); + }); + + it('should return null when botMember is null', () => { + const moderator = { roles: { highest: { position: 10 } } }; + const target = { roles: { highest: { position: 5 } } }; + expect(checkHierarchy(moderator, target, null)).toBeNull(); + }); + + it('should return error when bot role is too low', () => { + const moderator = { roles: { highest: { position: 10 } } }; + const target = { roles: { highest: { position: 5 } } }; + const botMember = { roles: { highest: { position: 4 } } }; + expect(checkHierarchy(moderator, target, botMember)).toContain('my role is not high enough'); + }); + + it('should pass when bot role is higher than target', () => { + const moderator = { roles: { highest: { position: 10 } } }; + const target = { roles: { highest: { position: 5 } } }; + const botMember = { roles: { highest: { position: 8 } } }; + expect(checkHierarchy(moderator, target, botMember)).toBeNull(); + }); + }); + + describe('shouldSendDm', () => { + it('should return true when enabled', () => { + const config = { moderation: { dmNotifications: { warn: true } } }; + expect(shouldSendDm(config, 'warn')).toBe(true); + }); + + it('should return false when disabled', () => { + const config = { moderation: { dmNotifications: { warn: false } } }; + expect(shouldSendDm(config, 'warn')).toBe(false); + }); + + it('should return false when action is not configured', () => { + const config = { moderation: {} }; + expect(shouldSendDm(config, 'warn')).toBe(false); + }); + }); + + describe('tempban scheduler', () => { + it('should start and stop scheduler idempotently', async () => { + vi.useFakeTimers(); + mockPool.query.mockResolvedValue({ rows: [] }); + const client = { + guilds: { fetch: vi.fn() }, + users: { fetch: vi.fn() }, + user: { id: 'bot1', tag: 'Bot#0001' }, + }; + + startTempbanScheduler(client); + startTempbanScheduler(client); + + await vi.advanceTimersByTimeAsync(100); + + stopTempbanScheduler(); + stopTempbanScheduler(); + }); + + it('should process expired tempbans on poll', async () => { + const mockGuild = { + members: { unban: vi.fn().mockResolvedValue(undefined) }, + }; + const mockClient = { + guilds: { fetch: vi.fn().mockResolvedValue(mockGuild) }, + users: { fetch: vi.fn().mockResolvedValue({ tag: 'User#0001' }) }, + user: { id: 'bot1', tag: 'Bot#0001' }, + channels: { + fetch: vi.fn().mockResolvedValue({ send: vi.fn().mockResolvedValue({ id: 'msg' }) }), + }, + }; + + mockPool.query + .mockResolvedValueOnce({ + rows: [ + { + id: 1, + guild_id: 'guild1', + action: 'unban', + target_id: 'user1', + case_id: 5, + execute_at: new Date(), + executed: false, + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ id: 1 }] }) // claim executed row + .mockResolvedValueOnce({ rows: [] }); // log_message_id update + + mockConnection.query + .mockResolvedValueOnce({}) // BEGIN + .mockResolvedValueOnce({}) // advisory lock + .mockResolvedValueOnce({ + rows: [ + { + id: 7, + case_number: 7, + action: 'unban', + target_id: 'user1', + target_tag: 'User#0001', + moderator_id: 'bot1', + moderator_tag: 'Bot#0001', + reason: 'Tempban expired (case #5)', + created_at: new Date().toISOString(), + }, + ], + }) + .mockResolvedValueOnce({}); // COMMIT + + vi.useFakeTimers(); + startTempbanScheduler(mockClient); + await vi.advanceTimersByTimeAsync(100); + + expect(mockGuild.members.unban).toHaveBeenCalledWith('user1', 'Tempban expired'); + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE mod_scheduled_actions SET executed = TRUE WHERE id = $1 AND executed = FALSE RETURNING id', + [1], + ); + + stopTempbanScheduler(); + }); + + it('should skip rows that were already claimed by another poll', async () => { + const mockClient = { + guilds: { fetch: vi.fn() }, + users: { fetch: vi.fn() }, + user: { id: 'bot1', tag: 'Bot#0001' }, + channels: { fetch: vi.fn() }, + }; + + mockPool.query + .mockResolvedValueOnce({ + rows: [ + { + id: 44, + guild_id: 'guild1', + action: 'unban', + target_id: 'user1', + case_id: 3, + execute_at: new Date(), + executed: false, + }, + ], + }) + .mockResolvedValueOnce({ rows: [] }); // claim failed + + vi.useFakeTimers(); + startTempbanScheduler(mockClient); + await vi.advanceTimersByTimeAsync(100); + + expect(mockClient.guilds.fetch).not.toHaveBeenCalled(); + + stopTempbanScheduler(); + }); + + it('should mark claimed tempban as executed even when unban fails', async () => { + const mockGuild = { + members: { unban: vi.fn().mockRejectedValue(new Error('unban failed')) }, + }; + const mockClient = { + guilds: { fetch: vi.fn().mockResolvedValue(mockGuild) }, + users: { fetch: vi.fn() }, + user: { id: 'bot1', tag: 'Bot#0001' }, + channels: { fetch: vi.fn() }, + }; + + mockPool.query + .mockResolvedValueOnce({ + rows: [ + { + id: 99, + guild_id: 'guild1', + action: 'unban', + target_id: 'user1', + case_id: 5, + execute_at: new Date(), + executed: false, + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ id: 99 }] }); // claim executed row + + vi.useFakeTimers(); + startTempbanScheduler(mockClient); + await vi.advanceTimersByTimeAsync(100); + + expect(mockPool.query).toHaveBeenCalledWith( + 'UPDATE mod_scheduled_actions SET executed = TRUE WHERE id = $1 AND executed = FALSE RETURNING id', + [99], + ); + expect(loggerError).toHaveBeenCalledWith( + 'Failed to process expired tempban', + expect.objectContaining({ id: 99, targetId: 'user1' }), + ); + + stopTempbanScheduler(); + }); + }); +}); diff --git a/tests/utils/duration.test.js b/tests/utils/duration.test.js new file mode 100644 index 000000000..4fd0e3cdd --- /dev/null +++ b/tests/utils/duration.test.js @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { formatDuration, parseDuration } from '../../src/utils/duration.js'; + +describe('parseDuration', () => { + describe('valid inputs', () => { + it('parses seconds', () => { + expect(parseDuration('30s')).toBe(30000); + expect(parseDuration('1s')).toBe(1000); + }); + + it('parses minutes', () => { + expect(parseDuration('5m')).toBe(300000); + expect(parseDuration('1m')).toBe(60000); + }); + + it('parses hours', () => { + expect(parseDuration('1h')).toBe(3600000); + expect(parseDuration('24h')).toBe(86400000); + }); + + it('parses days', () => { + expect(parseDuration('7d')).toBe(604800000); + expect(parseDuration('1d')).toBe(86400000); + }); + + it('parses weeks', () => { + expect(parseDuration('2w')).toBe(1209600000); + expect(parseDuration('1w')).toBe(604800000); + }); + }); + + describe('invalid inputs', () => { + it('returns null for null', () => { + expect(parseDuration(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(parseDuration(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseDuration('')).toBeNull(); + }); + + it('returns null for non-string types', () => { + expect(parseDuration(123)).toBeNull(); + expect(parseDuration(true)).toBeNull(); + expect(parseDuration({})).toBeNull(); + }); + + it('returns null for string without unit', () => { + expect(parseDuration('abc')).toBeNull(); + expect(parseDuration('123')).toBeNull(); + }); + + it('returns null for zero duration', () => { + expect(parseDuration('0s')).toBeNull(); + expect(parseDuration('0m')).toBeNull(); + }); + + it('returns null for negative duration', () => { + expect(parseDuration('-1h')).toBeNull(); + expect(parseDuration('-5m')).toBeNull(); + }); + + it('returns null for unsupported units', () => { + expect(parseDuration('5y')).toBeNull(); + expect(parseDuration('3x')).toBeNull(); + }); + }); + + describe('case insensitivity', () => { + it('handles uppercase units', () => { + expect(parseDuration('1H')).toBe(3600000); + expect(parseDuration('7D')).toBe(604800000); + expect(parseDuration('5M')).toBe(300000); + expect(parseDuration('30S')).toBe(30000); + expect(parseDuration('2W')).toBe(1209600000); + }); + }); + + describe('whitespace handling', () => { + it('trims leading and trailing whitespace', () => { + expect(parseDuration(' 1h ')).toBe(3600000); + expect(parseDuration(' 5m ')).toBe(300000); + }); + + it('handles whitespace between number and unit', () => { + expect(parseDuration('1 h')).toBe(3600000); + expect(parseDuration('5 m')).toBe(300000); + }); + }); +}); + +describe('formatDuration', () => { + it('formats weeks', () => { + expect(formatDuration(604800000)).toBe('1 week'); + expect(formatDuration(1209600000)).toBe('2 weeks'); + }); + + it('formats days', () => { + expect(formatDuration(86400000)).toBe('1 day'); + expect(formatDuration(172800000)).toBe('2 days'); + }); + + it('formats hours', () => { + expect(formatDuration(3600000)).toBe('1 hour'); + expect(formatDuration(7200000)).toBe('2 hours'); + }); + + it('formats minutes', () => { + expect(formatDuration(60000)).toBe('1 minute'); + expect(formatDuration(300000)).toBe('5 minutes'); + }); + + it('formats seconds', () => { + expect(formatDuration(1000)).toBe('1 second'); + expect(formatDuration(30000)).toBe('30 seconds'); + }); + + it('returns "0 seconds" for zero', () => { + expect(formatDuration(0)).toBe('0 seconds'); + }); + + it('returns "0 seconds" for negative values', () => { + expect(formatDuration(-1000)).toBe('0 seconds'); + }); + + it('returns "0 seconds" for non-number input', () => { + expect(formatDuration('abc')).toBe('0 seconds'); + expect(formatDuration(null)).toBe('0 seconds'); + }); + + it('uses the largest fitting unit', () => { + expect(formatDuration(604800000)).toBe('1 week'); + expect(formatDuration(86400000)).toBe('1 day'); + }); +}); + +describe('round-trip', () => { + it('parseDuration then formatDuration returns readable string', () => { + expect(formatDuration(parseDuration('1h'))).toBe('1 hour'); + expect(formatDuration(parseDuration('7d'))).toBe('1 week'); + expect(formatDuration(parseDuration('30s'))).toBe('30 seconds'); + expect(formatDuration(parseDuration('5m'))).toBe('5 minutes'); + expect(formatDuration(parseDuration('2w'))).toBe('2 weeks'); + }); +});