From 20af09b27962c0eafb55422f95a44dfb94e07a27 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Wed, 16 Apr 2025 22:10:47 -0400 Subject: [PATCH] feat: add kick, mute, and unmute commands --- src/commands/moderation/kick.ts | 88 +++++++++++++++++++++ src/commands/moderation/mute.ts | 115 ++++++++++++++++++++++++++++ src/commands/moderation/unmute.ts | 65 ++++++++++++++++ src/commands/moderation/warn.ts | 6 +- src/db/functions/memberFunctions.ts | 2 + src/events/memberEvents.ts | 17 +++- src/events/ready.ts | 3 +- src/util/helpers.ts | 106 ++++++++++++++++++++++++- 8 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 src/commands/moderation/kick.ts create mode 100644 src/commands/moderation/mute.ts create mode 100644 src/commands/moderation/unmute.ts diff --git a/src/commands/moderation/kick.ts b/src/commands/moderation/kick.ts new file mode 100644 index 0000000..5f49501 --- /dev/null +++ b/src/commands/moderation/kick.ts @@ -0,0 +1,88 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { updateMemberModerationHistory } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a member from the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to kick') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the kick') + .setRequired(true), + ), + execute: async (interaction) => { + const moderator = await interaction.guild?.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild?.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.KickMembers, + ) || + moderator!.roles.highest.position <= member!.roles.highest.position || + !member?.kickable + ) { + await interaction.reply({ + content: + 'You do not have permission to kick members or this member cannot be kicked.', + flags: ['Ephemeral'], + }); + return; + } + + try { + try { + await member.user.send( + `You have been kicked from ${interaction.guild!.name}. Reason: ${reason}. You can join back at: \nhttps://discord.gg/KRTGjxx7gY`, + ); + } catch (error) { + console.error('Failed to send DM to kicked user:', error); + } + + await member.kick(reason); + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'kick', + reason, + duration: '', + createdAt: new Date(), + }); + + await logAction({ + guild: interaction.guild!, + action: 'kick', + target: member, + moderator: moderator!, + reason, + }); + + await interaction.reply({ + content: `<@${member.id}> has been kicked. Reason: ${reason}`, + }); + } catch (error) { + console.error('Kick command error:', error); + await interaction.reply({ + content: 'Unable to kick member.', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts new file mode 100644 index 0000000..d2f8cc4 --- /dev/null +++ b/src/commands/moderation/mute.ts @@ -0,0 +1,115 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; +import { parseDuration } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('mute') + .setDescription('Timeout a member in the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to timeout') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the timeout') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('duration') + .setDescription( + 'The duration of the timeout (ex. 5m, 1h, 1d, 1w). Max 28 days.', + ) + .setRequired(true), + ), + execute: async (interaction) => { + const moderator = await interaction.guild?.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild?.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + const muteDuration = interaction.options.get('duration')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) || + moderator!.roles.highest.position <= member!.roles.highest.position || + !member?.moderatable + ) { + await interaction.reply({ + content: + 'You do not have permission to timeout members or this member cannot be timed out.', + flags: ['Ephemeral'], + }); + return; + } + + try { + const durationMs = parseDuration(muteDuration); + const maxTimeout = 28 * 24 * 60 * 60 * 1000; + + if (durationMs > maxTimeout) { + await interaction.reply({ + content: 'Timeout duration cannot exceed 28 days.', + flags: ['Ephemeral'], + }); + return; + } + + await member.user.send( + `You have been timed out in ${interaction.guild!.name} for ${muteDuration}. Reason: ${reason}.`, + ); + + await member.timeout(durationMs, reason); + + const expiresAt = new Date(Date.now() + durationMs); + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'mute', + reason, + duration: muteDuration, + createdAt: new Date(), + expiresAt, + active: true, + }); + + await updateMember({ + discordId: member.id, + currentlyMuted: true, + }); + + await logAction({ + guild: interaction.guild!, + action: 'mute', + target: member, + moderator: moderator!, + reason, + duration: muteDuration, + }); + + await interaction.reply({ + content: `<@${member.id}> has been timed out for ${muteDuration}. Reason: ${reason}`, + }); + } catch (error) { + console.error('Mute command error:', error); + await interaction.reply({ + content: 'Unable to timeout member.', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/unmute.ts b/src/commands/moderation/unmute.ts new file mode 100644 index 0000000..f9130f3 --- /dev/null +++ b/src/commands/moderation/unmute.ts @@ -0,0 +1,65 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { executeUnmute } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('unmute') + .setDescription('Remove a timeout from a member') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to unmute') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for removing the timeout') + .setRequired(true), + ), + execute: async (interaction) => { + const moderator = await interaction.guild?.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild?.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to unmute members.', + flags: ['Ephemeral'], + }); + return; + } + + try { + await executeUnmute( + interaction.client, + interaction.guild!.id, + member!.id, + reason, + moderator, + ); + + await interaction.reply({ + content: `<@${member!.id}>'s timeout has been removed. Reason: ${reason}`, + }); + } catch (error) { + console.error('Unmute command error:', error); + await interaction.reply({ + content: 'Unable to unmute member.', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index be2f386..1eef588 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -54,9 +54,6 @@ const command: OptionsCommand = { await member!.user.send( `You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`, ); - await interaction.reply( - `<@${member!.user.id}> has been warned. Reason: ${reason}`, - ); await logAction({ guild: interaction.guild!, action: 'warn', @@ -64,6 +61,9 @@ const command: OptionsCommand = { moderator: moderator!, reason: reason, }); + await interaction.reply( + `<@${member!.user.id}> has been warned. Reason: ${reason}`, + ); } catch (error) { console.error(error); await interaction.reply({ diff --git a/src/db/functions/memberFunctions.ts b/src/db/functions/memberFunctions.ts index aa72b30..3ed416d 100644 --- a/src/db/functions/memberFunctions.ts +++ b/src/db/functions/memberFunctions.ts @@ -133,6 +133,7 @@ export async function updateMember({ discordUsername, currentlyInServer, currentlyBanned, + currentlyMuted, }: schema.memberTableTypes): Promise { try { await ensureDbInitialized(); @@ -147,6 +148,7 @@ export async function updateMember({ discordUsername, currentlyInServer, currentlyBanned, + currentlyMuted, }) .where(eq(schema.memberTable.discordId, discordId)); diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index 655547d..3337108 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -6,7 +6,7 @@ import { } from 'discord.js'; import { updateMember, setMembers } from '@/db/db.js'; -import { generateMemberBanner } from '@/util/helpers.js'; +import { executeUnmute, generateMemberBanner } from '@/util/helpers.js'; import { loadConfig } from '@/util/configLoader.js'; import { Event } from '@/types/EventTypes.js'; import logAction from '@/util/logging/logAction.js'; @@ -144,6 +144,21 @@ export const memberUpdate: Event = { }); } } + + if ( + oldMember.communicationDisabledUntil !== + newMember.communicationDisabledUntil && + newMember.communicationDisabledUntil === null + ) { + executeUnmute( + newMember.client, + guild.id, + newMember.user.id, + undefined, + guild.members.me!, + true, + ); + } } catch (error) { console.error('Error handling member update:', error); } diff --git a/src/events/ready.ts b/src/events/ready.ts index bacb3da..a084068 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -11,7 +11,7 @@ import { setDiscordClient as setRedisDiscordClient, } from '@/db/redis.js'; import { setDiscordClient as setDbDiscordClient } from '@/db/db.js'; -import { loadActiveBans } from '@/util/helpers.js'; +import { loadActiveBans, loadActiveMutes } from '@/util/helpers.js'; export default { name: Events.ClientReady, @@ -38,6 +38,7 @@ export default { await setMembers(nonBotMembers); await loadActiveBans(client, guild); + await loadActiveMutes(client, guild); await scheduleFactOfTheDay(client); await scheduleGiveaways(client); diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 1cb210d..6279d22 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -10,11 +10,12 @@ import { ButtonStyle, ButtonBuilder, ActionRowBuilder, + DiscordAPIError, } from 'discord.js'; import { and, eq } from 'drizzle-orm'; import { moderationTable } from '@/db/schema.js'; -import { db, handleDbError, updateMember } from '@/db/db.js'; +import { db, getMember, handleDbError, updateMember } from '@/db/db.js'; import logAction from './logging/logAction.js'; const __dirname = path.resolve(); @@ -116,6 +117,107 @@ export async function generateMemberBanner({ return attachment; } +/** + * Executes an unmute for a user + * @param client - The client to use + * @param guildId - The guild ID to unmute the user in + * @param userId - The user ID to unmute + * @param reason - The reason for the unmute + * @param moderator - The moderator who is unmuting the user + * @param alreadyUnmuted - Whether the user is already unmuted + */ +export async function executeUnmute( + client: Client, + guildId: string, + userId: string, + reason?: string, + moderator?: GuildMember, + alreadyUnmuted: boolean = false, +): Promise { + try { + const guild = await client.guilds.fetch(guildId); + let member; + + try { + member = await guild.members.fetch(userId); + if (!alreadyUnmuted) { + await member.timeout(null, reason ?? 'Temporary mute expired'); + } + } catch (error) { + console.log( + `Member ${userId} not found in server, just updating database`, + ); + } + + if (!(await getMember(userId))?.currentlyMuted) return; + + await db + .update(moderationTable) + .set({ active: false }) + .where( + and( + eq(moderationTable.discordId, userId), + eq(moderationTable.action, 'mute'), + eq(moderationTable.active, true), + ), + ); + + await updateMember({ + discordId: userId, + currentlyMuted: false, + }); + + if (member) { + await logAction({ + guild, + action: 'unmute', + target: member, + reason: reason ?? 'Temporary mute expired', + moderator: moderator ? moderator : guild.members.me!, + }); + } + } catch (error) { + console.error('Error executing unmute:', error); + + if (!(error instanceof DiscordAPIError && error.code === 10007)) { + handleDbError('Failed to execute unmute', error as Error); + } + } +} + +/** + * Loads all active mutes and schedules unmute events + * @param client - The client to use + * @param guild - The guild to load mutes for + */ +export async function loadActiveMutes( + client: Client, + guild: Guild, +): Promise { + try { + const activeMutes = await db + .select() + .from(moderationTable) + .where( + and( + eq(moderationTable.action, 'mute'), + eq(moderationTable.active, true), + ), + ); + + for (const mute of activeMutes) { + if (!mute.expiresAt) continue; + + const timeUntilUnmute = mute.expiresAt.getTime() - Date.now(); + if (timeUntilUnmute <= 0) { + await executeUnmute(client, guild.id, mute.discordId); + } + } + } catch (error) { + handleDbError('Failed to load active mutes', error as Error); + } +} + /** * Schedules an unban for a user * @param client - The client to use @@ -174,7 +276,7 @@ export async function executeUnban( guild, action: 'unban', target: guild.members.cache.get(userId)!, - moderator: guild.members.cache.get(client.user!.id)!, + moderator: guild.members.me!, reason: reason ?? 'Temporary ban expired', }); } catch (error) {