diff --git a/bots/discord/src/commands/moderation/mute.ts b/bots/discord/src/commands/moderation/mute.ts new file mode 100644 index 0000000..cbecd9c --- /dev/null +++ b/bots/discord/src/commands/moderation/mute.ts @@ -0,0 +1,65 @@ +import { SlashCommandBuilder } from 'discord.js' + +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import { applyRolePreset } from '$/utils/discord/rolePresets' +import type { Command } from '..' + +import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from '$/utils/discord/embeds' +import { parse } from 'simple-duration' + +export default { + data: new SlashCommandBuilder() + .setName('mute') + .setDescription('Mute a member') + .addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to mute')) + .addStringOption(option => option.setName('reason').setDescription('The reason for muting the member')) + .addStringOption(option => option.setName('duration').setDescription('The duration of the mute')) + .toJSON(), + + memberRequirements: { + permissions: 8n, + }, + + global: false, + + async execute({ config, logger }, interaction) { + const user = interaction.options.getUser('member', true) + const reason = interaction.options.getString('reason') + const duration = interaction.options.getString('duration') + const durationMs = duration ? parse(duration) : null + + if (Number.isInteger(durationMs) && durationMs! < 1) + throw new CommandError( + CommandErrorType.InvalidDuration, + 'The duration must be at least 1 millisecond long.', + ) + + const member = await interaction.guild!.members.fetch(user.id) + if (!member) + throw new CommandError( + CommandErrorType.InvalidUser, + 'The provided member is not in the server or does not exist.', + ) + + await applyRolePreset(member, 'mute', durationMs ? Date.now() + durationMs : null) + + const embed = createModerationActionEmbed( + 'Muted', + user, + interaction.user, + reason ?? 'No reason provided', + durationMs, + ) + + const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch()) + + const logConfig = config.moderation?.log + if (logConfig) { + const channel = await interaction.guild!.channels.fetch(logConfig.thread ?? logConfig.channel) + if (!channel || !channel.isTextBased()) + return void logger.warn('The moderation log channel does not exist, skipping logging') + + await channel.send({ embeds: [applyReferenceToModerationActionEmbed(embed, reply.url)] }) + } + }, +} satisfies Command diff --git a/bots/discord/src/commands/moderation/ีืunmute.ts b/bots/discord/src/commands/moderation/ีืunmute.ts new file mode 100644 index 0000000..0016e9d --- /dev/null +++ b/bots/discord/src/commands/moderation/ีืunmute.ts @@ -0,0 +1,44 @@ +import { SlashCommandBuilder } from 'discord.js' + +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from '$/utils/discord/embeds' +import { removeRolePreset } from '$/utils/discord/rolePresets' +import type { Command } from '..' + +export default { + data: new SlashCommandBuilder() + .setName('unmute') + .setDescription('Unmute a member') + .addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to mute')) + .toJSON(), + + memberRequirements: { + permissions: 8n, + }, + + global: false, + + async execute({ config, logger }, interaction) { + const user = interaction.options.getUser('member', true) + const member = await interaction.guild!.members.fetch(user.id) + if (!member) + throw new CommandError( + CommandErrorType.InvalidUser, + 'The provided member is not in the server or does not exist.', + ) + + await removeRolePreset(member, 'mute') + const embed = createModerationActionEmbed('Muted', user, interaction.user) + + const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch()) + + const logConfig = config.moderation?.log + if (logConfig) { + const channel = await interaction.guild!.channels.fetch(logConfig.thread ?? logConfig.channel) + if (!channel || !channel.isTextBased()) + return void logger.warn('The moderation log channel does not exist, skipping logging') + + await channel.send({ embeds: [applyReferenceToModerationActionEmbed(embed, reply.url)] }) + } + }, +} satisfies Command diff --git a/bots/discord/src/types.d.ts b/bots/discord/src/types.d.ts index 24ab764..9beda5e 100644 --- a/bots/discord/src/types.d.ts +++ b/bots/discord/src/types.d.ts @@ -3,3 +3,8 @@ type IfTrue = IfExtends type EmptyObject = Record type ValuesOf = T[keyof T] type MaybeArray = T | T[] + +declare module 'simple-duration' { + export function parse(duration: string): number + export function stringify(duration: number): string +} diff --git a/bots/discord/src/utils/discord/embeds.ts b/bots/discord/src/utils/discord/embeds.ts index 77eb72c..602c9b8 100644 --- a/bots/discord/src/utils/discord/embeds.ts +++ b/bots/discord/src/utils/discord/embeds.ts @@ -1,5 +1,5 @@ import { DefaultEmbedColor, MessageScanHumanizedMode, ReVancedLogoURL } from '$/constants' -import { EmbedBuilder } from 'discord.js' +import { EmbedBuilder, type EmbedField, type User } from 'discord.js' import type { ConfigMessageScanResponseMessage } from '../../../config.schema' export const createErrorEmbed = (title: string, description?: string) => @@ -40,6 +40,35 @@ export const createMessageScanResponseEmbed = ( return applyCommonEmbedStyles(embed, true, true, true) } +export const createModerationActionEmbed = ( + action: string, + user: User, + moderator: User, + reason?: string, + expires?: number | null, +) => { + const fields: EmbedField[] = [] + if (reason) fields.push({ name: 'Reason', value: reason, inline: true }) + if (Number.isInteger(expires) || expires === null) + fields.push({ + name: 'Expires', + value: Number.isInteger(expires) ? new Date(expires! * 1000).toLocaleString() : 'Never', + inline: true, + }) + + const embed = new EmbedBuilder() + .setTitle(`${action} ${user.tag}`) + .setDescription(`${user.toString()} was ${action.toLowerCase()} by ${moderator.toString()}`) + .addFields(fields) + + return applyCommonEmbedStyles(embed, true, true, true) +} + +export const applyReferenceToModerationActionEmbed = (embed: EmbedBuilder, reference: string) => { + embed.addFields({ name: 'Reference', value: `[Jump to message](${reference})`, inline: true }) + return embed +} + export const applyCommonEmbedStyles = ( embed: EmbedBuilder, setThumbnail = false,