diff --git a/backend/package-lock.json b/backend/package-lock.json index dae025ac..c4881c3f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index b307b431..45f70e26 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/data/GuildEvents.ts b/backend/src/data/GuildEvents.ts index 4d5a8705..290b459d 100644 --- a/backend/src/data/GuildEvents.ts +++ b/backend/src/data/GuildEvents.ts @@ -6,6 +6,7 @@ import { VCAlert } from "./entities/VCAlert"; interface GuildEventArgs extends Record { expiredMute: [Mute]; + timeoutMuteToRenew: [Mute]; scheduledPost: [ScheduledPost]; reminder: [Reminder]; expiredTempban: [Tempban]; diff --git a/backend/src/data/GuildMutes.ts b/backend/src/data/GuildMutes.ts index ac857a57..bf0e80d2 100644 --- a/backend/src/data/GuildMutes.ts +++ b/backend/src/data/GuildMutes.ts @@ -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; @@ -34,14 +45,18 @@ export class GuildMutes extends BaseGuildRepository { return mute != null; } - async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise { - const expiresAt = expiryTime ? moment.utc().add(expiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null; + async addMute(params: AddMuteParams): Promise { + 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 { + 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 { + 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 { return this.mutes .createQueryBuilder("mutes") @@ -104,4 +145,16 @@ export class GuildMutes extends BaseGuildRepository { user_id: userId, }); } + + async fillMissingMuteRole(muteRole: string): Promise { + 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(); + } } diff --git a/backend/src/data/MuteTypes.ts b/backend/src/data/MuteTypes.ts new file mode 100644 index 00000000..5e609606 --- /dev/null +++ b/backend/src/data/MuteTypes.ts @@ -0,0 +1,4 @@ +export enum MuteTypes { + Role = 1, + Timeout = 2, +} diff --git a/backend/src/data/Mutes.ts b/backend/src/data/Mutes.ts index 17efed37..5139ce9d 100644 --- a/backend/src/data/Mutes.ts +++ b/backend/src/data/Mutes.ts @@ -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; @@ -14,7 +19,16 @@ export class Mutes extends BaseRepository { this.mutes = getRepository(Mute); } - async getSoonExpiringMutes(threshold: number): Promise { + findMute(guildId: string, userId: string): Promise { + return this.mutes.findOne({ + where: { + guild_id: guildId, + user_id: userId, + }, + }); + } + + getSoonExpiringMutes(threshold: number): Promise { 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 { + 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 { const thresholdDateStr = moment.utc().subtract(OLD_EXPIRED_MUTE_THRESHOLD, "ms").format(DBDateFormat); await this.mutes diff --git a/backend/src/data/entities/Mute.ts b/backend/src/data/entities/Mute.ts index 8e515acc..258f7352 100644 --- a/backend/src/data/entities/Mute.ts +++ b/backend/src/data/entities/Mute.ts @@ -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; } diff --git a/backend/src/data/loops/expiringMutesLoop.ts b/backend/src/data/loops/expiringMutesLoop.ts index 9060e853..d8c5cd57 100644 --- a/backend/src/data/loops/expiringMutesLoop.ts +++ b/backend/src/data/loops/expiringMutesLoop.ts @@ -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), ); } diff --git a/backend/src/migrations/1680354053183-AddTimeoutColumnsToMutes.ts b/backend/src/migrations/1680354053183-AddTimeoutColumnsToMutes.ts new file mode 100644 index 00000000..eb108fab --- /dev/null +++ b/backend/src/migrations/1680354053183-AddTimeoutColumnsToMutes.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from "typeorm"; + +export class AddTimeoutColumnsToMutes1680354053183 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropColumn("mutes", "type"); + await queryRunner.dropColumn("mutes", "mute_role"); + } +} diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index c58b08ba..f8f9e166 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -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); diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 70636c6c..21b7d2a5 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -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()({ configParser: makeIoTsConfigParser(ConfigSchema), defaultOptions, - events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt], + events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents], messageCommands: [ UpdateCmd, diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts index f09c2e83..191ab383 100644 --- a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +++ b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts @@ -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; } diff --git a/backend/src/plugins/ModActions/events/AuditLogEvents.ts b/backend/src/plugins/ModActions/events/AuditLogEvents.ts new file mode 100644 index 00000000..a8a7f4ef --- /dev/null +++ b/backend/src/plugins/ModActions/events/AuditLogEvents.ts @@ -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 `], + }); + } 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, + }); + } + } + }, +}); diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index 5797eed1..0add34bf 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -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()({ // ClearActiveMuteOnRoleRemovalEvt, // FIXME: Temporarily disabled for performance ClearActiveMuteOnMemberBanEvt, ReapplyActiveMuteOnJoinEvt, + RegisterManualTimeoutsEvt, ], public: { @@ -118,13 +121,24 @@ export const MutesPlugin = zeppelinGuildPlugin()({ 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(); }, }); diff --git a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts index 8c8c5396..4d39e316 100644 --- a/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts +++ b/backend/src/plugins/Mutes/events/ReapplyActiveMuteOnJoinEvt.ts @@ -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)); - await member.roles.add(muteRole as Snowflake); - memberRoleLock.unlock(); + try { + await member.roles.add(muteRole as Snowflake); + } finally { + memberRoleLock.unlock(); + } } - - pluginData.getPlugin(LogsPlugin).logMemberMuteRejoin({ - member, - }); } + + pluginData.getPlugin(LogsPlugin).logMemberMuteRejoin({ + member, + }); }, }); diff --git a/backend/src/plugins/Mutes/events/RegisterManualTimeoutsEvt.ts b/backend/src/plugins/Mutes/events/RegisterManualTimeoutsEvt.ts new file mode 100644 index 00000000..52c5a7cf --- /dev/null +++ b/backend/src/plugins/Mutes/events/RegisterManualTimeoutsEvt.ts @@ -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, + }); + } + } + }, +}); diff --git a/backend/src/plugins/Mutes/functions/clearMute.ts b/backend/src/plugins/Mutes/functions/clearMute.ts index e4a28c92..eba6de34 100644 --- a/backend/src/plugins/Mutes/functions/clearMute.ts +++ b/backend/src/plugins/Mutes/functions/clearMute.ts @@ -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,22 +25,36 @@ export async function clearMute( const lock = await pluginData.locks.acquire(memberRolesLock(member)); try { - const muteRole = pluginData.config.get().mute_role; - if (muteRole) { - await member.roles.remove(muteRole); - } - 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); - } + const defaultMuteRole = pluginData.config.get().mute_role; + if (mute) { + const muteRole = mute.mute_role || pluginData.config.get().mute_role; - 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 { pluginData.getPlugin(LogsPlugin).logBotAlert({ body: `Failed to remove mute role from ${verboseUserMention(member.user)}`, diff --git a/backend/src/plugins/Mutes/functions/getDefaultMuteType.ts b/backend/src/plugins/Mutes/functions/getDefaultMuteType.ts new file mode 100644 index 00000000..b58333fd --- /dev/null +++ b/backend/src/plugins/Mutes/functions/getDefaultMuteType.ts @@ -0,0 +1,8 @@ +import { GuildPluginData } from "knub"; +import { MuteTypes } from "../../../data/MuteTypes"; +import { MutesPluginType } from "../types"; + +export function getDefaultMuteType(pluginData: GuildPluginData): MuteTypes { + const muteRole = pluginData.config.get().mute_role; + return muteRole ? MuteTypes.Role : MuteTypes.Timeout; +} diff --git a/backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts b/backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts new file mode 100644 index 00000000..cac8c69b --- /dev/null +++ b/backend/src/plugins/Mutes/functions/getTimeoutExpiryTime.ts @@ -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; +} diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts index 8677c162..8d9bf1e6 100644 --- a/backend/src/plugins/Mutes/functions/muteUser.ts +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -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,38 +90,42 @@ export async function muteUser( rolesToRestore = currentUserRoles.filter((x) => (restoreRoles).includes(x)); } - // Apply mute role if it's missing - if (!currentUserRoles.includes(muteRole as Snowflake)) { - try { - await member.roles.add(muteRole as Snowflake); - } catch (e) { - const actualMuteRole = pluginData.guild.roles.cache.get(muteRole as Snowflake); - if (!actualMuteRole) { - lock.unlock(); - logs.logBotAlert({ - body: `Cannot mute users, specified mute role Id is invalid`, - }); - throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); - } + if (muteType === MuteTypes.Role) { + // Apply mute role if it's missing + if (!currentUserRoles.includes(muteRole!)) { + try { + await member.roles.add(muteRole!); + } catch (e) { + const actualMuteRole = pluginData.guild.roles.cache.get(muteRole!); + if (!actualMuteRole) { + lock.unlock(); + logs.logBotAlert({ + body: `Cannot mute users, specified mute role Id is invalid`, + }); + throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_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)); - // 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)) { - lock.unlock(); - logs.logBotAlert({ - body: `Cannot mute user ${member.id}: ${e}`, - }); - throw e; - } else { - // Otherwise, throw error that mute role is above zeps roles - lock.unlock(); - logs.logBotAlert({ - body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, - }); - throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild); + 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)); + // 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)) { + lock.unlock(); + logs.logBotAlert({ + body: `Cannot mute user ${member.id}: ${e}`, + }); + throw e; + } else { + // Otherwise, throw error that mute role is above zeps roles + lock.unlock(); + logs.logBotAlert({ + body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, + }); + 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) @@ -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 ?? "", }); diff --git a/backend/src/plugins/Mutes/functions/renewTimeoutMute.ts b/backend/src/plugins/Mutes/functions/renewTimeoutMute.ts new file mode 100644 index 00000000..71637829 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/renewTimeoutMute.ts @@ -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, 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); +} diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts index 00a7123a..baee29c6 100644 --- a/backend/src/plugins/Mutes/functions/unmuteUser.ts +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -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); diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 9fe1e575..53ba52c2 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -50,7 +50,8 @@ export interface MutesPluginType extends BasePluginType { serverLogs: GuildLogs; archives: GuildArchives; - unregisterGuildEventListener: () => void; + unregisterExpiredRoleMuteListener: () => void; + unregisterTimeoutMuteToRenewListener: () => void; events: MutesEventEmitter; };