3
0
Fork 0
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:
Dragory 2023-04-01 18:33:09 +03:00 committed by Miikka
parent 06877e90cc
commit 39e0dfa27f
23 changed files with 532 additions and 92 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,14 @@ import { getRepository, Repository } from "typeorm";
import { DAYS, DBDateFormat } from "../utils"; import { 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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