From d2146e449cabbd4b0ad5a2d1b7ee78ad7cba0e31 Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Sun, 22 Dec 2024 07:23:37 -0500 Subject: [PATCH 1/4] Add temporary bans to UI, timed unbanning to timer, alter user table to include ban end date --- src/discord/commands/guild/d.moderate.ts | 19 ++++++++ src/global/utils/timer.ts | 44 +++++++++++++++++++ .../migration.sql | 2 + src/prisma/tripbot/schema.prisma | 1 + 4 files changed, 66 insertions(+) create mode 100644 src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql diff --git a/src/discord/commands/guild/d.moderate.ts b/src/discord/commands/guild/d.moderate.ts index 00cb47d32..83a072adb 100644 --- a/src/discord/commands/guild/d.moderate.ts +++ b/src/discord/commands/guild/d.moderate.ts @@ -1387,6 +1387,7 @@ export async function moderate( } // Process duration time for ban and timeouts + let banEndTime = null; let duration = 0 as null | number; let durationStr = ''; if (isTimeout(command)) { @@ -1431,6 +1432,12 @@ export async function moderate( // Get the millisecond value of the input duration = await parseDuration(`${dayInput} days`); + + // The above code is about the TIMEOUT duration, not ban. Probably old code. + const banDurationInput = parseInt(modalInt.fields.getTextInputValue('ban_duration'), 10); + const banDuration = await parseDuration(`${banDurationInput}`); + const currentTime = new Date(); + banEndTime = new Date(currentTime.getTime() + banDuration); } // Display all properties we're going to use @@ -1532,6 +1539,7 @@ export async function moderate( if (command === 'FULL_BAN') { internalNote += `\n **Actioned by:** ${actor.displayName}`; + internalNote += `\n **Ban Ends At:** ${banEndTime}`; } let actionData = { @@ -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) { + targetData.discord_bot_ban_expires_at = banEndTime; + } } catch (err) { log.error(F, `Error: ${err}`); } @@ -1943,6 +1955,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/timer.ts b/src/global/utils/timer.ts index 2591d0356..659179c42 100644 --- a/src/global/utils/timer.ts +++ b/src/global/utils/timer.ts @@ -1119,6 +1119,49 @@ async function checkMoodle() { // eslint-disable-line // } } +async function undoExpiredBans() { + const expiredBans = await db.users.findMany({ + where: { + discord_bot_ban_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) + }, + }, + }); + + if (expiredBans.length > 0) { + // Get the tripsit guild + const tripsitGuild = await global.discordClient.guilds.fetch(env.DISCORD_GUILD_ID); + expiredBans.forEach(async bannedUser => { + // Check if the reminder is ready to be triggered + if (bannedUser.discord_id !== null) { + const user = await global.discordClient.users.fetch(bannedUser.discord_id); + if (user) { + // Unban them + await tripsitGuild.bans.remove(user, 'Temporary ban has expired'); + // Reset discord_bot_ban_expires_at flag to null + await db.users.update({ + where: { + id: bannedUser.id, + }, + data: { + discord_bot_ban_expires_at: null, // Reset the ban duration to null + }, + }); + try { + await user.send(stripIndents`Hey ${user.username}, your temporary ban in TripSit has been lifted! + You're welcome to rejoin anytime. We appreciate your understanding and are looking forward to seeing you back! + + Make sure to read the #rules if you decide to rejoin.`); + } catch (err) { + // Do nothing. It's likely their discord permissions disallowed the bot to ever have comms with them. + } + } + } + }); + } +} + async function checkEvery( callback: () => Promise, interval: number, @@ -1158,6 +1201,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/20241222102954_add_ban_duration_field/migration.sql b/src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql new file mode 100644 index 000000000..168b61a0e --- /dev/null +++ b/src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "discord_bot_ban_expires_at" TIMESTAMPTZ(6); diff --git a/src/prisma/tripbot/schema.prisma b/src/prisma/tripbot/schema.prisma index a815453dd..eb05539bf 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -134,6 +134,7 @@ model users { move_points Int @default(0) empathy_points Int @default(0) discord_bot_ban Boolean @default(false) + discord_bot_ban_expires_at DateTime? @db.Timestamptz(6) ticket_ban Boolean @default(false) last_seen_at DateTime @default(now()) @db.Timestamptz(6) last_seen_in String? From 52491fa06d9673e813d85013eef00bcc554030c4 Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Mon, 23 Dec 2024 17:57:08 -0500 Subject: [PATCH 2/4] Use user_actions instead of new column on users. Fix and refine expiring bans. Fix unmute not unmuting --- src/discord/commands/guild/d.moderate.ts | 64 +++++++++---------- src/global/utils/timer.ts | 47 ++++++++------ .../migration.sql | 2 - .../migration.sql | 2 + src/prisma/tripbot/schema.prisma | 2 +- 5 files changed, 64 insertions(+), 53 deletions(-) delete mode 100644 src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql create mode 100644 src/prisma/tripbot/migrations/20241223220041_user_actions_target_id/migration.sql diff --git a/src/discord/commands/guild/d.moderate.ts b/src/discord/commands/guild/d.moderate.ts index 83a072adb..4afc11c84 100644 --- a/src/discord/commands/guild/d.moderate.ts +++ b/src/discord/commands/guild/d.moderate.ts @@ -1388,7 +1388,7 @@ export async function moderate( // Process duration time for ban and timeouts let banEndTime = null; - let duration = 0 as null | number; + let actionDuration = 0 as null | number; let durationStr = ''; if (isTimeout(command)) { // log.debug(F, 'Parsing timeout duration'); @@ -1397,47 +1397,45 @@ export async function moderate( // 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)) { + actionDuration = parseInt(durationVal, 10); + if (Number.isNaN(actionDuration)) { return { content: 'Timeout must be a number!' }; } - if (duration < 0 || duration > 7) { + if (actionDuration < 0 || actionDuration > 7) { return { content: 'Timeout must be between 0 and 7 days!' }; } - durationVal = `${duration} days`; + durationVal = `${actionDuration} days`; } - duration = await parseDuration(durationVal); - if (duration && (duration < 0 || duration > 7 * 24 * 60 * 60 * 1000)) { + 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 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 (durationVal !== '') { + let tempBanDuration = parseInt(durationVal, 10); + if (Number.isNaN(tempBanDuration)) { + return { content: 'Ban duration must be a number!' }; + } - // Get the millisecond value of the input - duration = await parseDuration(`${dayInput} days`); + tempBanDuration = await parseDuration(durationVal); + if (tempBanDuration && tempBanDuration < 0) { + return { content: 'Ban duration must be at least 1 second!' }; + } - // The above code is about the TIMEOUT duration, not ban. Probably old code. - const banDurationInput = parseInt(modalInt.fields.getTextInputValue('ban_duration'), 10); - const banDuration = await parseDuration(`${banDurationInput}`); - const currentTime = new Date(); - banEndTime = new Date(currentTime.getTime() + banDuration); + 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 @@ -1447,7 +1445,7 @@ export async function moderate( targetId: ${targetId} internalNote: ${internalNote} description: ${description} - duration: ${duration} + duration: ${actionDuration} durationStr: ${durationStr} `); @@ -1539,11 +1537,12 @@ export async function moderate( if (command === 'FULL_BAN') { internalNote += `\n **Actioned by:** ${actor.displayName}`; - internalNote += `\n **Ban Ends At:** ${banEndTime}`; + 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, @@ -1561,7 +1560,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!`); @@ -1578,7 +1577,7 @@ export async function moderate( targetObj = await buttonInt.guild.bans.create(targetId, { deleteMessageSeconds: deleteMessageValue / 1000, reason: internalNote ?? noReason }); // Set ban duration if present if (banEndTime !== null) { - targetData.discord_bot_ban_expires_at = banEndTime; + actionData.expires_at = banEndTime; } } catch (err) { log.error(F, `Error: ${err}`); @@ -1629,11 +1628,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}`); } @@ -1661,7 +1661,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}`); diff --git a/src/global/utils/timer.ts b/src/global/utils/timer.ts index 659179c42..c427c09e2 100644 --- a/src/global/utils/timer.ts +++ b/src/global/utils/timer.ts @@ -1120,39 +1120,50 @@ async function checkMoodle() { // eslint-disable-line } async function undoExpiredBans() { - const expiredBans = await db.users.findMany({ + const expiredBans = await db.user_actions.findMany({ where: { - discord_bot_ban_expires_at: { + 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 bannedUser => { + expiredBans.forEach(async activeBan => { // Check if the reminder is ready to be triggered - if (bannedUser.discord_id !== null) { - const user = await global.discordClient.users.fetch(bannedUser.discord_id); + if (activeBan.target_discord_id !== null) { + const user = await global.discordClient.users.fetch(activeBan.target_discord_id); if (user) { // Unban them - await tripsitGuild.bans.remove(user, 'Temporary ban has expired'); - // Reset discord_bot_ban_expires_at flag to null - await db.users.update({ - where: { - id: bannedUser.id, - }, - data: { - discord_bot_ban_expires_at: null, // Reset the ban duration to null - }, - }); try { - await user.send(stripIndents`Hey ${user.username}, your temporary ban in TripSit has been lifted! - You're welcome to rejoin anytime. We appreciate your understanding and are looking forward to seeing you back! + 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 + }, + }); + } + + try { + await user.send(stripIndents`Hi ${user.username}, + Your temporary ban from TripSit has been lifted! You're welcome to rejoin the community whenever you're ready. + + We really appreciate your understanding during this time and look forward to seeing you back. - Make sure to read the #rules if you decide to rejoin.`); + If you decide to rejoin, please take a moment to review the #rules channel. Hope to see you around soon!`); } catch (err) { // Do nothing. It's likely their discord permissions disallowed the bot to ever have comms with them. } diff --git a/src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql b/src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql deleted file mode 100644 index 168b61a0e..000000000 --- a/src/prisma/tripbot/migrations/20241222102954_add_ban_duration_field/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "users" ADD COLUMN "discord_bot_ban_expires_at" TIMESTAMPTZ(6); 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 000000000..b08df646f --- /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 eb05539bf..dc3d8f9fb 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -134,7 +134,6 @@ model users { move_points Int @default(0) empathy_points Int @default(0) discord_bot_ban Boolean @default(false) - discord_bot_ban_expires_at DateTime? @db.Timestamptz(6) ticket_ban Boolean @default(false) last_seen_at DateTime @default(now()) @db.Timestamptz(6) last_seen_in String? @@ -360,6 +359,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 From 6c6840174ee9968155978c1d194634c54a600ad9 Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Mon, 23 Dec 2024 18:55:29 -0500 Subject: [PATCH 3/4] Input validation for timeout & ban. Better formatting for them too. Fixed months not working. Removed msg send on unban since its impossible. --- src/discord/commands/guild/d.moderate.ts | 27 ++++++++++++------------ src/global/utils/parseDuration.ts | 9 ++++++-- src/global/utils/timer.ts | 11 ---------- 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/discord/commands/guild/d.moderate.ts b/src/discord/commands/guild/d.moderate.ts index 4afc11c84..faabf8897 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'; @@ -1394,22 +1394,16 @@ export async function moderate( // 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 - actionDuration = parseInt(durationVal, 10); - if (Number.isNaN(actionDuration)) { - return { content: 'Timeout must be a number!' }; - } - if (actionDuration < 0 || actionDuration > 7) { - return { content: 'Timeout must be between 0 and 7 days!' }; - } - durationVal = `${actionDuration} days`; + + if (durationVal !== '' && !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.', + }; } actionDuration = await parseDuration(durationVal); if (actionDuration && (actionDuration < 0 || actionDuration > 7 * 24 * 60 * 60 * 1000)) { - return { content: 'Timeout must be between 0 and 7 days!!' }; + return { content: 'Timeout must be between 0 and 7 days!' }; } // convert the milliseconds into a human readable string @@ -1423,10 +1417,17 @@ export async function moderate( if (durationVal !== '') { let tempBanDuration = parseInt(durationVal, 10); + if (Number.isNaN(tempBanDuration)) { return { content: 'Ban duration must be a number!' }; } + if (durationVal !== '' && !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!' }; diff --git a/src/global/utils/parseDuration.ts b/src/global/utils/parseDuration.ts index 9682a7982..3d38d7973 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 3dd74062d..2cedb67bc 100644 --- a/src/global/utils/timer.ts +++ b/src/global/utils/timer.ts @@ -1159,17 +1159,6 @@ async function undoExpiredBans() { }, }); } - - try { - await user.send(stripIndents`Hi ${user.username}, - Your temporary ban from TripSit has been lifted! You're welcome to rejoin the community whenever you're ready. - - We really appreciate your understanding during this time and look forward to seeing you back. - - If you decide to rejoin, please take a moment to review the #rules channel. Hope to see you around soon!`); - } catch (err) { - // Do nothing. It's likely their discord permissions disallowed the bot to ever have comms with them. - } } } }); From 90a8b7ad79b34d4b1687f211261fa67d24b55f05 Mon Sep 17 00:00:00 2001 From: theimperious1 Date: Mon, 23 Dec 2024 19:07:34 -0500 Subject: [PATCH 4/4] Fix always true conditions --- src/discord/commands/guild/d.moderate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/discord/commands/guild/d.moderate.ts b/src/discord/commands/guild/d.moderate.ts index faabf8897..c6a1050a8 100644 --- a/src/discord/commands/guild/d.moderate.ts +++ b/src/discord/commands/guild/d.moderate.ts @@ -1393,9 +1393,9 @@ export async function moderate( if (isTimeout(command)) { // log.debug(F, 'Parsing timeout duration'); let durationVal = modalInt.fields.getTextInputValue('duration'); - if (durationVal === '') durationVal = '7 days'; + if (durationVal === '') durationVal = '7d'; - if (durationVal !== '' && !validateDurationInput(durationVal)) { + 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.', }; @@ -1422,7 +1422,7 @@ export async function moderate( return { content: 'Ban duration must be a number!' }; } - if (durationVal !== '' && !validateDurationInput(durationVal)) { + 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', };