mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-03-15 05:41:51 +00:00
feat: timeout support
This commit is contained in:
parent
06877e90cc
commit
39e0dfa27f
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",
|
||||
"io-ts": "^2.0.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"knub": "^32.0.0-next.4",
|
||||
"knub": "^32.0.0-next.5",
|
||||
"knub-command-manager": "^9.1.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
|
@ -2704,9 +2704,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/knub": {
|
||||
"version": "32.0.0-next.4",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.4.tgz",
|
||||
"integrity": "sha512-ywZbwcGFSr4Erl/nEUDVmziQHXKVIykWtI2Z05DLt01YmxDS+rTO8l/E6LYx7ZL3m+f2DbtLH0HB8zaZb0pUag==",
|
||||
"version": "32.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.5.tgz",
|
||||
"integrity": "sha512-emWfjgdYSabbPlngJkh/V8/93iCuhvR7Rp1tnLu/lUNUpq+IO66PSefxuzRfYZ4XrOZBTEbeKZ/2RakAwDU3MA==",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.8.0",
|
||||
"knub-command-manager": "^9.1.0",
|
||||
|
@ -7201,9 +7201,9 @@
|
|||
}
|
||||
},
|
||||
"knub": {
|
||||
"version": "32.0.0-next.4",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.4.tgz",
|
||||
"integrity": "sha512-ywZbwcGFSr4Erl/nEUDVmziQHXKVIykWtI2Z05DLt01YmxDS+rTO8l/E6LYx7ZL3m+f2DbtLH0HB8zaZb0pUag==",
|
||||
"version": "32.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/knub/-/knub-32.0.0-next.5.tgz",
|
||||
"integrity": "sha512-emWfjgdYSabbPlngJkh/V8/93iCuhvR7Rp1tnLu/lUNUpq+IO66PSefxuzRfYZ4XrOZBTEbeKZ/2RakAwDU3MA==",
|
||||
"requires": {
|
||||
"discord.js": "^14.8.0",
|
||||
"knub-command-manager": "^9.1.0",
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
"humanize-duration": "^3.15.0",
|
||||
"io-ts": "^2.0.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"knub": "^32.0.0-next.4",
|
||||
"knub": "^32.0.0-next.5",
|
||||
"knub-command-manager": "^9.1.0",
|
||||
"last-commit-log": "^2.1.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
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 { 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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
*/
|
||||
|
||||
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 {
|
||||
AnyPluginData,
|
||||
|
@ -27,10 +34,16 @@ export function canActOn(
|
|||
member1: GuildMember,
|
||||
member2: GuildMember,
|
||||
allowSameLevel = false,
|
||||
allowAdmins = false,
|
||||
) {
|
||||
if (member2.id === pluginData.client.user!.id) {
|
||||
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 memberLevel = getMemberLevel(pluginData, member2);
|
||||
|
|
|
@ -35,6 +35,7 @@ import { UnhideCaseCmd } from "./commands/UnhideCaseCmd";
|
|||
import { UnmuteCmd } from "./commands/UnmuteCmd";
|
||||
import { UpdateCmd } from "./commands/UpdateCmd";
|
||||
import { WarnCmd } from "./commands/WarnCmd";
|
||||
import { AuditLogEvents } from "./events/AuditLogEvents";
|
||||
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
|
||||
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
|
||||
import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt";
|
||||
|
@ -127,7 +128,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
|
|||
configParser: makeIoTsConfigParser(ConfigSchema),
|
||||
defaultOptions,
|
||||
|
||||
events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt],
|
||||
events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents],
|
||||
|
||||
messageCommands: [
|
||||
UpdateCmd,
|
||||
|
|
|
@ -44,7 +44,11 @@ export const UnmuteCmd = modActionsCmd({
|
|||
const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute);
|
||||
|
||||
// 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");
|
||||
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 { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt";
|
||||
import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt";
|
||||
import { RegisterManualTimeoutsEvt } from "./events/RegisterManualTimeoutsEvt";
|
||||
import { clearMute } from "./functions/clearMute";
|
||||
import { muteUser } from "./functions/muteUser";
|
||||
import { offMutesEvent } from "./functions/offMutesEvent";
|
||||
import { onMutesEvent } from "./functions/onMutesEvent";
|
||||
import { renewTimeoutMute } from "./functions/renewTimeoutMute";
|
||||
import { unmuteUser } from "./functions/unmuteUser";
|
||||
import { ConfigSchema, MutesPluginType } from "./types";
|
||||
|
||||
|
@ -85,6 +87,7 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
|
|||
// ClearActiveMuteOnRoleRemovalEvt, // FIXME: Temporarily disabled for performance
|
||||
ClearActiveMuteOnMemberBanEvt,
|
||||
ReapplyActiveMuteOnJoinEvt,
|
||||
RegisterManualTimeoutsEvt,
|
||||
],
|
||||
|
||||
public: {
|
||||
|
@ -118,13 +121,24 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
|
|||
afterLoad(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) {
|
||||
const { state, guild } = pluginData;
|
||||
|
||||
state.unregisterGuildEventListener?.();
|
||||
state.unregisterExpiredRoleMuteListener?.();
|
||||
state.unregisterTimeoutMuteToRenewListener?.();
|
||||
state.events.removeAllListeners();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Snowflake } from "discord.js";
|
||||
import { MuteTypes } from "../../../data/MuteTypes";
|
||||
import { memberRolesLock } from "../../../utils/lockNameHelpers";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
import { mutesEvt } from "../types";
|
||||
|
@ -10,18 +11,25 @@ export const ReapplyActiveMuteOnJoinEvt = mutesEvt({
|
|||
event: "guildMemberAdd",
|
||||
async listener({ pluginData, args: { member } }) {
|
||||
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;
|
||||
|
||||
if (muteRole) {
|
||||
const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member));
|
||||
try {
|
||||
await member.roles.add(muteRole as Snowflake);
|
||||
} finally {
|
||||
memberRoleLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { Mute } from "../../../data/entities/Mute";
|
||||
import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop";
|
||||
import { MuteTypes } from "../../../data/MuteTypes";
|
||||
import { resolveMember, verboseUserMention } from "../../../utils";
|
||||
import { memberRolesLock } from "../../../utils/lockNameHelpers";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
|
@ -24,11 +25,19 @@ export async function clearMute(
|
|||
const lock = await pluginData.locks.acquire(memberRolesLock(member));
|
||||
|
||||
try {
|
||||
const muteRole = pluginData.config.get().mute_role;
|
||||
const defaultMuteRole = pluginData.config.get().mute_role;
|
||||
if (mute) {
|
||||
const muteRole = mute.mute_role || pluginData.config.get().mute_role;
|
||||
|
||||
if (mute.type === MuteTypes.Role) {
|
||||
if (muteRole) {
|
||||
await member.roles.remove(muteRole);
|
||||
}
|
||||
if (mute?.roles_to_restore) {
|
||||
} 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) {
|
||||
|
@ -38,8 +47,14 @@ export async function clearMute(
|
|||
}
|
||||
await member.roles.set(newRoles);
|
||||
}
|
||||
|
||||
lock.unlock();
|
||||
} 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 {
|
||||
pluginData.getPlugin(LogsPlugin).logBotAlert({
|
||||
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 { Case } from "../../../data/entities/Case";
|
||||
import { Mute } from "../../../data/entities/Mute";
|
||||
import { AddMuteParams } from "../../../data/GuildMutes";
|
||||
import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop";
|
||||
import { MuteTypes } from "../../../data/MuteTypes";
|
||||
import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
|
||||
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
|
||||
import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
|
||||
|
@ -20,6 +22,8 @@ import { muteLock } from "../../../utils/lockNameHelpers";
|
|||
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
|
||||
import { CasesPlugin } from "../../Cases/CasesPlugin";
|
||||
import { MuteOptions, MutesPluginType } from "../types";
|
||||
import { getDefaultMuteType } from "./getDefaultMuteType";
|
||||
import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime";
|
||||
|
||||
/**
|
||||
* TODO: Clean up this function
|
||||
|
@ -36,12 +40,9 @@ export async function muteUser(
|
|||
const lock = await pluginData.locks.acquire(muteLock({ id: userId }));
|
||||
|
||||
const muteRole = pluginData.config.get().mute_role;
|
||||
if (!muteRole) {
|
||||
lock.unlock();
|
||||
throw new RecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG);
|
||||
}
|
||||
|
||||
const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite";
|
||||
const muteType = getDefaultMuteType(pluginData);
|
||||
const muteExpiresAt = muteTime ? Date.now() + muteTime : null;
|
||||
const timeoutUntil = getTimeoutExpiryTime(muteExpiresAt);
|
||||
|
||||
// No mod specified -> mark Zeppelin as the mod
|
||||
if (!muteOptions.caseArgs?.modId) {
|
||||
|
@ -67,7 +68,7 @@ export async function muteUser(
|
|||
const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute;
|
||||
const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute;
|
||||
|
||||
// remove roles
|
||||
// Remove roles
|
||||
if (!Array.isArray(removeRoles)) {
|
||||
if (removeRoles) {
|
||||
// exclude managed roles from being removed
|
||||
|
@ -80,7 +81,7 @@ export async function muteUser(
|
|||
await member.roles.set(newRoles as Snowflake[]);
|
||||
}
|
||||
|
||||
// set roles to be restored
|
||||
// Set roles to be restored
|
||||
if (!Array.isArray(restoreRoles)) {
|
||||
if (restoreRoles) {
|
||||
rolesToRestore = currentUserRoles;
|
||||
|
@ -89,12 +90,13 @@ export async function muteUser(
|
|||
rolesToRestore = currentUserRoles.filter((x) => (<string[]>restoreRoles).includes(x));
|
||||
}
|
||||
|
||||
if (muteType === MuteTypes.Role) {
|
||||
// Apply mute role if it's missing
|
||||
if (!currentUserRoles.includes(muteRole as Snowflake)) {
|
||||
if (!currentUserRoles.includes(muteRole!)) {
|
||||
try {
|
||||
await member.roles.add(muteRole as Snowflake);
|
||||
await member.roles.add(muteRole!);
|
||||
} catch (e) {
|
||||
const actualMuteRole = pluginData.guild.roles.cache.get(muteRole as Snowflake);
|
||||
const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!);
|
||||
if (!actualMuteRole) {
|
||||
lock.unlock();
|
||||
logs.logBotAlert({
|
||||
|
@ -122,6 +124,9 @@ export async function muteUser(
|
|||
}
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
const cfg = pluginData.config.get();
|
||||
|
@ -144,13 +149,28 @@ export async function muteUser(
|
|||
rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...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))!;
|
||||
} 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);
|
||||
|
||||
const timeUntilUnmuteStr = muteTime ? humanizeDuration(muteTime) : "indefinite";
|
||||
const template = existingMute
|
||||
? config.update_mute_message
|
||||
: muteTime
|
||||
|
@ -164,7 +184,7 @@ export async function muteUser(
|
|||
new TemplateSafeValueContainer({
|
||||
guildName: pluginData.guild.name,
|
||||
reason: reason || "None",
|
||||
time: timeUntilUnmute,
|
||||
time: timeUntilUnmuteStr,
|
||||
moderator: muteOptions.caseArgs?.modId
|
||||
? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId))
|
||||
: null,
|
||||
|
@ -201,7 +221,7 @@ export async function muteUser(
|
|||
|
||||
if (theCase) {
|
||||
// Update old case
|
||||
const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`];
|
||||
const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmuteStr : "indefinite"}`];
|
||||
const reasons = reason ? [reason] : [];
|
||||
if (muteOptions.caseArgs?.extraNotes) {
|
||||
reasons.push(...muteOptions.caseArgs.extraNotes);
|
||||
|
@ -217,7 +237,7 @@ export async function muteUser(
|
|||
}
|
||||
} else {
|
||||
// Create new case
|
||||
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`];
|
||||
const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmuteStr}` : "indefinitely"}`];
|
||||
if (notifyResult.text) {
|
||||
noteDetails.push(ucfirst(notifyResult.text));
|
||||
}
|
||||
|
@ -239,7 +259,7 @@ export async function muteUser(
|
|||
pluginData.getPlugin(LogsPlugin).logMemberTimedMute({
|
||||
mod,
|
||||
user,
|
||||
time: timeUntilUnmute,
|
||||
time: timeUntilUnmuteStr,
|
||||
caseNumber: theCase.case_number,
|
||||
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 { GuildPluginData } from "knub";
|
||||
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 { CasesPlugin } from "../../Cases/CasesPlugin";
|
||||
import { CaseArgs } from "../../Cases/types";
|
||||
import { LogsPlugin } from "../../Logs/LogsPlugin";
|
||||
import { MutesPluginType, UnmuteResult } from "../types";
|
||||
import { clearMute } from "./clearMute";
|
||||
import { getDefaultMuteType } from "./getDefaultMuteType";
|
||||
import { getTimeoutExpiryTime } from "./getTimeoutExpiryTime";
|
||||
import { memberHasMutedRole } from "./memberHasMutedRole";
|
||||
|
||||
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 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) {
|
||||
// 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) {
|
||||
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 {
|
||||
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 {
|
||||
// Unmute immediately
|
||||
clearMute(pluginData, existingMute);
|
||||
|
|
|
@ -50,7 +50,8 @@ export interface MutesPluginType extends BasePluginType {
|
|||
serverLogs: GuildLogs;
|
||||
archives: GuildArchives;
|
||||
|
||||
unregisterGuildEventListener: () => void;
|
||||
unregisterExpiredRoleMuteListener: () => void;
|
||||
unregisterTimeoutMuteToRenewListener: () => void;
|
||||
|
||||
events: MutesEventEmitter;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue