diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index b5eded54..b01df6b6 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -31,6 +31,7 @@ "MESSAGE_DELETE": "🗑 Message (`{message.id}`) from {userMention(user)} deleted in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", "MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in {channelMention(channel)} ({archiveUrl})", "MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in {channelMention(channel)} (no more info available)", + "MESSAGE_DELETE_AUTO": "🗑 Auto-deleted message (`{message.id}`) from {userMention(user)} in {channelMention(channel)} (originally posted at **{messageDate}**):{messageSummary(message)}", "VOICE_CHANNEL_JOIN": "🎙 🔵 {userMention(member)} joined **{channel.name}**", "VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**", diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts index 31798b78..0a66e352 100644 --- a/backend/src/data/LogType.ts +++ b/backend/src/data/LogType.ts @@ -62,4 +62,6 @@ export enum LogType { SCHEDULED_REPEATED_MESSAGE, REPEATED_MESSAGE, + + MESSAGE_DELETE_AUTO, } diff --git a/backend/src/plugins/AutoDelete.ts b/backend/src/plugins/AutoDelete.ts new file mode 100644 index 00000000..a83cbe31 --- /dev/null +++ b/backend/src/plugins/AutoDelete.ts @@ -0,0 +1,152 @@ +import { IPluginOptions, logger } from "knub"; +import * as t from "io-ts"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { GuildSavedMessages } from "../data/GuildSavedMessages"; +import { SavedMessage } from "../data/entities/SavedMessage"; +import { convertDelayStringToMS, MINUTES, sorter, stripObjectToScalars, tDelayString } from "../utils"; +import { GuildLogs } from "../data/GuildLogs"; +import { LogType } from "../data/LogType"; +import moment from "moment-timezone"; + +const ConfigSchema = t.type({ + enabled: t.boolean, + delay: tDelayString, +}); +type TConfigSchema = t.TypeOf; + +interface IDeletionQueueItem { + deleteAt: number; + message: SavedMessage; +} + +const MAX_DELAY = 5 * MINUTES; + +export class AutoDeletePlugin extends ZeppelinPlugin { + public static pluginName = "auto_delete"; + public static showInDocs = true; + + public static configSchema = ConfigSchema; + + public static pluginInfo = { + prettyName: "Auto-delete", + description: "Allows Zeppelin to auto-delete messages from a channel after a delay", + configurationGuide: "Maximum deletion delay is currently 5 minutes", + }; + + protected guildSavedMessages: GuildSavedMessages; + protected guildLogs: GuildLogs; + + protected onMessageCreateFn; + protected onMessageDeleteFn; + protected onMessageDeleteBulkFn; + + protected deletionQueue: IDeletionQueueItem[]; + protected nextDeletion: number; + protected nextDeletionTimeout; + + protected maxDelayWarningSent = false; + + public static getStaticDefaultOptions(): IPluginOptions { + return { + config: { + enabled: false, + delay: "5s", + }, + }; + } + + protected onLoad() { + this.guildSavedMessages = GuildSavedMessages.getGuildInstance(this.guildId); + this.guildLogs = new GuildLogs(this.guildId); + + this.deletionQueue = []; + + this.onMessageCreateFn = this.onMessageCreate.bind(this); + this.onMessageDeleteFn = this.onMessageDelete.bind(this); + this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this); + + this.guildSavedMessages.events.on("create", this.onMessageCreateFn); + this.guildSavedMessages.events.on("delete", this.onMessageDeleteFn); + this.guildSavedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn); + } + + protected onUnload() { + this.guildSavedMessages.events.off("create", this.onMessageCreateFn); + this.guildSavedMessages.events.off("delete", this.onMessageDeleteFn); + this.guildSavedMessages.events.off("deleteBulk", this.onMessageDeleteFn); + clearTimeout(this.nextDeletionTimeout); + } + + protected addMessageToDeletionQueue(msg: SavedMessage, delay: number) { + const deleteAt = Date.now() + delay; + this.deletionQueue.push({ deleteAt, message: msg }); + this.deletionQueue.sort(sorter("deleteAt")); + + this.scheduleNextDeletion(); + } + + protected scheduleNextDeletion() { + if (this.deletionQueue.length === 0) { + clearTimeout(this.nextDeletionTimeout); + return; + } + + const firstDeleteAt = this.deletionQueue[0].deleteAt; + clearTimeout(this.nextDeletionTimeout); + this.nextDeletionTimeout = setTimeout(() => this.deleteNextItem(), firstDeleteAt - Date.now()); + } + + protected async deleteNextItem() { + const [itemToDelete] = this.deletionQueue.splice(0, 1); + if (!itemToDelete) return; + + this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); + this.bot.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn); + + this.scheduleNextDeletion(); + + const user = await this.resolveUser(itemToDelete.message.user_id); + const channel = this.guild.channels.get(itemToDelete.message.channel_id); + const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss"); + + this.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, { + message: itemToDelete.message, + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + messageDate, + }); + } + + protected onMessageCreate(msg: SavedMessage) { + const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id); + if (config.enabled) { + let delay = convertDelayStringToMS(config.delay); + + if (delay > MAX_DELAY) { + delay = MAX_DELAY; + if (!this.maxDelayWarningSent) { + this.guildLogs.log(LogType.BOT_ALERT, { + body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, + }); + this.maxDelayWarningSent = true; + } + } + + this.addMessageToDeletionQueue(msg, delay); + } + } + + protected onMessageDelete(msg: SavedMessage) { + const indexToDelete = this.deletionQueue.findIndex(item => item.message.id === msg.id); + if (indexToDelete > -1) { + this.deletionQueue.splice(indexToDelete, 1); + this.scheduleNextDeletion(); + } + } + + protected onMessageDeleteBulk(messages: SavedMessage[]) { + for (const msg of messages) { + this.onMessageDelete(msg); + } + } +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index f30034ff..fdc535d9 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -28,6 +28,7 @@ import { GuildConfigReloader } from "./GuildConfigReloader"; import { ChannelArchiverPlugin } from "./ChannelArchiver"; import { AutomodPlugin } from "./Automod"; import { RolesPlugin } from "./Roles"; +import { AutoDeletePlugin } from "./AutoDelete"; /** * Plugins available to be loaded for individual guilds @@ -60,6 +61,7 @@ export const availablePlugins = [ LocatePlugin, ChannelArchiverPlugin, RolesPlugin, + AutoDeletePlugin, ]; /**