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);
}
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[]> {
return this.mutes
.createQueryBuilder("mutes")

View file

@ -11,9 +11,17 @@ export class GuildTempbans extends BaseGuildRepository {
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[]> {
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()")

View file

@ -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<ModActionsPluginType>()({
name: "mod_actions",
showInDocs: true,
@ -200,8 +202,8 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
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<ModActionsPluginType>()({
},
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 = [];
},
});

View file

@ -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

View file

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

View file

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

View file

@ -4,25 +4,86 @@ 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<ModActionsPluginType>) {
const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans();
export function addTimer(pluginData: GuildPluginData<ModActionsPluginType>, 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.timers.push(newTimer);
}
}
for (const tempban of outdatedTempbans) {
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);
continue;
return;
}
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id);
@ -62,9 +123,4 @@ export async function outdatedTempbansLoop(pluginData: GuildPluginData<ModAction
reason,
banTime: humanizeDuration(banTime),
});
}
if (!pluginData.state.unloaded) {
pluginData.state.outdatedTempbansTimeout = setTimeout(() => outdatedTempbansLoop(pluginData), TEMPBAN_LOOP_TIME);
}
}

View file

@ -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;

View file

@ -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<MutesPluginType>()({
name: "mutes",
@ -108,14 +109,14 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
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<MutesPluginType>()({
beforeUnload(pluginData) {
clearInterval(pluginData.state.muteClearIntervalId);
pluginData.state.events.removeAllListeners();
pluginData.state.timers = [];
},
});

View file

@ -1,15 +1,68 @@
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";
const LOAD_LESS_THAN_MIN_COUNT = 60 * MINUTES;
export function addTimer(pluginData: GuildPluginData<MutesPluginType>, 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.timers.push(newTimer);
}
}
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);
}
export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginType>) {
const expiredMutes = await pluginData.state.mutes.getExpiredMutes();
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) {
@ -46,5 +99,4 @@ export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginT
});
pluginData.state.events.emit("unmute", mute.user_id);
}
}

View file

@ -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<MutesPluginType>,
@ -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

View file

@ -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<MutesPluginType>,
@ -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);
}
}

View file

@ -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;
};
}

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