From 829e7247c0210955e93a0a1579b02bede76d5cbc Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Mon, 23 Feb 2026 10:30:13 +0100 Subject: [PATCH] feat: add moderation commands for ban, kick, slowdown, and timeout --- src/listeners/messageCreate/moderation/ban.ts | 110 +++++++++++++ .../messageCreate/moderation/kick.ts | 110 +++++++++++++ .../messageCreate/moderation/slowdown.ts | 136 ++++++++++++++++ .../messageCreate/moderation/timeout.ts | 147 ++++++++++++++++++ 4 files changed, 503 insertions(+) create mode 100644 src/listeners/messageCreate/moderation/ban.ts create mode 100644 src/listeners/messageCreate/moderation/kick.ts create mode 100644 src/listeners/messageCreate/moderation/slowdown.ts create mode 100644 src/listeners/messageCreate/moderation/timeout.ts diff --git a/src/listeners/messageCreate/moderation/ban.ts b/src/listeners/messageCreate/moderation/ban.ts new file mode 100644 index 0000000..5300bf0 --- /dev/null +++ b/src/listeners/messageCreate/moderation/ban.ts @@ -0,0 +1,110 @@ +import { + Client, + ContainerBuilder, + Events, + Message, + MessageFlags, + PermissionFlagsBits, + SeparatorBuilder, + SeparatorSpacingSize, + TextDisplayBuilder, +} from "discord.js"; + +async function sendResponse( + message: Message, + color: number, + title: string, + body: string, +): Promise { + const container = new ContainerBuilder().setAccentColor(color); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`## ${title}`), + ); + container.addSeparatorComponents( + new SeparatorBuilder() + .setDivider(true) + .setSpacing(SeparatorSpacingSize.Small), + ); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(body), + ); + await message.reply({ + flags: MessageFlags.IsComponentsV2, + components: [container], + }); +} + +export default async function banListener(client: Client): Promise { + client.on(Events.MessageCreate, async (message: Message) => { + if (message.author.bot || !message.guild) return; + if (!message.content.toLowerCase().startsWith("?ban")) return; + + // Check invoker permission + if (!message.member?.permissions.has(PermissionFlagsBits.BanMembers)) { + return void sendResponse( + message, + 0xff9900, + "⛔ Missing Permissions", + "You need the **Ban Members** permission to use this command.", + ); + } + + const args = message.content.trim().split(/\s+/); + args.shift(); // drop "?ban" + + const mentionMatch = args[0]?.match(/^<@!?(\d+)>$/); + if (!mentionMatch) { + return void sendResponse( + message, + 0x5865f2, + "ℹ️ Usage", + "`?ban @member [reason]`", + ); + } + + const targetId = mentionMatch[1]; + const reason = args.slice(1).join(" ") || "No reason provided"; + + const target = await message.guild.members + .fetch(targetId) + .catch(() => null); + if (!target) { + return void sendResponse( + message, + 0xff4444, + "❌ Member Not Found", + "Could not find that member in this server.", + ); + } + + if (!target.bannable) { + return void sendResponse( + message, + 0xff4444, + "❌ Cannot Ban", + "I cannot ban this member. They may have a higher role than me.", + ); + } + + try { + await message.guild.members.ban(targetId, { reason }); + console.log( + `[ban] ${message.author.tag} banned ${target.user.tag} — reason: ${reason}`, + ); + await sendResponse( + message, + 0xff4444, + "🔨 Member Banned", + `**User:** <@${targetId}> (${target.user.tag})\n**Reason:** ${reason}\n**Banned by:** ${message.author}`, + ); + } catch (error) { + console.error("[ban] Failed to ban member:", error); + await sendResponse( + message, + 0xff4444, + "❌ Ban Failed", + "An error occurred while trying to ban the member. Check my permissions and try again.", + ); + } + }); +} diff --git a/src/listeners/messageCreate/moderation/kick.ts b/src/listeners/messageCreate/moderation/kick.ts new file mode 100644 index 0000000..7ac3468 --- /dev/null +++ b/src/listeners/messageCreate/moderation/kick.ts @@ -0,0 +1,110 @@ +import { + Client, + ContainerBuilder, + Events, + Message, + MessageFlags, + PermissionFlagsBits, + SeparatorBuilder, + SeparatorSpacingSize, + TextDisplayBuilder, +} from "discord.js"; + +async function sendResponse( + message: Message, + color: number, + title: string, + body: string, +): Promise { + const container = new ContainerBuilder().setAccentColor(color); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`## ${title}`), + ); + container.addSeparatorComponents( + new SeparatorBuilder() + .setDivider(true) + .setSpacing(SeparatorSpacingSize.Small), + ); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(body), + ); + await message.reply({ + flags: MessageFlags.IsComponentsV2, + components: [container], + }); +} + +export default async function kickListener(client: Client): Promise { + client.on(Events.MessageCreate, async (message: Message) => { + if (message.author.bot || !message.guild) return; + if (!message.content.toLowerCase().startsWith("?kick")) return; + + // Check invoker permission + if (!message.member?.permissions.has(PermissionFlagsBits.KickMembers)) { + return void sendResponse( + message, + 0xff9900, + "⛔ Missing Permissions", + "You need the **Kick Members** permission to use this command.", + ); + } + + const args = message.content.trim().split(/\s+/); + args.shift(); // drop "?kick" + + const mentionMatch = args[0]?.match(/^<@!?(\d+)>$/); + if (!mentionMatch) { + return void sendResponse( + message, + 0x5865f2, + "ℹ️ Usage", + "`?kick @member [reason]`", + ); + } + + const targetId = mentionMatch[1]; + const reason = args.slice(1).join(" ") || "No reason provided"; + + const target = await message.guild.members + .fetch(targetId) + .catch(() => null); + if (!target) { + return void sendResponse( + message, + 0xff4444, + "❌ Member Not Found", + "Could not find that member in this server.", + ); + } + + if (!target.kickable) { + return void sendResponse( + message, + 0xff4444, + "❌ Cannot Kick", + "I cannot kick this member. They may have a higher role than me.", + ); + } + + try { + await target.kick(reason); + console.log( + `[kick] ${message.author.tag} kicked ${target.user.tag} — reason: ${reason}`, + ); + await sendResponse( + message, + 0xff8c00, + "👢 Member Kicked", + `**User:** <@${targetId}> (${target.user.tag})\n**Reason:** ${reason}\n**Kicked by:** ${message.author}`, + ); + } catch (error) { + console.error("[kick] Failed to kick member:", error); + await sendResponse( + message, + 0xff4444, + "❌ Kick Failed", + "An error occurred while trying to kick the member. Check my permissions and try again.", + ); + } + }); +} diff --git a/src/listeners/messageCreate/moderation/slowdown.ts b/src/listeners/messageCreate/moderation/slowdown.ts new file mode 100644 index 0000000..ba83fc3 --- /dev/null +++ b/src/listeners/messageCreate/moderation/slowdown.ts @@ -0,0 +1,136 @@ +import { + Client, + ContainerBuilder, + Events, + Message, + MessageFlags, + NewsChannel, + PermissionFlagsBits, + SeparatorBuilder, + SeparatorSpacingSize, + TextChannel, + TextDisplayBuilder, + ThreadChannel, +} from "discord.js"; + +type SlowmodeChannel = TextChannel | NewsChannel | ThreadChannel; + +async function sendResponse( + message: Message, + color: number, + title: string, + body: string, +): Promise { + const container = new ContainerBuilder().setAccentColor(color); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`## ${title}`), + ); + container.addSeparatorComponents( + new SeparatorBuilder() + .setDivider(true) + .setSpacing(SeparatorSpacingSize.Small), + ); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(body), + ); + await message.reply({ + flags: MessageFlags.IsComponentsV2, + components: [container], + }); +} + +// Discord max slowmode: 6 hours (21600 seconds) +const MAX_SLOWMODE_SECONDS = 21600; + +export default async function slowdownListener(client: Client): Promise { + client.on(Events.MessageCreate, async (message: Message) => { + if (message.author.bot || !message.guild) return; + if (!message.content.toLowerCase().startsWith("?slowdown")) return; + + // Check invoker permission + if ( + !message.member?.permissions.has(PermissionFlagsBits.ManageChannels) + ) { + return void sendResponse( + message, + 0xff9900, + "⛔ Missing Permissions", + "You need the **Manage Channels** permission to use this command.", + ); + } + + const args = message.content.trim().split(/\s+/); + args.shift(); // drop "?slowdown" + + const seconds = parseInt(args[0] ?? "", 10); + + if (isNaN(seconds) || seconds < 0) { + return void sendResponse( + message, + 0x5865f2, + "ℹ️ Usage", + "`?slowdown `\n-# Use `0` to disable slowmode. Maximum: 21600 (6 hours).", + ); + } + + if (seconds > MAX_SLOWMODE_SECONDS) { + return void sendResponse( + message, + 0xff9900, + "⚠️ Duration Too Long", + `Discord allows a maximum slowmode of **6 hours** (21600 seconds).\nYou specified **${seconds}s**.`, + ); + } + + const channel = message.channel as SlowmodeChannel; + + if (!("setRateLimitPerUser" in channel)) { + return void sendResponse( + message, + 0xff4444, + "❌ Unsupported Channel", + "Slowmode cannot be set in this type of channel.", + ); + } + + try { + await channel.setRateLimitPerUser( + seconds, + `Slowmode set by ${message.author.tag}`, + ); + console.log( + `[slowdown] ${message.author.tag} set slowmode to ${seconds}s in #${channel.name}`, + ); + + if (seconds === 0) { + await sendResponse( + message, + 0x00cc66, + "✅ Slowmode Disabled", + `Slowmode has been **disabled** in ${channel}.\n**Changed by:** ${message.author}`, + ); + } else { + const readable = + seconds < 60 + ? `${seconds} second${seconds !== 1 ? "s" : ""}` + : seconds < 3600 + ? `${Math.floor(seconds / 60)} minute${Math.floor(seconds / 60) !== 1 ? "s" : ""}` + : `${Math.floor(seconds / 3600)} hour${Math.floor(seconds / 3600) !== 1 ? "s" : ""}`; + await sendResponse( + message, + 0x5865f2, + "🐢 Slowmode Enabled", + `Slowmode set to **${readable}** in ${channel}.\n**Changed by:** ${message.author}`, + ); + } + } catch (error) { + console.error("[slowdown] Failed to set slowmode:", error); + await sendResponse( + message, + 0xff4444, + "❌ Slowmode Failed", + "An error occurred while setting slowmode. Check my permissions and try again.", + ); + } + }); +} diff --git a/src/listeners/messageCreate/moderation/timeout.ts b/src/listeners/messageCreate/moderation/timeout.ts new file mode 100644 index 0000000..1ccbe75 --- /dev/null +++ b/src/listeners/messageCreate/moderation/timeout.ts @@ -0,0 +1,147 @@ +import { + Client, + ContainerBuilder, + Events, + Message, + MessageFlags, + PermissionFlagsBits, + SeparatorBuilder, + SeparatorSpacingSize, + TextDisplayBuilder, +} from "discord.js"; + +async function sendResponse( + message: Message, + color: number, + title: string, + body: string, +): Promise { + const container = new ContainerBuilder().setAccentColor(color); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`## ${title}`), + ); + container.addSeparatorComponents( + new SeparatorBuilder() + .setDivider(true) + .setSpacing(SeparatorSpacingSize.Small), + ); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(body), + ); + await message.reply({ + flags: MessageFlags.IsComponentsV2, + components: [container], + }); +} + +/** Format a duration in seconds into a human-readable string */ +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${h}h ${m}m ${s}s`; +} + +// Discord timeout maximum: 28 days in seconds +const MAX_TIMEOUT_SECONDS = 28 * 24 * 60 * 60; + +export default async function timeoutListener(client: Client): Promise { + client.on(Events.MessageCreate, async (message: Message) => { + if (message.author.bot || !message.guild) return; + if (!message.content.toLowerCase().startsWith("?timeout")) return; + + // Check invoker permission (ModerateMembers = timeout) + if ( + !message.member?.permissions.has( + PermissionFlagsBits.ModerateMembers, + ) + ) { + return void sendResponse( + message, + 0xff9900, + "⛔ Missing Permissions", + "You need the **Moderate Members** permission to use this command.", + ); + } + + const args = message.content.trim().split(/\s+/); + args.shift(); // drop "?timeout" + + const mentionMatch = args[0]?.match(/^<@!?(\d+)>$/); + if (!mentionMatch) { + return void sendResponse( + message, + 0x5865f2, + "ℹ️ Usage", + "`?timeout @member `\n-# Maximum: 28 days (2419200 seconds)", + ); + } + + const targetId = mentionMatch[1]; + const seconds = parseInt(args[1] ?? "", 10); + + if (isNaN(seconds) || seconds <= 0) { + return void sendResponse( + message, + 0x5865f2, + "ℹ️ Usage", + "`?timeout @member `\n-# Seconds must be a positive number. Maximum: 2419200 (28 days).", + ); + } + + if (seconds > MAX_TIMEOUT_SECONDS) { + return void sendResponse( + message, + 0xff9900, + "⚠️ Duration Too Long", + `Discord allows a maximum timeout of **28 days** (2419200 seconds).\nYou specified **${seconds}s**.`, + ); + } + + const target = await message.guild.members + .fetch(targetId) + .catch(() => null); + if (!target) { + return void sendResponse( + message, + 0xff4444, + "❌ Member Not Found", + "Could not find that member in this server.", + ); + } + + if (!target.moderatable) { + return void sendResponse( + message, + 0xff4444, + "❌ Cannot Timeout", + "I cannot timeout this member. They may have a higher role than me.", + ); + } + + try { + await target.timeout(seconds * 1000); + const readable = formatDuration(seconds); + const until = ``; + console.log( + `[timeout] ${message.author.tag} timed out ${target.user.tag} for ${readable}`, + ); + await sendResponse( + message, + 0xffa500, + "⏱️ Member Timed Out", + `**User:** <@${targetId}> (${target.user.tag})\n**Duration:** ${readable}\n**Expires:** ${until}\n**Issued by:** ${message.author}`, + ); + } catch (error) { + console.error("[timeout] Failed to timeout member:", error); + await sendResponse( + message, + 0xff4444, + "❌ Timeout Failed", + "An error occurred while trying to timeout the member. Check my permissions and try again.", + ); + } + }); +}