3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-23 17:45:03 +00:00

initial - tempbans & mutes

This commit is contained in:
metal 2021-09-20 21:39:20 +00:00 committed by GitHub
parent c84d1a0be1
commit 744b9273bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 322 additions and 109 deletions

View file

@ -11,6 +11,14 @@ export class GuildMutes extends BaseGuildRepository {
this.mutes = getRepository(Mute); this.mutes = getRepository(Mute);
} }
async getAllTemporaryMutes(): Promise<Mute[]> {
return this.mutes
.createQueryBuilder("mutes")
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("expires_at IS NOT NULL")
.getMany();
}
async getExpiredMutes(): Promise<Mute[]> { async getExpiredMutes(): Promise<Mute[]> {
return this.mutes return this.mutes
.createQueryBuilder("mutes") .createQueryBuilder("mutes")

View file

@ -11,9 +11,17 @@ export class GuildTempbans extends BaseGuildRepository {
this.tempbans = getRepository(Tempban); this.tempbans = getRepository(Tempban);
} }
async getAllTempbans(): Promise<Tempban[]> {
return this.tempbans
.createQueryBuilder("tempbans")
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("expires_at IS NOT NULL")
.getMany();
}
async getExpiredTempbans(): Promise<Tempban[]> { async getExpiredTempbans(): Promise<Tempban[]> {
return this.tempbans return this.tempbans
.createQueryBuilder("mutes") .createQueryBuilder("tempbans")
.where("guild_id = :guild_id", { guild_id: this.guildId }) .where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("expires_at IS NOT NULL") .andWhere("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()") .andWhere("expires_at <= NOW()")

View file

@ -42,7 +42,7 @@ import { hasMutePermission } from "./functions/hasMutePerm";
import { kickMember } from "./functions/kickMember"; import { kickMember } from "./functions/kickMember";
import { offModActionsEvent } from "./functions/offModActionsEvent"; import { offModActionsEvent } from "./functions/offModActionsEvent";
import { onModActionsEvent } from "./functions/onModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent";
import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop"; import { loadExpiringTimers } from "./functions/outdatedTempbansLoop";
import { updateCase } from "./functions/updateCase"; import { updateCase } from "./functions/updateCase";
import { warnMember } from "./functions/warnMember"; import { warnMember } from "./functions/warnMember";
import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types"; 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<ModActionsPluginType>()({ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
name: "mod_actions", name: "mod_actions",
showInDocs: true, showInDocs: true,
@ -200,8 +202,8 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
state.serverLogs = new GuildLogs(guild.id); state.serverLogs = new GuildLogs(guild.id);
state.unloaded = false; state.unloaded = false;
state.outdatedTempbansTimeout = null;
state.ignoredEvents = []; state.ignoredEvents = [];
pluginData.state.timers = [];
// Massbans can take a while depending on rate limits, // Massbans can take a while depending on rate limits,
// so we're giving each massban 15 minutes to complete before launching the next massban // so we're giving each massban 15 minutes to complete before launching the next massban
state.massbanQueue = new Queue(15 * MINUTES); state.massbanQueue = new Queue(15 * MINUTES);
@ -210,11 +212,17 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
}, },
afterLoad(pluginData) { afterLoad(pluginData) {
outdatedTempbansLoop(pluginData); loadExpiringTimers(pluginData);
pluginData.state.banClearIntervalId = setInterval(
() => loadExpiringTimers(pluginData),
EXPIRED_BANS_CHECK_INTERVAL,
);
}, },
beforeUnload(pluginData) { beforeUnload(pluginData) {
clearInterval(pluginData.state.banClearIntervalId);
pluginData.state.unloaded = true; pluginData.state.unloaded = true;
pluginData.state.events.removeAllListeners(); pluginData.state.events.removeAllListeners();
pluginData.state.timers = [];
}, },
}); });

View file

@ -15,6 +15,8 @@ import { isBanned } from "../functions/isBanned";
import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs";
import { modActionsCmd } from "../types"; import { modActionsCmd } from "../types";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import moment from "moment";
import { addTimer, removeTimer, removeTimerByUserId } from "../functions/outdatedTempbansLoop";
const opts = { const opts = {
mod: ct.member({ option: true }), mod: ct.member({ option: true }),
@ -93,11 +95,18 @@ export const BanCmd = modActionsCmd({
if (time && time > 0) { if (time && time > 0) {
if (existingTempban) { if (existingTempban) {
pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id); 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 { } 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) { } else if (existingTempban) {
pluginData.state.tempbans.clear(user.id); 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 // Create a new case for the updated ban since we never stored the old case id and log the action

View file

@ -10,6 +10,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach
import { ignoreEvent } from "../functions/ignoreEvent"; import { ignoreEvent } from "../functions/ignoreEvent";
import { IgnoredEventType, modActionsCmd } from "../types"; import { IgnoredEventType, modActionsCmd } from "../types";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { removeTimerByUserId } from "../functions/outdatedTempbansLoop";
const opts = { const opts = {
mod: ct.member({ option: true }), mod: ct.member({ option: true }),
@ -69,7 +70,7 @@ export const UnbanCmd = modActionsCmd({
}); });
// Delete the tempban, if one exists // Delete the tempban, if one exists
pluginData.state.tempbans.clear(user.id); pluginData.state.tempbans.clear(user.id);
removeTimerByUserId(pluginData, user.id);
// Confirm the action // Confirm the action
sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`);

View file

@ -19,6 +19,8 @@ import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from ".
import { getDefaultContactMethods } from "./getDefaultContactMethods"; import { getDefaultContactMethods } from "./getDefaultContactMethods";
import { ignoreEvent } from "./ignoreEvent"; import { ignoreEvent } from "./ignoreEvent";
import { LogsPlugin } from "../../Logs/LogsPlugin"; 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. * 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; const selfId = pluginData.client.user!.id;
if (existingTempban) { if (existingTempban) {
pluginData.state.tempbans.updateExpiryTime(user.id, banTime, banOptions.modId ?? selfId); 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 { } 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);
} }
} }

View file

@ -4,67 +4,123 @@ import { GuildPluginData } from "knub";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { LogType } from "src/data/LogType"; import { LogType } from "src/data/LogType";
import { logger } from "src/logger"; import { logger } from "src/logger";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
import { CaseTypes } from "../../../data/CaseTypes"; import { CaseTypes } from "../../../data/CaseTypes";
import { resolveUser, SECONDS } from "../../../utils"; import { MINUTES, resolveUser } from "../../../utils";
import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CasesPlugin } from "../../Cases/CasesPlugin";
import { IgnoredEventType, ModActionsPluginType } from "../types"; import { IgnoredEventType, ModActionsPluginType } from "../types";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
import { ignoreEvent } from "./ignoreEvent"; import { ignoreEvent } from "./ignoreEvent";
import { isBanned } from "./isBanned"; import { isBanned } from "./isBanned";
import { LogsPlugin } from "../../Logs/LogsPlugin"; 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<ModActionsPluginType>) { export function addTimer(pluginData: GuildPluginData<ModActionsPluginType>, tempban: Tempban) {
const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans(); const existingMute = pluginData.state.timers.find(
(tm) => tm.options.key === tempban.user_id && tm.options.guildId === tempban.guild_id && !tm.done,
for (const tempban of outdatedTempbans) { ); // for future-proof when you do global events
if (!(await isBanned(pluginData, tempban.user_id))) { if (!existingMute && tempban.expires_at) {
pluginData.state.tempbans.clear(tempban.user_id); const exp = moment(tempban.expires_at!).toDate().getTime() - moment.utc().toDate().getTime();
continue; const newTimer = new ExpiringTimer({
} key: tempban.user_id,
guildId: tempban.guild_id,
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id); plugin: "tempban",
const reason = formatReasonWithAttachments( expiry: exp,
`Tempban timed out. callback: async () => {
Tempbanned at: \`${tempban.created_at} UTC\``, await clearTempBan(pluginData, tempban);
[], },
);
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); pluginData.state.timers.push(newTimer);
// 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);
} }
} }
export function removeTimer(pluginData: GuildPluginData<ModActionsPluginType>, 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<ModActionsPluginType>, 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<ModActionsPluginType>) {
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<ModActionsPluginType>, 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),
});
}

View file

@ -2,6 +2,7 @@ import { TextChannel } from "discord.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import * as t from "io-ts"; import * as t from "io-ts";
import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub";
import { ExpiringTimer } from "src/utils/timers";
import { Case } from "../../data/entities/Case"; import { Case } from "../../data/entities/Case";
import { GuildCases } from "../../data/GuildCases"; import { GuildCases } from "../../data/GuildCases";
import { GuildLogs } from "../../data/GuildLogs"; import { GuildLogs } from "../../data/GuildLogs";
@ -70,9 +71,9 @@ export interface ModActionsPluginType extends BasePluginType {
cases: GuildCases; cases: GuildCases;
tempbans: GuildTempbans; tempbans: GuildTempbans;
serverLogs: GuildLogs; serverLogs: GuildLogs;
banClearIntervalId: Timeout;
timers: ExpiringTimer[];
unloaded: boolean; unloaded: boolean;
outdatedTempbansTimeout: Timeout | null;
ignoredEvents: IIgnoredEvent[]; ignoredEvents: IIgnoredEvent[];
massbanQueue: Queue; massbanQueue: Queue;

View file

@ -1,5 +1,6 @@
import { GuildMember, Snowflake } from "discord.js"; import { GuildMember, Snowflake } from "discord.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { MINUTES } from "src/utils";
import { GuildArchives } from "../../data/GuildArchives"; import { GuildArchives } from "../../data/GuildArchives";
import { GuildCases } from "../../data/GuildCases"; import { GuildCases } from "../../data/GuildCases";
import { GuildLogs } from "../../data/GuildLogs"; import { GuildLogs } from "../../data/GuildLogs";
@ -15,7 +16,7 @@ import { MutesCmd } from "./commands/MutesCmd";
import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt"; import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt";
import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt"; import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt";
import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt"; import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt";
import { clearExpiredMutes } from "./functions/clearExpiredMutes"; import { loadExpiringTimers } from "./functions/clearExpiredMutes";
import { muteUser } from "./functions/muteUser"; import { muteUser } from "./functions/muteUser";
import { offMutesEvent } from "./functions/offMutesEvent"; import { offMutesEvent } from "./functions/offMutesEvent";
import { onMutesEvent } from "./functions/onMutesEvent"; 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<MutesPluginType>()({ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
name: "mutes", name: "mutes",
@ -108,14 +109,14 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
pluginData.state.cases = GuildCases.getGuildInstance(pluginData.guild.id); pluginData.state.cases = GuildCases.getGuildInstance(pluginData.guild.id);
pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id); pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id);
pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id);
pluginData.state.timers = [];
pluginData.state.events = new EventEmitter(); pluginData.state.events = new EventEmitter();
}, },
afterLoad(pluginData) { afterLoad(pluginData) {
clearExpiredMutes(pluginData); loadExpiringTimers(pluginData);
pluginData.state.muteClearIntervalId = setInterval( pluginData.state.muteClearIntervalId = setInterval(
() => clearExpiredMutes(pluginData), () => loadExpiringTimers(pluginData),
EXPIRED_MUTE_CHECK_INTERVAL, EXPIRED_MUTE_CHECK_INTERVAL,
); );
}, },
@ -123,5 +124,6 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
beforeUnload(pluginData) { beforeUnload(pluginData) {
clearInterval(pluginData.state.muteClearIntervalId); clearInterval(pluginData.state.muteClearIntervalId);
pluginData.state.events.removeAllListeners(); pluginData.state.events.removeAllListeners();
pluginData.state.timers = [];
}, },
}); });

View file

@ -1,50 +1,102 @@
import { Snowflake } from "discord.js";
import { GuildPluginData } from "knub"; import { GuildPluginData } from "knub";
import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects"; import { MINUTES, resolveMember, UnknownUser, verboseUserMention } from "../../../utils";
import { LogType } from "../../../data/LogType";
import { resolveMember, UnknownUser, verboseUserMention } from "../../../utils";
import { memberRolesLock } from "../../../utils/lockNameHelpers"; import { memberRolesLock } from "../../../utils/lockNameHelpers";
import { MutesPluginType } from "../types"; import { MutesPluginType } from "../types";
import { LogsPlugin } from "../../Logs/LogsPlugin"; 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<MutesPluginType>) { const LOAD_LESS_THAN_MIN_COUNT = 60 * MINUTES;
const expiredMutes = await pluginData.state.mutes.getExpiredMutes();
for (const mute of expiredMutes) {
const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true);
if (member) { export function addTimer(pluginData: GuildPluginData<MutesPluginType>, mute: Mute) {
try { const existingMute = pluginData.state.timers.find(
const lock = await pluginData.locks.acquire(memberRolesLock(member)); (tm) => tm.options.key === mute.user_id && tm.options.guildId === mute.guild_id && !tm.done,
); // for future-proof when you do global events
const muteRole = pluginData.config.get().mute_role; if (!existingMute && mute.expires_at) {
if (muteRole) { const exp = moment(mute.expires_at!).toDate().getTime() - moment.utc().toDate().getTime();
await member.roles.remove(muteRole); const newTimer = new ExpiringTimer({
} key: mute.user_id,
if (mute.roles_to_restore) { guildId: mute.guild_id,
const guildRoles = pluginData.guild.roles.cache; plugin: "mutes",
const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole); expiry: exp,
for (const toRestore of mute.roles_to_restore) { callback: async () => {
if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) { await clearExpiredMute(pluginData, mute);
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.timers.push(newTimer);
pluginData.state.events.emit("unmute", mute.user_id);
} }
} }
export function removeTimer(pluginData: GuildPluginData<MutesPluginType>, 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<MutesPluginType>) {
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<MutesPluginType>, 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);
}

View file

@ -19,6 +19,8 @@ import {
import { muteLock } from "../../../utils/lockNameHelpers"; import { muteLock } from "../../../utils/lockNameHelpers";
import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CasesPlugin } from "../../Cases/CasesPlugin";
import { MuteOptions, MutesPluginType } from "../types"; import { MuteOptions, MutesPluginType } from "../types";
import { addTimer, removeTimer } from "./clearExpiredMutes";
import moment from "moment";
export async function muteUser( export async function muteUser(
pluginData: GuildPluginData<MutesPluginType>, pluginData: GuildPluginData<MutesPluginType>,
@ -139,8 +141,14 @@ export async function muteUser(
rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore])); rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore]));
} }
await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, 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 { } 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 const template = existingMute

View file

@ -11,6 +11,8 @@ import { CaseArgs } from "../../Cases/types";
import { MutesPluginType, UnmuteResult } from "../types"; import { MutesPluginType, UnmuteResult } from "../types";
import { memberHasMutedRole } from "./memberHasMutedRole"; import { memberHasMutedRole } from "./memberHasMutedRole";
import { LogsPlugin } from "../../Logs/LogsPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin";
import { addTimer, removeTimer } from "./clearExpiredMutes";
import moment from "moment";
export async function unmuteUser( export async function unmuteUser(
pluginData: GuildPluginData<MutesPluginType>, pluginData: GuildPluginData<MutesPluginType>,
@ -28,9 +30,15 @@ export async function unmuteUser(
if (unmuteTime) { if (unmuteTime) {
// Schedule timed unmute (= just set the mute's duration) // Schedule timed unmute (= just set the mute's duration)
if (!existingMute) { if (!existingMute) {
await pluginData.state.mutes.addMute(userId, unmuteTime); const mute = await pluginData.state.mutes.addMute(userId, unmuteTime);
addTimer(pluginData, mute);
} else { } else {
await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime); 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 { } else {
// Unmute immediately // Unmute immediately
@ -60,6 +68,7 @@ export async function unmuteUser(
); );
} }
if (existingMute) { if (existingMute) {
removeTimer(pluginData, existingMute);
await pluginData.state.mutes.clear(userId); await pluginData.state.mutes.clear(userId);
} }
} }

View file

@ -2,6 +2,7 @@ import { GuildMember } from "discord.js";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import * as t from "io-ts"; import * as t from "io-ts";
import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub";
import { ExpiringTimer } from "src/utils/timers";
import { Case } from "../../data/entities/Case"; import { Case } from "../../data/entities/Case";
import { Mute } from "../../data/entities/Mute"; import { Mute } from "../../data/entities/Mute";
import { GuildArchives } from "../../data/GuildArchives"; import { GuildArchives } from "../../data/GuildArchives";
@ -54,6 +55,8 @@ export interface MutesPluginType extends BasePluginType {
muteClearIntervalId: Timeout; muteClearIntervalId: Timeout;
timers: ExpiringTimer[];
events: MutesEventEmitter; events: MutesEventEmitter;
}; };
} }

View file

@ -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 <T> 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();
}
}