mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-03-14 21:31:50 +00:00
feat: timeout support
This commit is contained in:
parent
06877e90cc
commit
7826cff2b2
23 changed files with 532 additions and 92 deletions
14
backend/package-lock.json
generated
14
backend/package-lock.json
generated
|
@ -23,7 +23,7 @@
|
||||||
"humanize-duration": "^3.15.0",
|
"humanize-duration": "^3.15.0",
|
||||||
"io-ts": "^2.0.0",
|
"io-ts": "^2.0.0",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"knub": "^32.0.0-next.4",
|
"knub": "^32.0.0-next.5",
|
||||||
"knub-command-manager": "^9.1.0",
|
"knub-command-manager": "^9.1.0",
|
||||||
"last-commit-log": "^2.1.0",
|
"last-commit-log": "^2.1.0",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
|
@ -2704,9 +2704,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/knub": {
|
"node_modules/knub": {
|
||||||
"version": "32.0.0-next.4",
|
"version": "32.0.0-next.5",
|
||||||
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.4.tgz",
|
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.5.tgz",
|
||||||
"integrity": "sha512-ywZbwcGFSr4Erl/nEUDVmziQHXKVIykWtI2Z05DLt01YmxDS+rTO8l/E6LYx7ZL3m+f2DbtLH0HB8zaZb0pUag==",
|
"integrity": "sha512-emWfjgdYSabbPlngJkh/V8/93iCuhvR7Rp1tnLu/lUNUpq+IO66PSefxuzRfYZ4XrOZBTEbeKZ/2RakAwDU3MA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "^14.8.0",
|
"discord.js": "^14.8.0",
|
||||||
"knub-command-manager": "^9.1.0",
|
"knub-command-manager": "^9.1.0",
|
||||||
|
@ -7201,9 +7201,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"knub": {
|
"knub": {
|
||||||
"version": "32.0.0-next.4",
|
"version": "32.0.0-next.5",
|
||||||
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.4.tgz",
|
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.5.tgz",
|
||||||
"integrity": "sha512-ywZbwcGFSr4Erl/nEUDVmziQHXKVIykWtI2Z05DLt01YmxDS+rTO8l/E6LYx7ZL3m+f2DbtLH0HB8zaZb0pUag==",
|
"integrity": "sha512-emWfjgdYSabbPlngJkh/V8/93iCuhvR7Rp1tnLu/lUNUpq+IO66PSefxuzRfYZ4XrOZBTEbeKZ/2RakAwDU3MA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"discord.js": "^14.8.0",
|
"discord.js": "^14.8.0",
|
||||||
"knub-command-manager": "^9.1.0",
|
"knub-command-manager": "^9.1.0",
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
"humanize-duration": "^3.15.0",
|
"humanize-duration": "^3.15.0",
|
||||||
"io-ts": "^2.0.0",
|
"io-ts": "^2.0.0",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"knub": "^32.0.0-next.4",
|
"knub": "^32.0.0-next.5",
|
||||||
"knub-command-manager": "^9.1.0",
|
"knub-command-manager": "^9.1.0",
|
||||||
"last-commit-log": "^2.1.0",
|
"last-commit-log": "^2.1.0",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { VCAlert } from "./entities/VCAlert";
|
||||||
|
|
||||||
interface GuildEventArgs extends Record<string, unknown[]> {
|
interface GuildEventArgs extends Record<string, unknown[]> {
|
||||||
expiredMute: [Mute];
|
expiredMute: [Mute];
|
||||||
|
timeoutMuteToRenew: [Mute];
|
||||||
scheduledPost: [ScheduledPost];
|
scheduledPost: [ScheduledPost];
|
||||||
reminder: [Reminder];
|
reminder: [Reminder];
|
||||||
expiredTempban: [Tempban];
|
expiredTempban: [Tempban];
|
||||||
|
|
|
@ -1,7 +1,18 @@
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { Brackets, getRepository, Repository } from "typeorm";
|
import { Brackets, getRepository, Repository } from "typeorm";
|
||||||
|
import { DBDateFormat } from "../utils";
|
||||||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||||
import { Mute } from "./entities/Mute";
|
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 {
|
export class GuildMutes extends BaseGuildRepository {
|
||||||
private mutes: Repository<Mute>;
|
private mutes: Repository<Mute>;
|
||||||
|
@ -34,14 +45,18 @@ export class GuildMutes extends BaseGuildRepository {
|
||||||
return mute != null;
|
return mute != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
|
async addMute(params: AddMuteParams): Promise<Mute> {
|
||||||
const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
|
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({
|
const result = await this.mutes.insert({
|
||||||
guild_id: this.guildId,
|
guild_id: this.guildId,
|
||||||
user_id: userId,
|
user_id: params.userId,
|
||||||
|
type: params.type,
|
||||||
expires_at: expiresAt,
|
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] }))!;
|
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[]> {
|
async getActiveMutes(): Promise<Mute[]> {
|
||||||
return this.mutes
|
return this.mutes
|
||||||
.createQueryBuilder("mutes")
|
.createQueryBuilder("mutes")
|
||||||
|
@ -104,4 +145,16 @@ export class GuildMutes extends BaseGuildRepository {
|
||||||
user_id: userId,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
4
backend/src/data/MuteTypes.ts
Normal file
4
backend/src/data/MuteTypes.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export enum MuteTypes {
|
||||||
|
Role = 1,
|
||||||
|
Timeout = 2,
|
||||||
|
}
|
|
@ -3,9 +3,14 @@ import { getRepository, Repository } from "typeorm";
|
||||||
import { DAYS, DBDateFormat } from "../utils";
|
import { DAYS, DBDateFormat } from "../utils";
|
||||||
import { BaseRepository } from "./BaseRepository";
|
import { BaseRepository } from "./BaseRepository";
|
||||||
import { Mute } from "./entities/Mute";
|
import { Mute } from "./entities/Mute";
|
||||||
|
import { MuteTypes } from "./MuteTypes";
|
||||||
|
|
||||||
const OLD_EXPIRED_MUTE_THRESHOLD = 7 * DAYS;
|
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 {
|
export class Mutes extends BaseRepository {
|
||||||
private mutes: Repository<Mute>;
|
private mutes: Repository<Mute>;
|
||||||
|
|
||||||
|
@ -14,7 +19,16 @@ export class Mutes extends BaseRepository {
|
||||||
this.mutes = getRepository(Mute);
|
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);
|
const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
|
||||||
return this.mutes
|
return this.mutes
|
||||||
.createQueryBuilder("mutes")
|
.createQueryBuilder("mutes")
|
||||||
|
@ -23,6 +37,16 @@ export class Mutes extends BaseRepository {
|
||||||
.getMany();
|
.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> {
|
async clearOldExpiredMutes(): Promise<void> {
|
||||||
const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, "ms").format(DBDateFormat);
|
const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, "ms").format(DBDateFormat);
|
||||||
await this.mutes
|
await this.mutes
|
||||||
|
|
|
@ -10,6 +10,8 @@ export class Mute {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|
||||||
|
@Column() type: number;
|
||||||
|
|
||||||
@Column() created_at: string;
|
@Column() created_at: string;
|
||||||
|
|
||||||
@Column({ type: String, nullable: true }) expires_at: string | null;
|
@Column({ type: String, nullable: true }) expires_at: string | null;
|
||||||
|
@ -17,4 +19,8 @@ export class Mute {
|
||||||
@Column() case_id: number;
|
@Column() case_id: number;
|
||||||
|
|
||||||
@Column("simple-array") roles_to_restore: string[];
|
@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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
// tslint:disable:no-console
|
// tslint:disable:no-console
|
||||||
|
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { lazyMemoize, MINUTES } from "../../utils";
|
import { lazyMemoize, MINUTES, SECONDS } from "../../utils";
|
||||||
import { Mute } from "../entities/Mute";
|
import { Mute } from "../entities/Mute";
|
||||||
import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
|
import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
|
||||||
import { Mutes } from "../Mutes";
|
import { Mutes, TIMEOUT_RENEWAL_THRESHOLD } from "../Mutes";
|
||||||
import Timeout = NodeJS.Timeout;
|
import Timeout = NodeJS.Timeout;
|
||||||
|
|
||||||
const LOOP_INTERVAL = 15 * MINUTES;
|
const LOOP_INTERVAL = 15 * MINUTES;
|
||||||
|
@ -16,14 +16,24 @@ function muteToKey(mute: Mute) {
|
||||||
return `${mute.guild_id}/${mute.user_id}`;
|
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}`);
|
console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`);
|
||||||
if (!hasGuildEventListener(mute.guild_id, "expiredMute")) {
|
if (!hasGuildEventListener(mute.guild_id, "expiredMute")) {
|
||||||
// If there are no listeners registered for the server yet, try again in a bit
|
// If there are no listeners registered for the server yet, try again in a bit
|
||||||
if (tries < MAX_TRIES_PER_SERVER) {
|
if (tries < MAX_TRIES_PER_SERVER) {
|
||||||
timeouts.set(
|
timeouts.set(
|
||||||
muteToKey(mute),
|
muteToKey(mute),
|
||||||
setTimeout(() => broadcastExpiredMute(mute, tries + 1), 1 * MINUTES),
|
setTimeout(() => broadcastExpiredMute(guildId, userId, tries + 1), 1 * MINUTES),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
@ -31,6 +41,21 @@ function broadcastExpiredMute(mute: Mute, tries = 0) {
|
||||||
emitGuildEvent(mute.guild_id, "expiredMute", [mute]);
|
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() {
|
export async function runExpiringMutesLoop() {
|
||||||
console.log("[EXPIRING MUTES LOOP] Clearing old timeouts");
|
console.log("[EXPIRING MUTES LOOP] Clearing old timeouts");
|
||||||
for (const timeout of timeouts.values()) {
|
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()));
|
const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc()));
|
||||||
timeouts.set(
|
timeouts.set(
|
||||||
muteToKey(mute),
|
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");
|
console.log("[EXPIRING MUTES LOOP] Scheduling next loop");
|
||||||
setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL);
|
setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL);
|
||||||
}
|
}
|
||||||
|
@ -69,7 +100,7 @@ export function registerExpiringMute(mute: Mute) {
|
||||||
|
|
||||||
timeouts.set(
|
timeouts.set(
|
||||||
muteToKey(mute),
|
muteToKey(mute),
|
||||||
setTimeout(() => broadcastExpiredMute(mute), remaining),
|
setTimeout(() => broadcastExpiredMute(mute.guild_id, mute.user_id), remaining),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm";
|
||||||
|
|
||||||
|
export class AddTimeoutColumnsToMutes1680354053183 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.addColumns("mutes", [
|
||||||
|
new TableColumn({
|
||||||
|
name: "type",
|
||||||
|
type: "tinyint",
|
||||||
|
unsigned: true,
|
||||||
|
default: 1, // The value for "Role" mute at the time of this migration
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: "mute_role",
|
||||||
|
type: "bigint",
|
||||||
|
unsigned: true,
|
||||||
|
isNullable: true,
|
||||||
|
default: null,
|
||||||
|
}),
|
||||||
|
new TableColumn({
|
||||||
|
name: "timeout_expires_at",
|
||||||
|
type: "datetime",
|
||||||
|
isNullable: true,
|
||||||
|
default: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
"mutes",
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["type"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
"mutes",
|
||||||
|
new TableIndex({
|
||||||
|
columnNames: ["timeout_expires_at"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropColumn("mutes", "type");
|
||||||
|
await queryRunner.dropColumn("mutes", "mute_role");
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,14 @@
|
||||||
* @file Utility functions that are plugin-instance-specific (i.e. use PluginData)
|
* @file Utility functions that are plugin-instance-specific (i.e. use PluginData)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { GuildMember, Message, MessageCreateOptions, MessageMentionOptions, TextBasedChannel } from "discord.js";
|
import {
|
||||||
|
GuildMember,
|
||||||
|
Message,
|
||||||
|
MessageCreateOptions,
|
||||||
|
MessageMentionOptions,
|
||||||
|
PermissionsBitField,
|
||||||
|
TextBasedChannel,
|
||||||
|
} from "discord.js";
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import {
|
import {
|
||||||
AnyPluginData,
|
AnyPluginData,
|
||||||
|
@ -27,10 +34,16 @@ export function canActOn(
|
||||||
member1: GuildMember,
|
member1: GuildMember,
|
||||||
member2: GuildMember,
|
member2: GuildMember,
|
||||||
allowSameLevel = false,
|
allowSameLevel = false,
|
||||||
|
allowAdmins = false,
|
||||||
) {
|
) {
|
||||||
if (member2.id === pluginData.client.user!.id) {
|
if (member2.id === pluginData.client.user!.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
member2.id === member2.guild.ownerId || member2.permissions.has(PermissionsBitField.Flags.Administrator);
|
||||||
|
if (isOwnerOrAdmin && !allowAdmins) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const ourLevel = getMemberLevel(pluginData, member1);
|
const ourLevel = getMemberLevel(pluginData, member1);
|
||||||
const memberLevel = getMemberLevel(pluginData, member2);
|
const memberLevel = getMemberLevel(pluginData, member2);
|
||||||
|
|
|
@ -35,6 +35,7 @@ import { UnhideCaseCmd } from "./commands/UnhideCaseCmd";
|
||||||
import { UnmuteCmd } from "./commands/UnmuteCmd";
|
import { UnmuteCmd } from "./commands/UnmuteCmd";
|
||||||
import { UpdateCmd } from "./commands/UpdateCmd";
|
import { UpdateCmd } from "./commands/UpdateCmd";
|
||||||
import { WarnCmd } from "./commands/WarnCmd";
|
import { WarnCmd } from "./commands/WarnCmd";
|
||||||
|
import { AuditLogEvents } from "./events/AuditLogEvents";
|
||||||
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
|
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
|
||||||
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
|
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
|
||||||
import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt";
|
import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt";
|
||||||
|
@ -127,7 +128,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
|
||||||
configParser: makeIoTsConfigParser(ConfigSchema),
|
configParser: makeIoTsConfigParser(ConfigSchema),
|
||||||
defaultOptions,
|
defaultOptions,
|
||||||
|
|
||||||
events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt],
|
events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents],
|
||||||
|
|
||||||
messageCommands: [
|
messageCommands: [
|
||||||
UpdateCmd,
|
UpdateCmd,
|
||||||
|
|
|
@ -44,7 +44,11 @@ export const UnmuteCmd = modActionsCmd({
|
||||||
const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute);
|
const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute);
|
||||||
|
|
||||||
// Check if they're muted in the first place
|
// Check if they're muted in the first place
|
||||||
if (!(await pluginData.state.mutes.isMuted(args.user)) && !hasMuteRole) {
|
if (
|
||||||
|
!(await pluginData.state.mutes.isMuted(user.id)) &&
|
||||||
|
!hasMuteRole &&
|
||||||
|
!memberToUnmute?.communicationDisabledUntilTimestamp
|
||||||
|
) {
|
||||||
sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted");
|
sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
71
backend/src/plugins/ModActions/events/AuditLogEvents.ts
Normal file
71
backend/src/plugins/ModActions/events/AuditLogEvents.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { AuditLogChange, AuditLogEvent } from "discord.js";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { CaseTypes } from "../../../data/CaseTypes";
|
||||||
|
import { resolveUser } from "../../../utils";
|
||||||
|
import { CasesPlugin } from "../../Cases/CasesPlugin";
|
||||||
|
import { modActionsEvt } from "../types";
|
||||||
|
|
||||||
|
export const AuditLogEvents = modActionsEvt({
|
||||||
|
event: "guildAuditLogEntryCreate",
|
||||||
|
async listener({ pluginData, args: { auditLogEntry } }) {
|
||||||
|
// Ignore the bot's own audit log events
|
||||||
|
if (auditLogEntry.executorId === pluginData.client.user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = pluginData.config.get();
|
||||||
|
const casesPlugin = pluginData.getPlugin(CasesPlugin);
|
||||||
|
|
||||||
|
// Create mute/unmute cases for manual timeouts
|
||||||
|
if (auditLogEntry.action === AuditLogEvent.MemberUpdate && config.create_cases_for_manual_actions) {
|
||||||
|
const target = await resolveUser(pluginData.client, auditLogEntry.targetId!);
|
||||||
|
|
||||||
|
// Only act based on the last changes in this log
|
||||||
|
let muteChange: AuditLogChange | null = null;
|
||||||
|
let unmuteChange: AuditLogChange | null = null;
|
||||||
|
for (const change of auditLogEntry.changes) {
|
||||||
|
if (change.key === "communication_disabled_until") {
|
||||||
|
if (change.new == null) {
|
||||||
|
unmuteChange = change;
|
||||||
|
} else {
|
||||||
|
muteChange = change;
|
||||||
|
unmuteChange = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muteChange) {
|
||||||
|
const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(target.id);
|
||||||
|
const existingCaseId = existingMute?.case_id;
|
||||||
|
if (existingCaseId) {
|
||||||
|
await casesPlugin.createCaseNote({
|
||||||
|
caseId: existingCaseId,
|
||||||
|
modId: auditLogEntry.executor?.id || "0",
|
||||||
|
body: auditLogEntry.reason || "",
|
||||||
|
noteDetails: [`Timeout set to expire on <t:${moment.utc(muteChange.new as string).valueOf()}>`],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await casesPlugin.createCase({
|
||||||
|
userId: target.id,
|
||||||
|
modId: auditLogEntry.executor?.id || "0",
|
||||||
|
type: CaseTypes.Mute,
|
||||||
|
auditLogId: auditLogEntry.id,
|
||||||
|
reason: auditLogEntry.reason || "",
|
||||||
|
automatic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmuteChange) {
|
||||||
|
await casesPlugin.createCase({
|
||||||
|
userId: target.id,
|
||||||
|
modId: auditLogEntry.executor?.id || "0",
|
||||||
|
type: CaseTypes.Unmute,
|
||||||
|
auditLogId: auditLogEntry.id,
|
||||||
|
reason: auditLogEntry.reason || "",
|
||||||
|
automatic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -15,10 +15,12 @@ import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd";
|
||||||
import { MutesCmd } from "./commands/MutesCmd";
|
import { MutesCmd } from "./commands/MutesCmd";
|
||||||
import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt";
|
import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt";
|
||||||
import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt";
|
import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt";
|
||||||
|
import { RegisterManualTimeoutsEvt } from "./events/RegisterManualTimeoutsEvt";
|
||||||
import { clearMute } from "./functions/clearMute";
|
import { clearMute } from "./functions/clearMute";
|
||||||
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";
|
||||||
|
import { renewTimeoutMute } from "./functions/renewTimeoutMute";
|
||||||
import { unmuteUser } from "./functions/unmuteUser";
|
import { unmuteUser } from "./functions/unmuteUser";
|
||||||
import { ConfigSchema, MutesPluginType } from "./types";
|
import { ConfigSchema, MutesPluginType } from "./types";
|
||||||
|
|
||||||
|
@ -85,6 +87,7 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
|
||||||
// ClearActiveMuteOnRoleRemovalEvt, // FIXME: Temporarily disabled for performance
|
// ClearActiveMuteOnRoleRemovalEvt, // FIXME: Temporarily disabled for performance
|
||||||
ClearActiveMuteOnMemberBanEvt,
|
ClearActiveMuteOnMemberBanEvt,
|
||||||
ReapplyActiveMuteOnJoinEvt,
|
ReapplyActiveMuteOnJoinEvt,
|
||||||
|
RegisterManualTimeoutsEvt,
|
||||||
],
|
],
|
||||||
|
|
||||||
public: {
|
public: {
|
||||||
|
@ -118,13 +121,24 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
|
||||||
afterLoad(pluginData) {
|
afterLoad(pluginData) {
|
||||||
const { state, guild } = pluginData;
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
state.unregisterGuildEventListener = onGuildEvent(guild.id, "expiredMute", (mute) => clearMute(pluginData, mute));
|
state.unregisterExpiredRoleMuteListener = onGuildEvent(guild.id, "expiredMute", (mute) =>
|
||||||
|
clearMute(pluginData, mute),
|
||||||
|
);
|
||||||
|
state.unregisterTimeoutMuteToRenewListener = onGuildEvent(guild.id, "timeoutMuteToRenew", (mute) =>
|
||||||
|
renewTimeoutMute(pluginData, mute),
|
||||||
|
);
|
||||||
|
|
||||||
|
const muteRole = pluginData.config.get().mute_role;
|
||||||
|
if (muteRole) {
|
||||||
|
state.mutes.fillMissingMuteRole(muteRole);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeUnload(pluginData) {
|
beforeUnload(pluginData) {
|
||||||
const { state, guild } = pluginData;
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
state.unregisterGuildEventListener?.();
|
state.unregisterExpiredRoleMuteListener?.();
|
||||||
|
state.unregisterTimeoutMuteToRenewListener?.();
|
||||||
state.events.removeAllListeners();
|
state.events.removeAllListeners();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Snowflake } from "discord.js";
|
import { Snowflake } from "discord.js";
|
||||||
|
import { MuteTypes } from "../../../data/MuteTypes";
|
||||||
import { memberRolesLock } from "../../../utils/lockNameHelpers";
|
import { memberRolesLock } from "../../../utils/lockNameHelpers";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
import { mutesEvt } from "../types";
|
import { mutesEvt } from "../types";
|
||||||
|
@ -10,18 +11,25 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt({
|
||||||
event: "guildMemberAdd",
|
event: "guildMemberAdd",
|
||||||
async listener({ pluginData, args: { member } }) {
|
async listener({ pluginData, args: { member } }) {
|
||||||
const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id);
|
const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id);
|
||||||
if (mute) {
|
if (!mute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mute.type === MuteTypes.Role) {
|
||||||
const muteRole = pluginData.config.get().mute_role;
|
const muteRole = pluginData.config.get().mute_role;
|
||||||
|
|
||||||
if (muteRole) {
|
if (muteRole) {
|
||||||
const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
|
const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
|
||||||
await member.roles.add(muteRole as Snowflake);
|
try {
|
||||||
memberRoleLock.unlock();
|
await member.roles.add(muteRole as Snowflake);
|
||||||
|
} finally {
|
||||||
|
memberRoleLock.unlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginData.getPlugin(LogsPlugin).logMemberMuteRejoin({
|
|
||||||
member,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pluginData.getPlugin(LogsPlugin).logMemberMuteRejoin({
|
||||||
|
member,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { AuditLogChange, AuditLogEvent } from "discord.js";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { MuteTypes } from "../../../data/MuteTypes";
|
||||||
|
import { resolveUser } from "../../../utils";
|
||||||
|
import { mutesEvt } from "../types";
|
||||||
|
|
||||||
|
export const RegisterManualTimeoutsEvt = mutesEvt({
|
||||||
|
event: "guildAuditLogEntryCreate",
|
||||||
|
async listener({ pluginData, args: { auditLogEntry } }) {
|
||||||
|
// Ignore the bot's own audit log events
|
||||||
|
if (auditLogEntry.executorId === pluginData.client.user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auditLogEntry.action !== AuditLogEvent.MemberUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await resolveUser(pluginData.client, auditLogEntry.targetId!);
|
||||||
|
|
||||||
|
// Only act based on the last changes in this log
|
||||||
|
let lastTimeoutChange: AuditLogChange | null = null;
|
||||||
|
for (const change of auditLogEntry.changes) {
|
||||||
|
if (change.key === "communication_disabled_until") {
|
||||||
|
lastTimeoutChange = change;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lastTimeoutChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(target.id);
|
||||||
|
|
||||||
|
if (lastTimeoutChange.new == null && existingMute) {
|
||||||
|
await pluginData.state.mutes.clear(target.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTimeoutChange.new != null) {
|
||||||
|
const expiresAtTimestamp = moment.utc(lastTimeoutChange.new as string).valueOf();
|
||||||
|
if (existingMute) {
|
||||||
|
await pluginData.state.mutes.updateExpiresAt(target.id, expiresAtTimestamp);
|
||||||
|
} else {
|
||||||
|
await pluginData.state.mutes.addMute({
|
||||||
|
userId: target.id,
|
||||||
|
type: MuteTypes.Timeout,
|
||||||
|
expiresAt: expiresAtTimestamp,
|
||||||
|
timeoutExpiresAt: expiresAtTimestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -2,6 +2,7 @@ import { GuildMember } from "discord.js";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { Mute } from "../../../data/entities/Mute";
|
import { Mute } from "../../../data/entities/Mute";
|
||||||
import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop";
|
import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop";
|
||||||
|
import { MuteTypes } from "../../../data/MuteTypes";
|
||||||
import { resolveMember, verboseUserMention } from "../../../utils";
|
import { resolveMember, verboseUserMention } from "../../../utils";
|
||||||
import { memberRolesLock } from "../../../utils/lockNameHelpers";
|
import { memberRolesLock } from "../../../utils/lockNameHelpers";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
|
@ -24,22 +25,36 @@ export async function clearMute(
|
||||||
const lock = await pluginData.locks.acquire(memberRolesLock(member));
|
const lock = await pluginData.locks.acquire(memberRolesLock(member));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const muteRole = pluginData.config.get().mute_role;
|
const defaultMuteRole = pluginData.config.get().mute_role;
|
||||||
if (muteRole) {
|
if (mute) {
|
||||||
await member.roles.remove(muteRole);
|
const muteRole = mute.mute_role || pluginData.config.get().mute_role;
|
||||||
}
|
|
||||||
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();
|
if (mute.type === MuteTypes.Role) {
|
||||||
|
if (muteRole) {
|
||||||
|
await member.roles.remove(muteRole);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await member.timeout(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unmuting someone without an active mute -> remove timeouts and/or mute role
|
||||||
|
const muteRole = pluginData.config.get().mute_role;
|
||||||
|
if (muteRole) {
|
||||||
|
await member.roles.remove(muteRole);
|
||||||
|
}
|
||||||
|
await member.timeout(null);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||||
body: `Failed to remove mute role from ${verboseUserMention(member.user)}`,
|
body: `Failed to remove mute role from ${verboseUserMention(member.user)}`,
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { MuteTypes } from "../../../data/MuteTypes";
|
||||||
|
import { MutesPluginType } from "../types";
|
||||||
|
|
||||||
|
export function getDefaultMuteType(pluginData: GuildPluginData<MutesPluginType>): MuteTypes {
|
||||||
|
const muteRole = pluginData.config.get().mute_role;
|
||||||
|
return muteRole ? MuteTypes.Role : MuteTypes.Timeout;
|
||||||
|
}
|
15
backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts
Normal file
15
backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { MAX_TIMEOUT_DURATION } from "../../../data/Mutes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since timeouts have a limited duration (max 28d) but we support mutes longer than that,
|
||||||
|
* the timeouts are applied for a certain duration at first and then renewed as necessary.
|
||||||
|
* This function returns the initial end time for a timeout.
|
||||||
|
* @param muteTime Time to mute for in ms
|
||||||
|
* @return - Timestamp of the
|
||||||
|
*/
|
||||||
|
export function getTimeoutExpiryTime(muteExpiresAt: number | null | undefined): number {
|
||||||
|
if (muteExpiresAt && muteExpiresAt <= MAX_TIMEOUT_DURATION) {
|
||||||
|
return muteExpiresAt;
|
||||||
|
}
|
||||||
|
return Date.now() + MAX_TIMEOUT_DURATION;
|
||||||
|
}
|
|
@ -4,7 +4,9 @@ import { GuildPluginData } from "knub";
|
||||||
import { CaseTypes } from "../../../data/CaseTypes";
|
import { CaseTypes } from "../../../data/CaseTypes";
|
||||||
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 { AddMuteParams } from "../../../data/GuildMutes";
|
||||||
import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop";
|
import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop";
|
||||||
|
import { MuteTypes } from "../../../data/MuteTypes";
|
||||||
import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
|
import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
|
||||||
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
|
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
|
||||||
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
|
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
|
||||||
|
@ -20,6 +22,8 @@ import { muteLock } from "../../../utils/lockNameHelpers";
|
||||||
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||||
import { CasesPlugin } from "../../Cases/CasesPlugin";
|
import { CasesPlugin } from "../../Cases/CasesPlugin";
|
||||||
import { MuteOptions, MutesPluginType } from "../types";
|
import { MuteOptions, MutesPluginType } from "../types";
|
||||||
|
import { getDefaultMuteType } from "./getDefaultMuteType";
|
||||||
|
import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Clean up this function
|
* TODO: Clean up this function
|
||||||
|
@ -36,12 +40,9 @@ export async function muteUser(
|
||||||
const lock = await pluginData.locks.acquire(muteLock({ id: userId }));
|
const lock = await pluginData.locks.acquire(muteLock({ id: userId }));
|
||||||
|
|
||||||
const muteRole = pluginData.config.get().mute_role;
|
const muteRole = pluginData.config.get().mute_role;
|
||||||
if (!muteRole) {
|
const muteType = getDefaultMuteType(pluginData);
|
||||||
lock.unlock();
|
const muteExpiresAt = muteTime ? Date.now() + muteTime : null;
|
||||||
throw new RecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG);
|
const timeoutUntil = getTimeoutExpiryTime(muteExpiresAt);
|
||||||
}
|
|
||||||
|
|
||||||
const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite";
|
|
||||||
|
|
||||||
// No mod specified -> mark Zeppelin as the mod
|
// No mod specified -> mark Zeppelin as the mod
|
||||||
if (!muteOptions.caseArgs?.modId) {
|
if (!muteOptions.caseArgs?.modId) {
|
||||||
|
@ -67,7 +68,7 @@ export async function muteUser(
|
||||||
const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute;
|
const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute;
|
||||||
const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute;
|
const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute;
|
||||||
|
|
||||||
// remove roles
|
// Remove roles
|
||||||
if (!Array.isArray(removeRoles)) {
|
if (!Array.isArray(removeRoles)) {
|
||||||
if (removeRoles) {
|
if (removeRoles) {
|
||||||
// exclude managed roles from being removed
|
// exclude managed roles from being removed
|
||||||
|
@ -80,7 +81,7 @@ export async function muteUser(
|
||||||
await member.roles.set(newRoles as Snowflake[]);
|
await member.roles.set(newRoles as Snowflake[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// set roles to be restored
|
// Set roles to be restored
|
||||||
if (!Array.isArray(restoreRoles)) {
|
if (!Array.isArray(restoreRoles)) {
|
||||||
if (restoreRoles) {
|
if (restoreRoles) {
|
||||||
rolesToRestore = currentUserRoles;
|
rolesToRestore = currentUserRoles;
|
||||||
|
@ -89,38 +90,42 @@ export async function muteUser(
|
||||||
rolesToRestore = currentUserRoles.filter((x) => (<string[]>restoreRoles).includes(x));
|
rolesToRestore = currentUserRoles.filter((x) => (<string[]>restoreRoles).includes(x));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply mute role if it's missing
|
if (muteType === MuteTypes.Role) {
|
||||||
if (!currentUserRoles.includes(muteRole as Snowflake)) {
|
// Apply mute role if it's missing
|
||||||
try {
|
if (!currentUserRoles.includes(muteRole!)) {
|
||||||
await member.roles.add(muteRole as Snowflake);
|
try {
|
||||||
} catch (e) {
|
await member.roles.add(muteRole!);
|
||||||
const actualMuteRole = pluginData.guild.roles.cache.get(muteRole as Snowflake);
|
} catch (e) {
|
||||||
if (!actualMuteRole) {
|
const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!);
|
||||||
lock.unlock();
|
if (!actualMuteRole) {
|
||||||
logs.logBotAlert({
|
lock.unlock();
|
||||||
body: `Cannot mute users, specified mute role Id is invalid`,
|
logs.logBotAlert({
|
||||||
});
|
body: `Cannot mute users, specified mute role Id is invalid`,
|
||||||
throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID);
|
});
|
||||||
}
|
throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id);
|
const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id);
|
||||||
const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id));
|
const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id));
|
||||||
// If we have roles and one of them is above the muted role, throw generic error
|
// If we have roles and one of them is above the muted role, throw generic error
|
||||||
if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) {
|
if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) {
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
logs.logBotAlert({
|
logs.logBotAlert({
|
||||||
body: `Cannot mute user ${member.id}: ${e}`,
|
body: `Cannot mute user ${member.id}: ${e}`,
|
||||||
});
|
});
|
||||||
throw e;
|
throw e;
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, throw error that mute role is above zeps roles
|
// Otherwise, throw error that mute role is above zeps roles
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
logs.logBotAlert({
|
logs.logBotAlert({
|
||||||
body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`,
|
body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`,
|
||||||
});
|
});
|
||||||
throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild);
|
throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await member.disableCommunicationUntil(timeoutUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role)
|
// If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role)
|
||||||
|
@ -144,13 +149,28 @@ 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);
|
||||||
|
if (muteType === MuteTypes.Timeout) {
|
||||||
|
await pluginData.state.mutes.updateTimeoutExpiresAt(user.id, timeoutUntil);
|
||||||
|
}
|
||||||
finalMute = (await pluginData.state.mutes.findExistingMuteForUserId(user.id))!;
|
finalMute = (await pluginData.state.mutes.findExistingMuteForUserId(user.id))!;
|
||||||
} else {
|
} else {
|
||||||
finalMute = await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore);
|
const muteParams: AddMuteParams = {
|
||||||
|
userId: user.id,
|
||||||
|
type: muteType,
|
||||||
|
expiresAt: muteExpiresAt,
|
||||||
|
rolesToRestore,
|
||||||
|
};
|
||||||
|
if (muteType === MuteTypes.Role) {
|
||||||
|
muteParams.muteRole = muteRole;
|
||||||
|
} else {
|
||||||
|
muteParams.timeoutExpiresAt = timeoutUntil;
|
||||||
|
}
|
||||||
|
finalMute = await pluginData.state.mutes.addMute(muteParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerExpiringMute(finalMute);
|
registerExpiringMute(finalMute);
|
||||||
|
|
||||||
|
const timeUntilUnmuteStr = muteTime ? humanizeDuration(muteTime) : "indefinite";
|
||||||
const template = existingMute
|
const template = existingMute
|
||||||
? config.update_mute_message
|
? config.update_mute_message
|
||||||
: muteTime
|
: muteTime
|
||||||
|
@ -164,7 +184,7 @@ export async function muteUser(
|
||||||
new TemplateSafeValueContainer({
|
new TemplateSafeValueContainer({
|
||||||
guildName: pluginData.guild.name,
|
guildName: pluginData.guild.name,
|
||||||
reason: reason || "None",
|
reason: reason || "None",
|
||||||
time: timeUntilUnmute,
|
time: timeUntilUnmuteStr,
|
||||||
moderator: muteOptions.caseArgs?.modId
|
moderator: muteOptions.caseArgs?.modId
|
||||||
? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId))
|
? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId))
|
||||||
: null,
|
: null,
|
||||||
|
@ -201,7 +221,7 @@ export async function muteUser(
|
||||||
|
|
||||||
if (theCase) {
|
if (theCase) {
|
||||||
// Update old case
|
// Update old case
|
||||||
const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`];
|
const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmuteStr : "indefinite"}`];
|
||||||
const reasons = reason ? [reason] : [];
|
const reasons = reason ? [reason] : [];
|
||||||
if (muteOptions.caseArgs?.extraNotes) {
|
if (muteOptions.caseArgs?.extraNotes) {
|
||||||
reasons.push(...muteOptions.caseArgs.extraNotes);
|
reasons.push(...muteOptions.caseArgs.extraNotes);
|
||||||
|
@ -217,7 +237,7 @@ export async function muteUser(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new case
|
// Create new case
|
||||||
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
|
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmuteStr}` : "indefinitely"}`];
|
||||||
if (notifyResult.text) {
|
if (notifyResult.text) {
|
||||||
noteDetails.push(ucfirst(notifyResult.text));
|
noteDetails.push(ucfirst(notifyResult.text));
|
||||||
}
|
}
|
||||||
|
@ -239,7 +259,7 @@ export async function muteUser(
|
||||||
pluginData.getPlugin(LogsPlugin).logMemberTimedMute({
|
pluginData.getPlugin(LogsPlugin).logMemberTimedMute({
|
||||||
mod,
|
mod,
|
||||||
user,
|
user,
|
||||||
time: timeUntilUnmute,
|
time: timeUntilUnmuteStr,
|
||||||
caseNumber: theCase.case_number,
|
caseNumber: theCase.case_number,
|
||||||
reason: reason ?? "",
|
reason: reason ?? "",
|
||||||
});
|
});
|
||||||
|
|
22
backend/src/plugins/Mutes/functions/renewTimeoutMute.ts
Normal file
22
backend/src/plugins/Mutes/functions/renewTimeoutMute.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { Mute } from "../../../data/entities/Mute";
|
||||||
|
import { MAX_TIMEOUT_DURATION } from "../../../data/Mutes";
|
||||||
|
import { DBDateFormat, resolveMember } from "../../../utils";
|
||||||
|
import { MutesPluginType } from "../types";
|
||||||
|
|
||||||
|
export async function renewTimeoutMute(pluginData: GuildPluginData<MutesPluginType>, mute: Mute) {
|
||||||
|
const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true);
|
||||||
|
if (!member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newExpiryTime = moment.utc().add(MAX_TIMEOUT_DURATION).format(DBDateFormat);
|
||||||
|
if (mute.expires_at && newExpiryTime > mute.expires_at) {
|
||||||
|
newExpiryTime = mute.expires_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryTimestamp = moment.utc(newExpiryTime).valueOf();
|
||||||
|
await member.disableCommunicationUntil(expiryTimestamp);
|
||||||
|
await pluginData.state.mutes.updateTimeoutExpiresAt(mute.user_id, expiryTimestamp);
|
||||||
|
}
|
|
@ -2,12 +2,17 @@ import { Snowflake } from "discord.js";
|
||||||
import humanizeDuration from "humanize-duration";
|
import humanizeDuration from "humanize-duration";
|
||||||
import { GuildPluginData } from "knub";
|
import { GuildPluginData } from "knub";
|
||||||
import { CaseTypes } from "../../../data/CaseTypes";
|
import { CaseTypes } from "../../../data/CaseTypes";
|
||||||
|
import { Mute } from "../../../data/entities/Mute";
|
||||||
|
import { AddMuteParams } from "../../../data/GuildMutes";
|
||||||
|
import { MuteTypes } from "../../../data/MuteTypes";
|
||||||
import { resolveMember, resolveUser } from "../../../utils";
|
import { resolveMember, resolveUser } from "../../../utils";
|
||||||
import { CasesPlugin } from "../../Cases/CasesPlugin";
|
import { CasesPlugin } from "../../Cases/CasesPlugin";
|
||||||
import { CaseArgs } from "../../Cases/types";
|
import { CaseArgs } from "../../Cases/types";
|
||||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||||
import { MutesPluginType, UnmuteResult } from "../types";
|
import { MutesPluginType, UnmuteResult } from "../types";
|
||||||
import { clearMute } from "./clearMute";
|
import { clearMute } from "./clearMute";
|
||||||
|
import { getDefaultMuteType } from "./getDefaultMuteType";
|
||||||
|
import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime";
|
||||||
import { memberHasMutedRole } from "./memberHasMutedRole";
|
import { memberHasMutedRole } from "./memberHasMutedRole";
|
||||||
|
|
||||||
export async function unmuteUser(
|
export async function unmuteUser(
|
||||||
|
@ -21,15 +26,43 @@ export async function unmuteUser(
|
||||||
const member = await resolveMember(pluginData.client, pluginData.guild, userId, true); // Grab the fresh member so we don't have stale role info
|
const member = await resolveMember(pluginData.client, pluginData.guild, userId, true); // Grab the fresh member so we don't have stale role info
|
||||||
const modId = caseArgs.modId || pluginData.client.user!.id;
|
const modId = caseArgs.modId || pluginData.client.user!.id;
|
||||||
|
|
||||||
if (!existingMute && member && !memberHasMutedRole(pluginData, member)) return null;
|
if (
|
||||||
|
!existingMute &&
|
||||||
|
member &&
|
||||||
|
!memberHasMutedRole(pluginData, member) &&
|
||||||
|
!member?.communicationDisabledUntilTimestamp
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (unmuteTime) {
|
if (unmuteTime) {
|
||||||
// Schedule timed unmute (= just set the mute's duration)
|
// Schedule timed unmute (= just update the mute's duration)
|
||||||
|
const muteExpiresAt = Date.now() + unmuteTime;
|
||||||
|
const timeoutExpiresAt = getTimeoutExpiryTime(muteExpiresAt);
|
||||||
|
let createdMute: Mute | null = null;
|
||||||
|
|
||||||
if (!existingMute) {
|
if (!existingMute) {
|
||||||
await pluginData.state.mutes.addMute(userId, unmuteTime);
|
const defaultMuteType = getDefaultMuteType(pluginData);
|
||||||
|
const muteParams: AddMuteParams = {
|
||||||
|
userId,
|
||||||
|
type: defaultMuteType,
|
||||||
|
expiresAt: muteExpiresAt,
|
||||||
|
};
|
||||||
|
if (defaultMuteType === MuteTypes.Role) {
|
||||||
|
muteParams.muteRole = pluginData.config.get().mute_role;
|
||||||
|
} else {
|
||||||
|
muteParams.timeoutExpiresAt = timeoutExpiresAt;
|
||||||
|
}
|
||||||
|
createdMute = await pluginData.state.mutes.addMute(muteParams);
|
||||||
} else {
|
} else {
|
||||||
await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime);
|
await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update timeout
|
||||||
|
if (existingMute?.type === MuteTypes.Timeout || createdMute?.type === MuteTypes.Timeout) {
|
||||||
|
await member?.disableCommunicationUntil(timeoutExpiresAt);
|
||||||
|
await pluginData.state.mutes.updateTimeoutExpiresAt(userId, timeoutExpiresAt);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unmute immediately
|
// Unmute immediately
|
||||||
clearMute(pluginData, existingMute);
|
clearMute(pluginData, existingMute);
|
||||||
|
|
|
@ -50,7 +50,8 @@ export interface MutesPluginType extends BasePluginType {
|
||||||
serverLogs: GuildLogs;
|
serverLogs: GuildLogs;
|
||||||
archives: GuildArchives;
|
archives: GuildArchives;
|
||||||
|
|
||||||
unregisterGuildEventListener: () => void;
|
unregisterExpiredRoleMuteListener: () => void;
|
||||||
|
unregisterTimeoutMuteToRenewListener: () => void;
|
||||||
|
|
||||||
events: MutesEventEmitter;
|
events: MutesEventEmitter;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue