diff --git a/src/discord/commands/guild/d.moderate.ts b/src/discord/commands/guild/d.moderate.ts index 00cb47d3..c6a1050a 100644 --- a/src/discord/commands/guild/d.moderate.ts +++ b/src/discord/commands/guild/d.moderate.ts @@ -42,7 +42,7 @@ import { stripIndents } from 'common-tags'; import { user_action_type, user_actions, users } from '@prisma/client'; import moment from 'moment'; import { SlashCommand } from '../../@types/commandDef'; -import { parseDuration } from '../../../global/utils/parseDuration'; +import { parseDuration, validateDurationInput } from '../../../global/utils/parseDuration'; import commandContext from '../../utils/context'; // eslint-disable-line import { getDiscordMember } from '../../utils/guildMemberLookup'; import { embedTemplate } from '../../utils/embedTemplate'; @@ -1387,50 +1387,56 @@ export async function moderate( } // Process duration time for ban and timeouts - let duration = 0 as null | number; + let banEndTime = null; + let actionDuration = 0 as null | number; let durationStr = ''; if (isTimeout(command)) { // log.debug(F, 'Parsing timeout duration'); let durationVal = modalInt.fields.getTextInputValue('duration'); - if (durationVal === '') durationVal = '7 days'; - // log.debug(F, `durationVal: ${durationVal}`); - if (durationVal.length === 1) { - // If the input is a single number, assume it's days - duration = parseInt(durationVal, 10); - if (Number.isNaN(duration)) { - return { content: 'Timeout must be a number!' }; - } - if (duration < 0 || duration > 7) { - return { content: 'Timeout must be between 0 and 7 days!' }; - } - durationVal = `${duration} days`; + if (durationVal === '') durationVal = '7d'; + + if (!validateDurationInput(durationVal)) { + return { + content: 'Timeout duration must include at least one of seconds, minutes, hours, days, or a week. For example: 5d 5h 5m 5s, 1w or 5d.', + }; } - duration = await parseDuration(durationVal); - if (duration && (duration < 0 || duration > 7 * 24 * 60 * 60 * 1000)) { - return { content: 'Timeout must be between 0 and 7 days!!' }; + actionDuration = await parseDuration(durationVal); + if (actionDuration && (actionDuration < 0 || actionDuration > 7 * 24 * 60 * 60 * 1000)) { + return { content: 'Timeout must be between 0 and 7 days!' }; } // convert the milliseconds into a human readable string - const humanTime = msToHuman(duration); + const humanTime = msToHuman(actionDuration); - durationStr = `for ${humanTime}. It will expire ${time(new Date(Date.now() + duration), 'R')}`; + durationStr = ` for ${humanTime}. It will expire ${time(new Date(Date.now() + actionDuration), 'R')}`; // log.debug(F, `duration: ${duration}`); } if (isFullBan(command)) { - // If the command is ban, then the input value exists, so pull that and try to parse it as an int - let dayInput = parseInt(modalInt.fields.getTextInputValue('days'), 10); + const durationVal = modalInt.fields.getTextInputValue('ban_duration'); - // If no input was provided, default to 0 days - if (Number.isNaN(dayInput)) dayInput = 0; + if (durationVal !== '') { + let tempBanDuration = parseInt(durationVal, 10); - // If the input is a string, or outside the bounds, tell the user and return - if (dayInput && (dayInput < 0 || dayInput > 7)) { - return { content: 'Ban days must be at least 0 and at most 7!' }; - } + if (Number.isNaN(tempBanDuration)) { + return { content: 'Ban duration must be a number!' }; + } - // Get the millisecond value of the input - duration = await parseDuration(`${dayInput} days`); + if (!validateDurationInput(durationVal)) { + return { + content: 'Ban duration must include at least one of seconds, minutes, hours, days, weeks, months, or years. For example: 1yr 1M 1w 1d 1h 1m 1s', + }; + } + + tempBanDuration = await parseDuration(durationVal); + if (tempBanDuration && tempBanDuration < 0) { + return { content: 'Ban duration must be at least 1 second!' }; + } + + durationStr = `${time(new Date(Date.now() + tempBanDuration), 'R')}`; + const currentTime = new Date(); + banEndTime = tempBanDuration !== 0 ? new Date(currentTime.getTime() + tempBanDuration) : null; + } } // Display all properties we're going to use @@ -1440,7 +1446,7 @@ export async function moderate( targetId: ${targetId} internalNote: ${internalNote} description: ${description} - duration: ${duration} + duration: ${actionDuration} durationStr: ${durationStr} `); @@ -1532,10 +1538,12 @@ export async function moderate( if (command === 'FULL_BAN') { internalNote += `\n **Actioned by:** ${actor.displayName}`; + internalNote += `\n **Ban Ends:** ${durationStr || 'Never'}`; } let actionData = { user_id: targetData.id, + target_discord_id: targetData.discord_id, guild_id: actor.guild.id, type: command.includes('UN-') ? command.slice(3) : command, ban_evasion_related_user: null as string | null, @@ -1553,7 +1561,7 @@ export async function moderate( if (isBan(command)) { if (isFullBan(command) || isUnderban(command) || isBanEvasion(command)) { targetData.removed_at = new Date(); - const deleteMessageValue = duration ?? 0; + const deleteMessageValue = actionDuration ?? 0; try { if (deleteMessageValue > 0 && targetMember) { // log.debug(F, `I am deleting ${deleteMessageValue} days of messages!`); @@ -1568,6 +1576,10 @@ export async function moderate( try { targetObj = await buttonInt.guild.bans.create(targetId, { deleteMessageSeconds: deleteMessageValue / 1000, reason: internalNote ?? noReason }); + // Set ban duration if present + if (banEndTime !== null) { + actionData.expires_at = banEndTime; + } } catch (err) { log.error(F, `Error: ${err}`); } @@ -1617,11 +1629,12 @@ export async function moderate( } actionData.repealed_at = new Date(); actionData.repealed_by = actorData.id; + actionData.expires_at = null; } else if (isTimeout(command)) { if (targetMember) { - actionData.expires_at = new Date(Date.now() + (duration as number)); + actionData.expires_at = new Date(Date.now() + (actionDuration as number)); try { - await targetMember.timeout(duration, internalNote ?? noReason); + await targetMember.timeout(actionDuration, internalNote ?? noReason); } catch (err) { log.error(F, `Error: ${err}`); } @@ -1649,7 +1662,7 @@ export async function moderate( actionData.repealed_by = actorData.id; try { - await targetMember.timeout(0, internalNote ?? noReason); + await targetMember.timeout(null, internalNote ?? noReason); // log.debug(F, `I untimeout ${target.displayName} because\n '${internalNote}'!`); } catch (err) { log.error(F, `Error: ${err}`); @@ -1943,6 +1956,13 @@ export async function modModal( .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 0 days)') .setRequired(false) .setCustomId('days'))); + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel('How long should they be banned for?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('365 days (Empty = Permanent)') + .setRequired(false) + .setCustomId('ban_duration'))); } // When the modal is opened, disable the button on the embed diff --git a/src/global/utils/parseDuration.ts b/src/global/utils/parseDuration.ts index 9682a798..3d38d797 100644 --- a/src/global/utils/parseDuration.ts +++ b/src/global/utils/parseDuration.ts @@ -12,7 +12,7 @@ export default parseDuration; export async function parseDuration(duration:string):Promise { // Those code inspired by https://gist.github.com/substanc3-dev/306bb4d04b2aad3a5d019052b1a0dec0 // This is super cool, thanks a lot! - const supported = 'smhdwmoy'; + const supported = 'smMhdwmoy'; const numbers = '0123456789'; let stage = 1; let idx = 0; @@ -53,7 +53,7 @@ export async function parseDuration(duration:string):Promise { if (c === 'h') { timeValue += tempNumber * 60 * 60 * 1000; } - if (c === 'mo') { + if (c === 'M') { timeValue += tempNumber * 30 * 24 * 60 * 60 * 1000; } if (c === 'm') { @@ -81,3 +81,8 @@ export async function parseDuration(duration:string):Promise { } return timeValue; } + +export const validateDurationInput = (input: string): boolean => { + const regex = /^(\d+(yr|M|w|d|h|m|s)\s?)+$/; + return regex.test(input.trim()); +}; diff --git a/src/global/utils/timer.ts b/src/global/utils/timer.ts index 2e63e9df..2cedb67b 100644 --- a/src/global/utils/timer.ts +++ b/src/global/utils/timer.ts @@ -1122,6 +1122,49 @@ async function checkMoodle() { // eslint-disable-line // } } +async function undoExpiredBans() { + const expiredBans = await db.user_actions.findMany({ + where: { + expires_at: { + not: null, // Ensure the ban duration is set (i.e., not null, indicating a ban exists) + lte: new Date(), // Fetch users whose ban duration is in the past (expired bans) + }, + type: 'FULL_BAN', + }, + }); + + if (expiredBans.length > 0) { + // Get the tripsit guild + const tripsitGuild = await global.discordClient.guilds.fetch(env.DISCORD_GUILD_ID); + expiredBans.forEach(async activeBan => { + // Check if the reminder is ready to be triggered + if (activeBan.target_discord_id !== null) { + const user = await global.discordClient.users.fetch(activeBan.target_discord_id); + if (user) { + // Unban them + try { + await tripsitGuild.bans.remove(user, 'Temporary ban expired'); + log.info(F, `Temporary ban for ${activeBan.target_discord_id} has expired and been lifted!`); + } catch (err) { + // If this error is ever encountered then something in our flow is probably wrong. This should never happen. + log.error(F, `Failed to remove temporary ban on ${activeBan.target_discord_id}. Likely already unbanned.`); + } finally { + // Reset expires_at flag to null + await db.user_actions.update({ + where: { + id: activeBan.id, + }, + data: { + expires_at: null, // Reset the ban duration to null + }, + }); + } + } + } + }); + } +} + async function checkEvery( callback: () => Promise, interval: number, @@ -1161,6 +1204,7 @@ async function runTimer() { { callback: checkMoodle, interval: env.NODE_ENV === 'production' ? seconds60 : seconds5 }, // { callback: checkLpm, interval: env.NODE_ENV === 'production' ? seconds10 : seconds5 }, { callback: updateDb, interval: env.NODE_ENV === 'production' ? hours24 : hours48 }, + { callback: undoExpiredBans, interval: env.NODE_ENV === 'production' ? hours24 : seconds10 }, ]; timers.forEach(timer => { diff --git a/src/prisma/tripbot/migrations/20241223220041_user_actions_target_id/migration.sql b/src/prisma/tripbot/migrations/20241223220041_user_actions_target_id/migration.sql new file mode 100644 index 00000000..b08df646 --- /dev/null +++ b/src/prisma/tripbot/migrations/20241223220041_user_actions_target_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user_actions" ADD COLUMN "target_discord_id" TEXT; diff --git a/src/prisma/tripbot/schema.prisma b/src/prisma/tripbot/schema.prisma index 3aa4628b..1bdba26c 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -360,6 +360,7 @@ model rpg_inventory { model user_actions { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid user_id String @db.Uuid + target_discord_id String? guild_id String @default("179641883222474752") type user_action_type ban_evasion_related_user String? @db.Uuid