feat: timeout support

This commit is contained in:
Dragory 2023-04-01 18:33:09 +03:00 committed by Miikka
parent 06877e90cc
commit 39e0dfa27f
23 changed files with 532 additions and 92 deletions

View file

@ -6,6 +6,7 @@ import { VCAlert } from "./entities/VCAlert";
interface GuildEventArgs extends Record<string, unknown[]> {
expiredMute: [Mute];
timeoutMuteToRenew: [Mute];
scheduledPost: [ScheduledPost];
reminder: [Reminder];
expiredTempban: [Tempban];

View file

@ -1,7 +1,18 @@
import moment from "moment-timezone";
import { Brackets, getRepository, Repository } from "typeorm";
import { DBDateFormat } from "../utils";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { Mute } from "./entities/Mute";
import { MuteTypes } from "./MuteTypes";
export type AddMuteParams = {
userId: Mute["user_id"];
type: MuteTypes;
expiresAt: number | null;
rolesToRestore?: Mute["roles_to_restore"];
muteRole?: string | null;
timeoutExpiresAt?: number;
};
export class GuildMutes extends BaseGuildRepository {
private mutes: Repository<Mute>;
@ -34,14 +45,18 @@ export class GuildMutes extends BaseGuildRepository {
return mute != null;
}
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
async addMute(params: AddMuteParams): Promise<Mute> {
const expiresAt = params.expiresAt ? moment.utc(params.expiresAt).format(DBDateFormat) : null;
const timeoutExpiresAt = params.timeoutExpiresAt ? moment.utc(params.timeoutExpiresAt).format(DBDateFormat) : null;
const result = await this.mutes.insert({
guild_id: this.guildId,
user_id: userId,
user_id: params.userId,
type: params.type,
expires_at: expiresAt,
roles_to_restore: rolesToRestore ?? [],
roles_to_restore: params.rolesToRestore ?? [],
mute_role: params.muteRole,
timeout_expires_at: timeoutExpiresAt,
});
return (await this.mutes.findOne({ where: result.identifiers[0] }))!;
@ -74,6 +89,32 @@ export class GuildMutes extends BaseGuildRepository {
}
}
async updateExpiresAt(userId: string, timestamp: number | null): Promise<void> {
const expiresAt = timestamp ? moment.utc(timestamp).format("YYYY-MM-DD HH:mm:ss") : null;
await this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
expires_at: expiresAt,
},
);
}
async updateTimeoutExpiresAt(userId: string, timestamp: number): Promise<void> {
const timeoutExpiresAt = moment.utc(timestamp).format(DBDateFormat);
await this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
timeout_expires_at: timeoutExpiresAt,
},
);
}
async getActiveMutes(): Promise<Mute[]> {
return this.mutes
.createQueryBuilder("mutes")
@ -104,4 +145,16 @@ export class GuildMutes extends BaseGuildRepository {
user_id: userId,
});
}
async fillMissingMuteRole(muteRole: string): Promise<void> {
await this.mutes
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("type = :type", { type: MuteTypes.Role })
.andWhere("mute_role IS NULL")
.update({
mute_role: muteRole,
})
.execute();
}
}

View file

@ -0,0 +1,4 @@
export enum MuteTypes {
Role = 1,
Timeout = 2,
}

View file

@ -3,9 +3,14 @@ import { getRepository, Repository } from "typeorm";
import { DAYS, DBDateFormat } from "../utils";
import { BaseRepository } from "./BaseRepository";
import { Mute } from "./entities/Mute";
import { MuteTypes } from "./MuteTypes";
const OLD_EXPIRED_MUTE_THRESHOLD = 7 * DAYS;
export const MAX_TIMEOUT_DURATION = 28 * DAYS;
// When a timeout is under this duration but the mute expires later, the timeout will be reset to max duration
export const TIMEOUT_RENEWAL_THRESHOLD = 21 * DAYS;
export class Mutes extends BaseRepository {
private mutes: Repository<Mute>;
@ -14,7 +19,16 @@ export class Mutes extends BaseRepository {
this.mutes = getRepository(Mute);
}
async getSoonExpiringMutes(threshold: number): Promise<Mute[]> {
findMute(guildId: string, userId: string): Promise<Mute | undefined> {
return this.mutes.findOne({
where: {
guild_id: guildId,
user_id: userId,
},
});
}
getSoonExpiringMutes(threshold: number): Promise<Mute[]> {
const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
return this.mutes
.createQueryBuilder("mutes")
@ -23,6 +37,16 @@ export class Mutes extends BaseRepository {
.getMany();
}
getTimeoutMutesToRenew(threshold: number): Promise<Mute[]> {
const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
return this.mutes
.createQueryBuilder("mutes")
.andWhere("type = :type", { type: MuteTypes.Timeout })
.andWhere("(expires_at IS NULL OR timeout_expires_at < expires_at)")
.andWhere("timeout_expires_at <= :date", { date: thresholdDateStr })
.getMany();
}
async clearOldExpiredMutes(): Promise<void> {
const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, "ms").format(DBDateFormat);
await this.mutes

View file

@ -10,6 +10,8 @@ export class Mute {
@PrimaryColumn()
user_id: string;
@Column() type: number;
@Column() created_at: string;
@Column({ type: String, nullable: true }) expires_at: string | null;
@ -17,4 +19,8 @@ export class Mute {
@Column() case_id: number;
@Column("simple-array") roles_to_restore: string[];
@Column({ type: String, nullable: true }) mute_role: string | null;
@Column({ type: String, nullable: true }) timeout_expires_at: string | null;
}

View file

@ -1,10 +1,10 @@
// tslint:disable:no-console
import moment from "moment-timezone";
import { lazyMemoize, MINUTES } from "../../utils";
import { lazyMemoize, MINUTES, SECONDS } from "../../utils";
import { Mute } from "../entities/Mute";
import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
import { Mutes } from "../Mutes";
import { Mutes, TIMEOUT_RENEWAL_THRESHOLD } from "../Mutes";
import Timeout = NodeJS.Timeout;
const LOOP_INTERVAL = 15 * MINUTES;
@ -16,14 +16,24 @@ function muteToKey(mute: Mute) {
return `${mute.guild_id}/${mute.user_id}`;
}
function broadcastExpiredMute(mute: Mute, tries = 0) {
async function broadcastExpiredMute(guildId: string, userId: string, tries = 0) {
const mute = await getMutesRepository().findMute(guildId, userId);
if (!mute) {
// Mute was already cleared
return;
}
if (!mute.expires_at || moment(mute.expires_at).diff(moment()) > 10 * SECONDS) {
// Mute duration was changed and it's no longer expiring now
return;
}
console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`);
if (!hasGuildEventListener(mute.guild_id, "expiredMute")) {
// If there are no listeners registered for the server yet, try again in a bit
if (tries < MAX_TRIES_PER_SERVER) {
timeouts.set(
muteToKey(mute),
setTimeout(() => broadcastExpiredMute(mute, tries + 1), 1 * MINUTES),
setTimeout(() => broadcastExpiredMute(guildId, userId, tries + 1), 1 * MINUTES),
);
}
return;
@ -31,6 +41,21 @@ function broadcastExpiredMute(mute: Mute, tries = 0) {
emitGuildEvent(mute.guild_id, "expiredMute", [mute]);
}
function broadcastTimeoutMuteToRenew(mute: Mute, tries = 0) {
console.log(`[EXPIRING MUTES LOOP] Broadcasting timeout mute to renew: ${mute.guild_id}/${mute.user_id}`);
if (!hasGuildEventListener(mute.guild_id, "timeoutMuteToRenew")) {
// If there are no listeners registered for the server yet, try again in a bit
if (tries < MAX_TRIES_PER_SERVER) {
timeouts.set(
muteToKey(mute),
setTimeout(() => broadcastTimeoutMuteToRenew(mute, tries + 1), 1 * MINUTES),
);
}
return;
}
emitGuildEvent(mute.guild_id, "timeoutMuteToRenew", [mute]);
}
export async function runExpiringMutesLoop() {
console.log("[EXPIRING MUTES LOOP] Clearing old timeouts");
for (const timeout of timeouts.values()) {
@ -46,10 +71,16 @@ export async function runExpiringMutesLoop() {
const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc()));
timeouts.set(
muteToKey(mute),
setTimeout(() => broadcastExpiredMute(mute), remaining),
setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining),
);
}
console.log("[EXPIRING MUTES LOOP] Broadcasting timeout mutes to renew");
const timeoutMutesToRenew = await getMutesRepository().getTimeoutMutesToRenew(TIMEOUT_RENEWAL_THRESHOLD);
for (const mute of timeoutMutesToRenew) {
broadcastTimeoutMuteToRenew(mute);
}
console.log("[EXPIRING MUTES LOOP] Scheduling next loop");
setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL);
}
@ -69,7 +100,7 @@ export function registerExpiringMute(mute: Mute) {
timeouts.set(
muteToKey(mute),
setTimeout(() => broadcastExpiredMute(mute), remaining),
setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining),
);
}