feat: add kick, mute, and unmute commands

This commit is contained in:
Ahmad 2025-04-16 22:10:47 -04:00
parent 49d274f2be
commit 20af09b279
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
8 changed files with 395 additions and 7 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -54,9 +54,6 @@ const command: OptionsCommand = {
await member!.user.send( await member!.user.send(
`You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`, `You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`,
); );
await interaction.reply(
`<@${member!.user.id}> has been warned. Reason: ${reason}`,
);
await logAction({ await logAction({
guild: interaction.guild!, guild: interaction.guild!,
action: 'warn', action: 'warn',
@ -64,6 +61,9 @@ const command: OptionsCommand = {
moderator: moderator!, moderator: moderator!,
reason: reason, reason: reason,
}); });
await interaction.reply(
`<@${member!.user.id}> has been warned. Reason: ${reason}`,
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.reply({ await interaction.reply({

View file

@ -133,6 +133,7 @@ export async function updateMember({
discordUsername, discordUsername,
currentlyInServer, currentlyInServer,
currentlyBanned, currentlyBanned,
currentlyMuted,
}: schema.memberTableTypes): Promise<void> { }: schema.memberTableTypes): Promise<void> {
try { try {
await ensureDbInitialized(); await ensureDbInitialized();
@ -147,6 +148,7 @@ export async function updateMember({
discordUsername, discordUsername,
currentlyInServer, currentlyInServer,
currentlyBanned, currentlyBanned,
currentlyMuted,
}) })
.where(eq(schema.memberTable.discordId, discordId)); .where(eq(schema.memberTable.discordId, discordId));

View file

@ -6,7 +6,7 @@ import {
} from 'discord.js'; } from 'discord.js';
import { updateMember, setMembers } from '@/db/db.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 { loadConfig } from '@/util/configLoader.js';
import { Event } from '@/types/EventTypes.js'; import { Event } from '@/types/EventTypes.js';
import logAction from '@/util/logging/logAction.js'; import logAction from '@/util/logging/logAction.js';
@ -144,6 +144,21 @@ export const memberUpdate: Event<typeof Events.GuildMemberUpdate> = {
}); });
} }
} }
if (
oldMember.communicationDisabledUntil !==
newMember.communicationDisabledUntil &&
newMember.communicationDisabledUntil === null
) {
executeUnmute(
newMember.client,
guild.id,
newMember.user.id,
undefined,
guild.members.me!,
true,
);
}
} catch (error) { } catch (error) {
console.error('Error handling member update:', error); console.error('Error handling member update:', error);
} }

View file

@ -11,7 +11,7 @@ import {
setDiscordClient as setRedisDiscordClient, setDiscordClient as setRedisDiscordClient,
} from '@/db/redis.js'; } from '@/db/redis.js';
import { setDiscordClient as setDbDiscordClient } from '@/db/db.js'; import { setDiscordClient as setDbDiscordClient } from '@/db/db.js';
import { loadActiveBans } from '@/util/helpers.js'; import { loadActiveBans, loadActiveMutes } from '@/util/helpers.js';
export default { export default {
name: Events.ClientReady, name: Events.ClientReady,
@ -38,6 +38,7 @@ export default {
await setMembers(nonBotMembers); await setMembers(nonBotMembers);
await loadActiveBans(client, guild); await loadActiveBans(client, guild);
await loadActiveMutes(client, guild);
await scheduleFactOfTheDay(client); await scheduleFactOfTheDay(client);
await scheduleGiveaways(client); await scheduleGiveaways(client);

View file

@ -10,11 +10,12 @@ import {
ButtonStyle, ButtonStyle,
ButtonBuilder, ButtonBuilder,
ActionRowBuilder, ActionRowBuilder,
DiscordAPIError,
} from 'discord.js'; } from 'discord.js';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { moderationTable } from '@/db/schema.js'; 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'; import logAction from './logging/logAction.js';
const __dirname = path.resolve(); const __dirname = path.resolve();
@ -116,6 +117,107 @@ export async function generateMemberBanner({
return attachment; 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<void> {
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<void> {
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 * Schedules an unban for a user
* @param client - The client to use * @param client - The client to use
@ -174,7 +276,7 @@ export async function executeUnban(
guild, guild,
action: 'unban', action: 'unban',
target: guild.members.cache.get(userId)!, target: guild.members.cache.get(userId)!,
moderator: guild.members.cache.get(client.user!.id)!, moderator: guild.members.me!,
reason: reason ?? 'Temporary ban expired', reason: reason ?? 'Temporary ban expired',
}); });
} catch (error) { } catch (error) {