diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 4ba2801b..7a05f1b5 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -34,7 +34,7 @@ import { Member, Message } from "eris"; import { kickMember } from "./functions/kickMember"; import { banUserId } from "./functions/banUserId"; import { MassmuteCmd } from "./commands/MassmuteCmd"; -import { trimPluginDescription } from "../../utils"; +import { MINUTES, trimPluginDescription } from "../../utils"; import { DeleteCaseCmd } from "./commands/DeleteCaseCmd"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { GuildTempbans } from "../../data/GuildTempbans"; @@ -44,6 +44,7 @@ import { mapToPublicFn } from "../../pluginUtils"; import { onModActionsEvent } from "./functions/onModActionsEvent"; import { offModActionsEvent } from "./functions/offModActionsEvent"; import { updateCase } from "./functions/updateCase"; +import { Queue } from "../../Queue"; const defaultOptions = { config: { @@ -197,6 +198,9 @@ export const ModActionsPlugin = zeppelinGuildPlugin()("mod state.unloaded = false; state.outdatedTempbansTimeout = null; state.ignoredEvents = []; + // Massbans can take a while depending on rate limits, + // so we're giving each massban 15 minutes to complete before launching the next massban + state.massbanQueue = new Queue(15 * MINUTES); state.events = new EventEmitter(); diff --git a/backend/src/plugins/ModActions/commands/MassBanCmd.ts b/backend/src/plugins/ModActions/commands/MassBanCmd.ts index 49110e79..1a1bf7a5 100644 --- a/backend/src/plugins/ModActions/commands/MassBanCmd.ts +++ b/backend/src/plugins/ModActions/commands/MassBanCmd.ts @@ -1,7 +1,7 @@ import { modActionsCmd, IgnoredEventType } from "../types"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; -import { resolveUser, resolveMember, stripObjectToScalars, noop } from "../../../utils"; +import { resolveUser, resolveMember, stripObjectToScalars, noop, MINUTES } from "../../../utils"; import { isBanned } from "../functions/isBanned"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; @@ -14,6 +14,7 @@ import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin"; import { LogType } from "../../../data/LogType"; import { performance } from "perf_hooks"; import { humanizeDurationShort } from "../../../humanizeDurationShort"; +import { load } from "js-yaml"; export const MassbanCmd = modActionsCmd({ trigger: "massban", @@ -53,73 +54,100 @@ export const MassbanCmd = modActionsCmd({ } // Show a loading indicator since this can take a while - const loadingMsg = await msg.channel.createMessage("Banning..."); + const initialLoadingText = + pluginData.state.massbanQueue.length === 0 + ? "Banning..." + : "Massban queued. Waiting for previous massban to finish."; + const loadingMsg = await msg.channel.createMessage(initialLoadingText); - // Ban each user and count failed bans (if any) - const startTime = performance.now(); - const failedBans: string[] = []; - const casesPlugin = pluginData.getPlugin(CasesPlugin); - for (const [i, userId] of args.userIds.entries()) { - try { - // Ignore automatic ban cases and logs - // We create our own cases below and post a single "mass banned" log instead - ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 120 * 1000); - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 120 * 1000); + const waitTimeStart = performance.now(); + const waitingInterval = setInterval(() => { + const waitTime = humanizeDurationShort(performance.now() - waitTimeStart); + loadingMsg + .edit(`Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`) + .catch(() => clearInterval(waitingInterval)); + }, 1 * MINUTES); - await pluginData.guild.banMember(userId, 1, banReason != null ? encodeURIComponent(banReason) : undefined); + pluginData.state.massbanQueue.add(async () => { + clearInterval(waitingInterval); - await casesPlugin.createCase({ - userId, - modId: msg.author.id, - type: CaseTypes.Ban, - reason: `Mass ban: ${banReason}`, - postInCaseLogOverride: false, + if (pluginData.state.unloaded) { + void loadingMsg.delete().catch(noop); + return; + } + + void loadingMsg.edit("Banning...").catch(noop); + + // Ban each user and count failed bans (if any) + const startTime = performance.now(); + const failedBans: string[] = []; + const casesPlugin = pluginData.getPlugin(CasesPlugin); + for (const [i, userId] of args.userIds.entries()) { + if (pluginData.state.unloaded) { + break; + } + + try { + // Ignore automatic ban cases and logs + // We create our own cases below and post a single "mass banned" log instead + ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 120 * 1000); + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 120 * 1000); + + await pluginData.guild.banMember(userId, 1, banReason != null ? encodeURIComponent(banReason) : undefined); + + await casesPlugin.createCase({ + userId, + modId: msg.author.id, + type: CaseTypes.Ban, + reason: `Mass ban: ${banReason}`, + postInCaseLogOverride: false, + }); + + pluginData.state.events.emit("ban", userId, banReason); + } catch { + failedBans.push(userId); + } + + // Send a status update every 10 bans + if ((i + 1) % 10 === 0) { + loadingMsg.edit(`Banning... ${i + 1}/${args.userIds.length}`).catch(noop); + } + } + + const totalTime = performance.now() - startTime; + const formattedTimeTaken = humanizeDurationShort(totalTime); + + // Clear loading indicator + loadingMsg.delete().catch(noop); + + const successfulBanCount = args.userIds.length - failedBans.length; + if (successfulBanCount === 0) { + // All bans failed - don't create a log entry and notify the user + sendErrorMessage(pluginData, msg.channel, "All bans failed. Make sure the IDs are valid."); + } else { + // Some or all bans were successful. Create a log entry for the mass ban and notify the user. + pluginData.state.serverLogs.log(LogType.MASSBAN, { + mod: stripObjectToScalars(msg.author), + count: successfulBanCount, + reason: banReason, }); - pluginData.state.events.emit("ban", userId, banReason); - } catch { - failedBans.push(userId); + if (failedBans.length) { + sendSuccessMessage( + pluginData, + msg.channel, + `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${ + failedBans.length + } failed: ${failedBans.join(" ")}`, + ); + } else { + sendSuccessMessage( + pluginData, + msg.channel, + `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, + ); + } } - - // Send a status update every 10 bans - if ((i + 1) % 10 === 0) { - loadingMsg.edit(`Banning... ${i + 1}/${args.userIds.length}`).catch(noop); - } - } - - const totalTime = performance.now() - startTime; - const formattedTimeTaken = humanizeDurationShort(totalTime); - - // Clear loading indicator - loadingMsg.delete().catch(noop); - - const successfulBanCount = args.userIds.length - failedBans.length; - if (successfulBanCount === 0) { - // All bans failed - don't create a log entry and notify the user - sendErrorMessage(pluginData, msg.channel, "All bans failed. Make sure the IDs are valid."); - } else { - // Some or all bans were successful. Create a log entry for the mass ban and notify the user. - pluginData.state.serverLogs.log(LogType.MASSBAN, { - mod: stripObjectToScalars(msg.author), - count: successfulBanCount, - reason: banReason, - }); - - if (failedBans.length) { - sendSuccessMessage( - pluginData, - msg.channel, - `Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join( - " ", - )}`, - ); - } else { - sendSuccessMessage( - pluginData, - msg.channel, - `Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`, - ); - } - } + }); }, }); diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index 14b092d7..852a43ba 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -10,6 +10,7 @@ import { TextChannel } from "eris"; import { GuildTempbans } from "../../data/GuildTempbans"; import Timeout = NodeJS.Timeout; import { EventEmitter } from "events"; +import { Queue } from "../../Queue"; export const ConfigSchema = t.type({ dm_on_warn: t.boolean, @@ -72,6 +73,7 @@ export interface ModActionsPluginType extends BasePluginType { unloaded: boolean; outdatedTempbansTimeout: Timeout | null; ignoredEvents: IIgnoredEvent[]; + massbanQueue: Queue; events: ModActionsEventEmitter; };