From 744b9273bb369f79d4f55f8781ce0d5636202598 Mon Sep 17 00:00:00 2001 From: metal Date: Mon, 20 Sep 2021 21:39:20 +0000 Subject: [PATCH] initial - tempbans & mutes --- backend/src/data/GuildMutes.ts | 8 + backend/src/data/GuildTempbans.ts | 10 +- .../plugins/ModActions/ModActionsPlugin.ts | 14 +- .../src/plugins/ModActions/commands/BanCmd.ts | 11 +- .../plugins/ModActions/commands/UnbanCmd.ts | 3 +- .../plugins/ModActions/functions/banUserId.ts | 10 +- .../functions/outdatedTempbansLoop.ts | 160 ++++++++++++------ backend/src/plugins/ModActions/types.ts | 5 +- backend/src/plugins/Mutes/MutesPlugin.ts | 12 +- .../Mutes/functions/clearExpiredMutes.ts | 134 ++++++++++----- .../src/plugins/Mutes/functions/muteUser.ts | 10 +- .../src/plugins/Mutes/functions/unmuteUser.ts | 11 +- backend/src/plugins/Mutes/types.ts | 3 + backend/src/utils/timers.ts | 40 +++++ 14 files changed, 322 insertions(+), 109 deletions(-) create mode 100644 backend/src/utils/timers.ts diff --git a/backend/src/data/GuildMutes.ts b/backend/src/data/GuildMutes.ts index f95571a0..f9448738 100644 --- a/backend/src/data/GuildMutes.ts +++ b/backend/src/data/GuildMutes.ts @@ -11,6 +11,14 @@ export class GuildMutes extends BaseGuildRepository { this.mutes = getRepository(Mute); } + async getAllTemporaryMutes(): Promise { + return this.mutes + .createQueryBuilder("mutes") + .where("guild_id = :guild_id", { guild_id: this.guildId }) + .andWhere("expires_at IS NOT NULL") + .getMany(); + } + async getExpiredMutes(): Promise { return this.mutes .createQueryBuilder("mutes") diff --git a/backend/src/data/GuildTempbans.ts b/backend/src/data/GuildTempbans.ts index 6085a193..4fbe8ca1 100644 --- a/backend/src/data/GuildTempbans.ts +++ b/backend/src/data/GuildTempbans.ts @@ -11,9 +11,17 @@ export class GuildTempbans extends BaseGuildRepository { this.tempbans = getRepository(Tempban); } + async getAllTempbans(): Promise { + return this.tempbans + .createQueryBuilder("tempbans") + .where("guild_id = :guild_id", { guild_id: this.guildId }) + .andWhere("expires_at IS NOT NULL") + .getMany(); + } + async getExpiredTempbans(): Promise { return this.tempbans - .createQueryBuilder("mutes") + .createQueryBuilder("tempbans") .where("guild_id = :guild_id", { guild_id: this.guildId }) .andWhere("expires_at IS NOT NULL") .andWhere("expires_at <= NOW()") diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 452cabf6..191547d3 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -42,7 +42,7 @@ import { hasMutePermission } from "./functions/hasMutePerm"; import { kickMember } from "./functions/kickMember"; import { offModActionsEvent } from "./functions/offModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent"; -import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop"; +import { loadExpiringTimers } from "./functions/outdatedTempbansLoop"; import { updateCase } from "./functions/updateCase"; import { warnMember } from "./functions/warnMember"; import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types"; @@ -112,6 +112,8 @@ const defaultOptions = { ], }; +const EXPIRED_BANS_CHECK_INTERVAL = 30 * MINUTES; + export const ModActionsPlugin = zeppelinGuildPlugin()({ name: "mod_actions", showInDocs: true, @@ -200,8 +202,8 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ state.serverLogs = new GuildLogs(guild.id); state.unloaded = false; - state.outdatedTempbansTimeout = null; state.ignoredEvents = []; + pluginData.state.timers = []; // 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); @@ -210,11 +212,17 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ }, afterLoad(pluginData) { - outdatedTempbansLoop(pluginData); + loadExpiringTimers(pluginData); + pluginData.state.banClearIntervalId = setInterval( + () => loadExpiringTimers(pluginData), + EXPIRED_BANS_CHECK_INTERVAL, + ); }, beforeUnload(pluginData) { + clearInterval(pluginData.state.banClearIntervalId); pluginData.state.unloaded = true; pluginData.state.events.removeAllListeners(); + pluginData.state.timers = []; }, }); diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts index 40a352c7..5ab55c28 100644 --- a/backend/src/plugins/ModActions/commands/BanCmd.ts +++ b/backend/src/plugins/ModActions/commands/BanCmd.ts @@ -15,6 +15,8 @@ import { isBanned } from "../functions/isBanned"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { modActionsCmd } from "../types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import moment from "moment"; +import { addTimer, removeTimer, removeTimerByUserId } from "../functions/outdatedTempbansLoop"; const opts = { mod: ct.member({ option: true }), @@ -93,11 +95,18 @@ export const BanCmd = modActionsCmd({ if (time && time > 0) { if (existingTempban) { pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); + removeTimer(pluginData, existingTempban); + addTimer(pluginData, { + ...existingTempban, + expires_at: moment().utc().add(time, "ms").format("YYYY-MM-DD HH:mm:ss"), + }); } else { - pluginData.state.tempbans.addTempban(user.id, time, mod.id); + const tempban = await pluginData.state.tempbans.addTempban(user.id, time, mod.id); + addTimer(pluginData, tempban); } } else if (existingTempban) { pluginData.state.tempbans.clear(user.id); + removeTimerByUserId(pluginData, user.id); } // Create a new case for the updated ban since we never stored the old case id and log the action diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts index eb235917..f2a2c79d 100644 --- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts @@ -10,6 +10,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach import { ignoreEvent } from "../functions/ignoreEvent"; import { IgnoredEventType, modActionsCmd } from "../types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { removeTimerByUserId } from "../functions/outdatedTempbansLoop"; const opts = { mod: ct.member({ option: true }), @@ -69,7 +70,7 @@ export const UnbanCmd = modActionsCmd({ }); // Delete the tempban, if one exists pluginData.state.tempbans.clear(user.id); - + removeTimerByUserId(pluginData, user.id); // Confirm the action sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts index 0fc69371..28c87b88 100644 --- a/backend/src/plugins/ModActions/functions/banUserId.ts +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -19,6 +19,8 @@ import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from ". import { getDefaultContactMethods } from "./getDefaultContactMethods"; import { ignoreEvent } from "./ignoreEvent"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { addTimer, removeTimer } from "./outdatedTempbansLoop"; +import moment from "moment"; /** * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. @@ -109,8 +111,14 @@ export async function banUserId( const selfId = pluginData.client.user!.id; if (existingTempban) { pluginData.state.tempbans.updateExpiryTime(user.id, banTime, banOptions.modId ?? selfId); + removeTimer(pluginData, existingTempban); + addTimer(pluginData, { + ...existingTempban, + expires_at: moment().utc().add(banTime, "ms").format("YYYY-MM-DD HH:mm:ss"), + }); } else { - pluginData.state.tempbans.addTempban(user.id, banTime, banOptions.modId ?? selfId); + const tempban = await pluginData.state.tempbans.addTempban(user.id, banTime, banOptions.modId ?? selfId); + addTimer(pluginData, tempban); } } diff --git a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts index ca3c79ef..4926ea4e 100644 --- a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts +++ b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts @@ -4,67 +4,123 @@ import { GuildPluginData } from "knub"; import moment from "moment-timezone"; import { LogType } from "src/data/LogType"; import { logger } from "src/logger"; -import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { CaseTypes } from "../../../data/CaseTypes"; -import { resolveUser, SECONDS } from "../../../utils"; +import { MINUTES, resolveUser } from "../../../utils"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { IgnoredEventType, ModActionsPluginType } from "../types"; import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; import { ignoreEvent } from "./ignoreEvent"; import { isBanned } from "./isBanned"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { Tempban } from "src/data/entities/Tempban"; +import { ExpiringTimer } from "src/utils/timers"; -const TEMPBAN_LOOP_TIME = 60 * SECONDS; +const LOAD_LESS_THAN_MIN_COUNT = 60 * MINUTES; -export async function outdatedTempbansLoop(pluginData: GuildPluginData) { - const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans(); - - for (const tempban of outdatedTempbans) { - if (!(await isBanned(pluginData, tempban.user_id))) { - pluginData.state.tempbans.clear(tempban.user_id); - continue; - } - - pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); - const reason = formatReasonWithAttachments( - `Tempban timed out. - Tempbanned at: \`${tempban.created_at} UTC\``, - [], - ); - try { - ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id); - await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined); - } catch (e) { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`, - }); - logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`); - return; - } - - // Create case and delete tempban - const casesPlugin = pluginData.getPlugin(CasesPlugin); - const createdCase = await casesPlugin.createCase({ - userId: tempban.user_id, - modId: tempban.mod_id, - type: CaseTypes.Unban, - reason, - ppId: undefined, +export function addTimer(pluginData: GuildPluginData, tempban: Tempban) { + const existingMute = pluginData.state.timers.find( + (tm) => tm.options.key === tempban.user_id && tm.options.guildId === tempban.guild_id && !tm.done, + ); // for future-proof when you do global events + if (!existingMute && tempban.expires_at) { + const exp = moment(tempban.expires_at!).toDate().getTime() - moment.utc().toDate().getTime(); + const newTimer = new ExpiringTimer({ + key: tempban.user_id, + guildId: tempban.guild_id, + plugin: "tempban", + expiry: exp, + callback: async () => { + await clearTempBan(pluginData, tempban); + }, }); - pluginData.state.tempbans.clear(tempban.user_id); - - // Log the unban - const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at)); - pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({ - mod: await resolveUser(pluginData.client, tempban.mod_id), - userId: tempban.user_id, - caseNumber: createdCase.case_number, - reason, - banTime: humanizeDuration(banTime), - }); - } - - if (!pluginData.state.unloaded) { - pluginData.state.outdatedTempbansTimeout = setTimeout(() => outdatedTempbansLoop(pluginData), TEMPBAN_LOOP_TIME); + pluginData.state.timers.push(newTimer); } } + +export function removeTimer(pluginData: GuildPluginData, tempban: Tempban) { + const existingMute = pluginData.state.timers.findIndex( + (tm) => tm.options.key === tempban.user_id && tm.options.guildId === tempban.guild_id && !tm.done, + ); + if (existingMute) { + const tm = pluginData.state.timers[existingMute]; + tm.clear(); + tm.done = true; + pluginData.state.timers.splice(existingMute, 1); + } +} + +export function removeTimerByUserId(pluginData: GuildPluginData, user_id: Snowflake) { + const existingMute = pluginData.state.timers.findIndex((tm) => tm.options.key === user_id && !tm.done); + if (existingMute) { + const tm = pluginData.state.timers[existingMute]; + tm.clear(); + tm.done = true; + pluginData.state.timers.splice(existingMute, 1); + } +} + +export async function loadExpiringTimers(pluginData: GuildPluginData) { + const now = moment.utc().toDate().getTime(); + pluginData.state.timers = pluginData.state.timers.filter((tm) => !tm.done || !tm.timeout); + const tempbans = (await pluginData.state.tempbans.getAllTempbans()).filter((m) => m.expires_at); + const expiredBans = tempbans.filter((m) => now >= moment(m.expires_at!).toDate().getTime()); + const expiringBans = tempbans.filter( + (m) => !expiredBans.find((exp) => exp.user_id === m.user_id && exp.guild_id === m.guild_id), + ); + + for (const tempban of expiringBans) { + const expires = moment(tempban.expires_at!).toDate().getTime(); + if (expires <= now) continue; // exclude expired mutes, just in case + if (expires > now + LOAD_LESS_THAN_MIN_COUNT) continue; // exclude timers that are expiring in over 180 mins + + addTimer(pluginData, tempban); + } + + for (const tempban of expiredBans) { + await clearTempBan(pluginData, tempban); + } +} + +export async function clearTempBan(pluginData: GuildPluginData, tempban: Tempban) { + if (!(await isBanned(pluginData, tempban.user_id))) { + pluginData.state.tempbans.clear(tempban.user_id); + return; + } + + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); + const reason = formatReasonWithAttachments( + `Tempban timed out. + Tempbanned at: \`${tempban.created_at} UTC\``, + [], + ); + try { + ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id); + await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined); + } catch (e) { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`, + }); + logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`); + return; + } + + // Create case and delete tempban + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: tempban.user_id, + modId: tempban.mod_id, + type: CaseTypes.Unban, + reason, + ppId: undefined, + }); + pluginData.state.tempbans.clear(tempban.user_id); + + // Log the unban + const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at)); + pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({ + mod: await resolveUser(pluginData.client, tempban.mod_id), + userId: tempban.user_id, + caseNumber: createdCase.case_number, + reason, + banTime: humanizeDuration(banTime), + }); +} diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index b8c07270..57aefc62 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -2,6 +2,7 @@ import { TextChannel } from "discord.js"; import { EventEmitter } from "events"; import * as t from "io-ts"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; +import { ExpiringTimer } from "src/utils/timers"; import { Case } from "../../data/entities/Case"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; @@ -70,9 +71,9 @@ export interface ModActionsPluginType extends BasePluginType { cases: GuildCases; tempbans: GuildTempbans; serverLogs: GuildLogs; - + banClearIntervalId: Timeout; + timers: ExpiringTimer[]; unloaded: boolean; - outdatedTempbansTimeout: Timeout | null; ignoredEvents: IIgnoredEvent[]; massbanQueue: Queue; diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index ad1c0c66..a9c0d75e 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -1,5 +1,6 @@ import { GuildMember, Snowflake } from "discord.js"; import { EventEmitter } from "events"; +import { MINUTES } from "src/utils"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; @@ -15,7 +16,7 @@ import { MutesCmd } from "./commands/MutesCmd"; import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt"; import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt"; import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt"; -import { clearExpiredMutes } from "./functions/clearExpiredMutes"; +import { loadExpiringTimers } from "./functions/clearExpiredMutes"; import { muteUser } from "./functions/muteUser"; import { offMutesEvent } from "./functions/offMutesEvent"; import { onMutesEvent } from "./functions/onMutesEvent"; @@ -58,7 +59,7 @@ const defaultOptions = { ], }; -const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000; +const EXPIRED_MUTE_CHECK_INTERVAL = 30 * MINUTES; export const MutesPlugin = zeppelinGuildPlugin()({ name: "mutes", @@ -108,14 +109,14 @@ export const MutesPlugin = zeppelinGuildPlugin()({ pluginData.state.cases = GuildCases.getGuildInstance(pluginData.guild.id); pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id); pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); - + pluginData.state.timers = []; pluginData.state.events = new EventEmitter(); }, afterLoad(pluginData) { - clearExpiredMutes(pluginData); + loadExpiringTimers(pluginData); pluginData.state.muteClearIntervalId = setInterval( - () => clearExpiredMutes(pluginData), + () => loadExpiringTimers(pluginData), EXPIRED_MUTE_CHECK_INTERVAL, ); }, @@ -123,5 +124,6 @@ export const MutesPlugin = zeppelinGuildPlugin()({ beforeUnload(pluginData) { clearInterval(pluginData.state.muteClearIntervalId); pluginData.state.events.removeAllListeners(); + pluginData.state.timers = []; }, }); diff --git a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts index dc83fee6..56c48ac9 100644 --- a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts +++ b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts @@ -1,50 +1,102 @@ -import { Snowflake } from "discord.js"; import { GuildPluginData } from "knub"; -import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; -import { LogType } from "../../../data/LogType"; -import { resolveMember, UnknownUser, verboseUserMention } from "../../../utils"; +import { MINUTES, resolveMember, UnknownUser, verboseUserMention } from "../../../utils"; import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { MutesPluginType } from "../types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { ExpiringTimer } from "src/utils/timers"; +import { Mute } from "src/data/entities/Mute"; +import moment from "moment"; -export async function clearExpiredMutes(pluginData: GuildPluginData) { - const expiredMutes = await pluginData.state.mutes.getExpiredMutes(); - for (const mute of expiredMutes) { - const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); +const LOAD_LESS_THAN_MIN_COUNT = 60 * MINUTES; - if (member) { - try { - const lock = await pluginData.locks.acquire(memberRolesLock(member)); - - const muteRole = pluginData.config.get().mute_role; - if (muteRole) { - await member.roles.remove(muteRole); - } - if (mute.roles_to_restore) { - const guildRoles = pluginData.guild.roles.cache; - const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole); - for (const toRestore of mute.roles_to_restore) { - if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { - newRoles.push(toRestore); - } - } - await member.roles.set(newRoles); - } - - lock.unlock(); - } catch { - pluginData.getPlugin(LogsPlugin).logBotAlert({ - body: `Failed to remove mute role from ${verboseUserMention(member.user)}`, - }); - } - } - - await pluginData.state.mutes.clear(mute.user_id); - - pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({ - member: member || new UnknownUser({ id: mute.user_id }), +export function addTimer(pluginData: GuildPluginData, mute: Mute) { + const existingMute = pluginData.state.timers.find( + (tm) => tm.options.key === mute.user_id && tm.options.guildId === mute.guild_id && !tm.done, + ); // for future-proof when you do global events + if (!existingMute && mute.expires_at) { + const exp = moment(mute.expires_at!).toDate().getTime() - moment.utc().toDate().getTime(); + const newTimer = new ExpiringTimer({ + key: mute.user_id, + guildId: mute.guild_id, + plugin: "mutes", + expiry: exp, + callback: async () => { + await clearExpiredMute(pluginData, mute); + }, }); - - pluginData.state.events.emit("unmute", mute.user_id); + pluginData.state.timers.push(newTimer); } } + +export function removeTimer(pluginData: GuildPluginData, mute: Mute) { + const existingMute = pluginData.state.timers.findIndex( + (tm) => tm.options.key === mute.user_id && tm.options.guildId === mute.guild_id && !tm.done, + ); + if (existingMute) { + const tm = pluginData.state.timers[existingMute]; + tm.clear(); + tm.done = true; + pluginData.state.timers.splice(existingMute, 1); + } +} + +export async function loadExpiringTimers(pluginData: GuildPluginData) { + const now = moment.utc().toDate().getTime(); + pluginData.state.timers = pluginData.state.timers.filter((tm) => !tm.done || !tm.timeout); + const mutes = (await pluginData.state.mutes.getAllTemporaryMutes()).filter((m) => m.expires_at); + const expiredMutes = mutes.filter((m) => now >= moment(m.expires_at!).toDate().getTime()); + const expiringMutes = mutes.filter( + (m) => !expiredMutes.find((exp) => exp.user_id === m.user_id && exp.guild_id === m.guild_id), + ); + + for (const mute of expiringMutes) { + const expires = moment(mute.expires_at!).toDate().getTime(); + if (expires <= now) continue; // exclude expired mutes, just in case + if (expires > now + LOAD_LESS_THAN_MIN_COUNT) continue; // exclude timers that are expiring in over 180 mins + + addTimer(pluginData, mute); + } + + for (const mute of expiredMutes) { + await clearExpiredMute(pluginData, mute); + } +} + +export async function clearExpiredMute(pluginData: GuildPluginData, mute: Mute) { + const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true); + + if (member) { + try { + const lock = await pluginData.locks.acquire(memberRolesLock(member)); + + const muteRole = pluginData.config.get().mute_role; + if (muteRole) { + await member.roles.remove(muteRole); + } + if (mute.roles_to_restore) { + const guildRoles = pluginData.guild.roles.cache; + const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole); + for (const toRestore of mute.roles_to_restore) { + if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { + newRoles.push(toRestore); + } + } + await member.roles.set(newRoles); + } + + lock.unlock(); + } catch { + pluginData.getPlugin(LogsPlugin).logBotAlert({ + body: `Failed to remove mute role from ${verboseUserMention(member.user)}`, + }); + } + } + + await pluginData.state.mutes.clear(mute.user_id); + + pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({ + member: member || new UnknownUser({ id: mute.user_id }), + }); + + pluginData.state.events.emit("unmute", mute.user_id); +} diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index cf0aeca0..a9407653 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -19,6 +19,8 @@ import { import { muteLock } from "../../../utils/lockNameHelpers"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { MuteOptions, MutesPluginType } from "../types"; +import { addTimer, removeTimer } from "./clearExpiredMutes"; +import moment from "moment"; export async function muteUser( pluginData: GuildPluginData, @@ -139,8 +141,14 @@ export async function muteUser( rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore])); } await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore); + removeTimer(pluginData, existingMute); + addTimer(pluginData, { + ...existingMute, + expires_at: moment().utc().add(muteTime, "ms").format("YYYY-MM-DD HH:mm:ss"), + }); } else { - await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore); + const mute = await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore); + addTimer(pluginData, mute); } const template = existingMute diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts index 0f2d88d5..b431d1f7 100644 --- a/backend/src/plugins/Mutes/functions/unmuteUser.ts +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -11,6 +11,8 @@ import { CaseArgs } from "../../Cases/types"; import { MutesPluginType, UnmuteResult } from "../types"; import { memberHasMutedRole } from "./memberHasMutedRole"; import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { addTimer, removeTimer } from "./clearExpiredMutes"; +import moment from "moment"; export async function unmuteUser( pluginData: GuildPluginData, @@ -28,9 +30,15 @@ export async function unmuteUser( if (unmuteTime) { // Schedule timed unmute (= just set the mute's duration) if (!existingMute) { - await pluginData.state.mutes.addMute(userId, unmuteTime); + const mute = await pluginData.state.mutes.addMute(userId, unmuteTime); + addTimer(pluginData, mute); } else { await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime); + removeTimer(pluginData, existingMute); + addTimer(pluginData, { + ...existingMute, + expires_at: moment().utc().add(unmuteTime, "ms").format("YYYY-MM-DD HH:mm:ss"), + }); } } else { // Unmute immediately @@ -60,6 +68,7 @@ export async function unmuteUser( ); } if (existingMute) { + removeTimer(pluginData, existingMute); await pluginData.state.mutes.clear(userId); } } diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index c0afcc18..90b16b0e 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -2,6 +2,7 @@ import { GuildMember } from "discord.js"; import { EventEmitter } from "events"; import * as t from "io-ts"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; +import { ExpiringTimer } from "src/utils/timers"; import { Case } from "../../data/entities/Case"; import { Mute } from "../../data/entities/Mute"; import { GuildArchives } from "../../data/GuildArchives"; @@ -54,6 +55,8 @@ export interface MutesPluginType extends BasePluginType { muteClearIntervalId: Timeout; + timers: ExpiringTimer[]; + events: MutesEventEmitter; }; } diff --git a/backend/src/utils/timers.ts b/backend/src/utils/timers.ts new file mode 100644 index 00000000..abce0643 --- /dev/null +++ b/backend/src/utils/timers.ts @@ -0,0 +1,40 @@ +import { Snowflake } from "discord-api-types"; + +type TimerCallback = (key: string, expiry: number) => void; + +type TimerOptions = { + key: Snowflake; + guildId?: Snowflake; + expiry: number; + plugin?: string; + callback: TimerCallback; +}; + +export class ExpiringTimer { + done: boolean = false; + options: TimerOptions; + timeout?: NodeJS.Timeout; + data?: any; // idk how to make this take generic typings data + isValid() { + return !this.done; + } + private execute() { + if (!this.isValid()) return; + this.options.callback(this.options.key, this.options.expiry); + this.done = true; + } + init() { + if (this.timeout) this.clear(); + this.timeout = setTimeout(() => this.execute(), this.options.expiry); + } + clear() { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = undefined; + } + } + constructor(options: TimerOptions) { + this.options = options; + this.init(); + } +}