diff --git a/src/commandDetails/admin/ban.ts b/src/commandDetails/admin/ban.ts index 76187232..e79953b7 100644 --- a/src/commandDetails/admin/ban.ts +++ b/src/commandDetails/admin/ban.ts @@ -9,7 +9,8 @@ import { import { banUser } from '../../components/admin'; import { vars } from '../../config'; import { DEFAULT_EMBED_COLOUR } from '../../utils/embeds.js'; -import { pluralize } from '../../utils/pluralize'; +import { DurationStyle, formatDuration } from '../../utils/formatDuration.js'; +import { parseDuration } from '../../utils/parseDuration.js'; import { CodeyUserError } from './../../codeyUserError'; const NOTIF_CHANNEL_ID: string = vars.NOTIF_CHANNEL_ID; @@ -35,10 +36,22 @@ const banExecuteCommand: SapphireMessageExecuteType = async (client, messageFrom 'please enter a valid reason why you are banning the user.', ); } - const days = args['days']; + const duration = parseDuration(args['duration']); + + if (duration === null) { + throw new CodeyUserError( + messageFromUser, + 'please enter a valid duration (e.g. 7d, 3h, 1h30m).', + ); + } + + if (duration > 7 * 24 * 60 * 60 * 1000) { + throw new CodeyUserError(messageFromUser, 'cannot purge more than 7 days of messages.'); + } + // get Guild object corresponding to server const guild = await client.guilds.fetch(vars.TARGET_GUILD_ID); - if (await banUser(guild, user, reason, days)) { + if (await banUser(guild, user, reason, duration)) { const mod = getUserFromMessage(messageFromUser); const banEmbed = new EmbedBuilder() .setTitle('Ban') @@ -52,14 +65,19 @@ const banExecuteCommand: SapphireMessageExecuteType = async (client, messageFrom { name: 'Reason', value: reason }, { name: 'Messages Purged', - value: !days ? 'None' : `Past ${days} ${pluralize('day', days)}`, + value: !duration ? 'None' : `Past ${formatDuration(duration, DurationStyle.Blank)}`, }, ]); (client.channels.cache.get(NOTIF_CHANNEL_ID) as TextChannel).send({ embeds: [banEmbed], }); return `Successfully banned user ${user.tag} (id: ${user.id}) ${ - days ? `and deleted their messages in the past ${days} ${pluralize('day', days)} ` : `` + duration + ? `and deleted their messages in the past ${formatDuration( + duration, + DurationStyle.Blank, + )} ` + : `` }for the following reason: ${reason}`; } else { throw new CodeyUserError( @@ -98,9 +116,10 @@ export const banCommandDetails: CodeyCommandDetails = { required: true, }, { - name: 'days', - description: "Messages in last 'days' days from user are deleted. Default is 0 days.", - type: CodeyCommandOptionType.INTEGER, + name: 'duration', + description: + 'Messages within the specified time (e.g. 1 day, 2h30m) from user are deleted. Default 0d, max 7d.', + type: CodeyCommandOptionType.STRING, required: false, }, ], diff --git a/src/components/admin.ts b/src/components/admin.ts index f54f43d2..6d35080f 100644 --- a/src/components/admin.ts +++ b/src/components/admin.ts @@ -1,15 +1,20 @@ import { Guild, User } from 'discord.js'; import { vars } from '../config'; import { logger } from '../logger/default'; -import { pluralize } from '../utils/pluralize'; +import { DurationStyle, formatDuration } from '../utils/formatDuration.js'; const MOD_USER_ID_FOR_BAN_APPEAL: string = vars.MOD_USER_ID_FOR_BAN_APPEAL; /* Make ban message */ -const makeBanMessage = (reason: string, days?: number): string => +const makeBanMessage = (reason: string, duration?: number): string => ` Uh oh, you have been banned from the UW Computer Science Club server ${ - days ? `and your messages in the past ${days} ${pluralize('day', days)} have been deleted ` : '' + duration + ? `and your messages in the past ${formatDuration( + duration, + DurationStyle.Blank, + )} have been deleted ` + : '' }for the following reason: > ${reason} @@ -25,12 +30,12 @@ export const banUser = async ( guild: Guild, user: User, reason: string, - days?: number, + duration?: number, ): Promise => { let isSuccessful = false; try { try { - await user.send(makeBanMessage(reason, days)); + await user.send(makeBanMessage(reason, duration)); } catch (err) { logger.error({ event: "Can't send message to user not in server", @@ -39,7 +44,7 @@ export const banUser = async ( } await guild.members.ban(user, { reason: reason, - deleteMessageSeconds: days == null ? 0 : days * 86400, + deleteMessageSeconds: duration === undefined ? 0 : Math.floor(duration / 1000), }); isSuccessful = true; } catch (err) { diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts new file mode 100644 index 00000000..94e51c25 --- /dev/null +++ b/src/utils/formatDuration.ts @@ -0,0 +1,61 @@ +/** + * Formats a duration in milliseconds into English text. + * @param duration - The duration in milliseconds + * @param style - The style to format the duration in + * @returns The formatted duration + */ +export const formatDuration = (duration: number, style = DurationStyle.For): string => { + if (duration === Infinity) return 'indefinitely'; + + duration = Math.round(duration / 1000); + + if (duration < 0) { + const core = _formatDuration(-duration); + if (style === DurationStyle.Blank) return `negative ${core}`; + if (style === DurationStyle.For) return `for negative ${core}`; + if (style === DurationStyle.Until) return `until ${core} ago`; + } + + if (duration === 0) { + if (style === DurationStyle.Blank) return 'no time'; + if (style === DurationStyle.For) return 'for no time'; + if (style === DurationStyle.Until) return 'until right now'; + } + + const core = _formatDuration(duration); + if (style === DurationStyle.Blank) return core; + if (style === DurationStyle.For) return `for ${core}`; + if (style === DurationStyle.Until) return `until ${core} from now`; + + return '??'; +}; + +function _formatDuration(duration: number): string { + if (duration === Infinity) return 'indefinitely'; + + const parts: string[] = []; + + for (const [name, scale] of formatTimescales) { + if (duration >= scale) { + const amount = Math.floor(duration / scale); + duration %= scale; + + parts.push(`${amount} ${name}${amount === 1 ? '' : 's'}`); + } + } + + return parts.join(' '); +} + +export enum DurationStyle { + Blank, + For, + Until, +} + +const formatTimescales: [string, number][] = [ + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ['second', 1], +]; diff --git a/src/utils/parseDuration.ts b/src/utils/parseDuration.ts new file mode 100644 index 00000000..517fa9b5 --- /dev/null +++ b/src/utils/parseDuration.ts @@ -0,0 +1,31 @@ +/** + * Parses English text into a duration in milliseconds. Works for long for (1 day, + * 3 weeks 2 hours) and short form (1d, 3w2h). + * + * @param text - The text to parse + * @returns The duration in milliseconds or null if invalid + */ +export const parseDuration = (text: string): number | null => { + const match = text.match( + /^(\d+\s*w(eeks?)?\s*)?(\d+\s*d(ays?)?\s*)?(\d+\s*h((ou)?rs?)?\s*)?(\d+\s*m(in(ute)?s?)?\s*)?(\d+\s*s(ec(ond)?s?)?\s*)?$/, + ); + + if (!match) return null; + + let duration = 0; + + for (const [index, scale] of parseTimescales) { + const submatch = match[index]?.match(/\d+/); + if (submatch) duration += parseInt(submatch[0]) * scale; + } + + return duration; +}; + +const parseTimescales = [ + [1, 604800000], + [3, 86400000], + [5, 3600000], + [8, 60000], + [11, 1000], +];