Merge pull request #9 from Fretuks/implement-configurable-command-settings

Add per-guild moderation settings and persistent moderation records
This commit is contained in:
2026-01-27 11:46:53 +01:00
committed by GitHub
10 changed files with 298 additions and 19 deletions
+9
View File
@@ -1,4 +1,5 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
const { recordModerationAction } = require('../../services/moderationRecords');
module.exports = {
permissionGroup: 'moderation',
@@ -31,6 +32,14 @@ module.exports = {
}
await member.kick(reason);
await recordModerationAction({
guildId: interaction.guildId,
userId: user.id,
action: 'kick',
moderatorId: interaction.user.id,
reason,
metadata: {},
});
await interaction.reply(`Kicked ${user.tag} for: ${reason}`);
} catch (error) {
+10
View File
@@ -1,4 +1,5 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
const { recordModerationAction } = require('../../services/moderationRecords');
module.exports = {
permissionGroup: 'moderation',
@@ -39,6 +40,15 @@ module.exports = {
}
await member.timeout(durationMs, reason);
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'mute',
moderatorId: interaction.user.id,
reason,
metadata: { durationMinutes: duration },
stateUpdates: { mutedUntil: new Date(Date.now() + durationMs) },
});
return interaction.reply({
content: `Muted ${target.tag} for **${duration} minutes**. Reason: ${reason}`,
+15 -1
View File
@@ -1,5 +1,6 @@
const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require("discord.js");
const { connectToDatabase } = require('../../db.js');
const { recordModerationAction } = require('../../services/moderationRecords');
module.exports = {
mod: true,
@@ -73,11 +74,24 @@ module.exports = {
const tempBanCollection = db.collection("tempban");
// 3. Insert the ban record
await tempBanCollection.insertOne({
const banRecord = {
guildId: interaction.guild.id,
userId: user.id,
banTime: banUntil,
reason: reason,
};
await tempBanCollection.insertOne(banRecord);
await recordModerationAction({
guildId: interaction.guild.id,
userId: user.id,
action: 'temp-ban',
moderatorId: interaction.user.id,
reason,
metadata: { durationMs: durationms },
stateUpdates: {
isBanned: true,
bannedUntil: new Date(banUntil),
},
});
} catch (err) {
console.error(err);
+101 -1
View File
@@ -1,5 +1,8 @@
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { addWarning } = require('../../services/warnings');
const { getGuildSettings } = require('../../services/guildSettings');
const { recordModerationAction } = require('../../services/moderationRecords');
const { connectToDatabase } = require('../../db');
module.exports = {
permissionGroup: 'moderation',
@@ -29,13 +32,110 @@ module.exports = {
});
}
const { warnings } = await addWarning({
const { warnings, strikeCount } = await addWarning({
guildId: interaction.guildId,
userId: target.id,
moderatorId: interaction.user.id,
reason,
});
const settings = await getGuildSettings(interaction.guildId);
const thresholds = settings.moderation?.strikeThresholds ?? {};
const muteThreshold = Number.isInteger(thresholds.mute) ? thresholds.mute : null;
const kickThreshold = Number.isInteger(thresholds.kick) ? thresholds.kick : null;
const banThreshold = Number.isInteger(thresholds.ban) ? thresholds.ban : null;
const muteDurationMinutes = settings.moderation?.muteDurationMinutes ?? 0;
const banDurationMs = settings.moderation?.banDurationMs ?? 0;
let actionTaken = null;
if (banThreshold && strikeCount === banThreshold) {
actionTaken = 'ban';
} else if (kickThreshold && strikeCount === kickThreshold) {
actionTaken = 'kick';
} else if (muteThreshold && strikeCount === muteThreshold) {
actionTaken = 'mute';
}
if (actionTaken === 'mute') {
try {
const member = await interaction.guild.members.fetch(target.id);
if (member.moderatable && muteDurationMinutes > 0) {
const durationMs = muteDurationMinutes * 60 * 1000;
await member.timeout(durationMs, 'Strike threshold reached');
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'mute',
moderatorId: interaction.user.id,
reason: 'Strike threshold reached',
metadata: { strikeCount, durationMinutes: muteDurationMinutes },
stateUpdates: { mutedUntil: new Date(Date.now() + durationMs) },
});
}
} catch (error) {
console.error('Failed to auto-mute member:', error);
}
}
if (actionTaken === 'kick') {
try {
const member = await interaction.guild.members.fetch(target.id);
if (member.kickable) {
await member.kick('Strike threshold reached');
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'kick',
moderatorId: interaction.user.id,
reason: 'Strike threshold reached',
metadata: { strikeCount },
});
}
} catch (error) {
console.error('Failed to auto-kick member:', error);
}
}
if (actionTaken === 'ban') {
try {
const banReason = 'Strike threshold reached';
const shouldTempBan = Number.isInteger(banDurationMs) && banDurationMs > 0;
await interaction.guild.bans.create(target.id, {
reason: banReason,
});
if (shouldTempBan) {
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const tempBanCollection = db.collection('tempban');
const banUntil = Date.now() + banDurationMs;
await tempBanCollection.insertOne({
guildId: interaction.guildId,
userId: target.id,
banTime: banUntil,
reason: banReason,
});
}
await recordModerationAction({
guildId: interaction.guildId,
userId: target.id,
action: 'ban',
moderatorId: interaction.user.id,
reason: banReason,
metadata: { strikeCount, banDurationMs },
stateUpdates: {
isBanned: true,
bannedUntil: shouldTempBan ? new Date(Date.now() + banDurationMs) : null,
},
});
} catch (error) {
console.error('Failed to auto-ban member:', error);
}
}
return interaction.reply({
content: `${target.tag} has been warned. Total strikes: **${warnings.length}**.`,
});
-2
View File
@@ -14,7 +14,6 @@ module.exports = {
}
try {
if (command.permissionGroup) {
const allowed = await isCommandAllowed({
guildId: interaction.guildId,
userId: interaction.user.id,
@@ -30,7 +29,6 @@ module.exports = {
});
return;
}
}
await command.execute(interaction);
} catch (error) {
+5
View File
@@ -1,5 +1,6 @@
const { Events } = require('discord.js');
const { connectToDatabase } = require('../db.js');
const { clearBanStatus } = require('../services/moderationRecords');
module.exports = {
name: Events.ClientReady,
@@ -30,6 +31,10 @@ module.exports = {
await guild.bans.remove(banData.userId);
await tempBanCollection.deleteOne({ _id: banData._id });
await clearBanStatus({
guildId: banData.guildId,
userId: banData.userId,
});
} catch (err) {
console.error('Error unbanning member:', err);
} finally {
+48
View File
@@ -0,0 +1,48 @@
const { connectToDatabase } = require('../db');
const DEFAULT_GUILD_SETTINGS = {
moderation: {
strikeThresholds: {
mute: 2,
kick: 3,
ban: 5,
},
muteDurationMinutes: 30,
banDurationMs: 0,
},
};
const normalizeGuildSettings = (settings = {}) => {
const moderation = settings.moderation ?? {};
const strikeThresholds = moderation.strikeThresholds ?? {};
return {
...DEFAULT_GUILD_SETTINGS,
...settings,
moderation: {
...DEFAULT_GUILD_SETTINGS.moderation,
...moderation,
strikeThresholds: {
...DEFAULT_GUILD_SETTINGS.moderation.strikeThresholds,
...strikeThresholds,
},
},
};
};
const getGuildSettings = async (guildId) => {
if (!guildId) {
return normalizeGuildSettings();
}
const dbClient = await connectToDatabase();
const db = dbClient.db('discord');
const collection = db.collection('guild_settings');
const settings = await collection.findOne({ guildId });
return normalizeGuildSettings(settings);
};
module.exports = {
getGuildSettings,
};
+91
View File
@@ -0,0 +1,91 @@
const { connectToDatabase } = require('../db');
const MODERATION_COLLECTION = 'moderation_records';
const getModerationCollection = async () => {
const client = await connectToDatabase();
return client.db('discord').collection(MODERATION_COLLECTION);
};
const updateStrikes = async ({ guildId, userId, strikes }) => {
const collection = await getModerationCollection();
const now = new Date();
return collection.findOneAndUpdate(
{ guildId, userId },
{
$set: {
strikes,
lastWarnedAt: now,
updatedAt: now,
},
$setOnInsert: {
guildId,
userId,
actions: [],
createdAt: now,
},
},
{ upsert: true, returnDocument: 'after' },
);
};
const recordModerationAction = async ({
guildId,
userId,
action,
moderatorId,
reason,
metadata = {},
stateUpdates = {},
}) => {
const collection = await getModerationCollection();
const now = new Date();
const actionEntry = {
action,
moderatorId,
reason,
metadata,
createdAt: now,
};
return collection.findOneAndUpdate(
{ guildId, userId },
{
$push: { actions: actionEntry },
$set: {
lastAction: action,
lastActionAt: now,
updatedAt: now,
...stateUpdates,
},
$setOnInsert: {
guildId,
userId,
strikes: 0,
createdAt: now,
},
},
{ upsert: true, returnDocument: 'after' },
);
};
const clearBanStatus = async ({ guildId, userId }) =>
recordModerationAction({
guildId,
userId,
action: 'unban',
moderatorId: null,
reason: 'Temporary ban expired',
metadata: {},
stateUpdates: {
isBanned: false,
bannedUntil: null,
},
});
module.exports = {
updateStrikes,
recordModerationAction,
clearBanStatus,
};
+1 -1
View File
@@ -40,7 +40,7 @@ const isCommandAllowed = async ({
permissions.adminUserIds.includes(userId) ||
(member && memberHasRole(member, permissions.adminRoleIds));
if (permissionGroup && isAdmin) {
if (isAdmin) {
return true;
}
+5 -1
View File
@@ -1,4 +1,5 @@
const { connectToDatabase } = require('../db');
const { updateStrikes } = require('./moderationRecords');
const WARNING_COLLECTION = 'warnings';
@@ -25,8 +26,11 @@ const addWarning = async ({ guildId, userId, moderatorId, reason }) => {
);
const warnings = result.value?.warnings ?? [];
const strikeCount = warnings.length;
return { warnings, warningEntry };
await updateStrikes({ guildId, userId, strikes: strikeCount });
return { warnings, warningEntry, strikeCount };
};
const getWarnings = async ({ guildId, userId }) => {