diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index b01df6b6..e36e5e53 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -61,5 +61,8 @@ "POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "BOT_ALERT": "⚠ {tmplEval(body)}", - "AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}\n{matchSummary}\nActions taken: **{actionsTaken}**" + "AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**", + + "SET_ANTIRAID_USER": "⚔ {userMention(user)} set anti-raid to **{level}**", + "SET_ANTIRAID_AUTO": "⚔ Anti-raid automatically set to **{level}**" } diff --git a/backend/src/data/GuildAntiraidLevels.ts b/backend/src/data/GuildAntiraidLevels.ts new file mode 100644 index 00000000..43080641 --- /dev/null +++ b/backend/src/data/GuildAntiraidLevels.ts @@ -0,0 +1,42 @@ +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { getRepository, Repository } from "typeorm"; +import { AntiraidLevel } from "./entities/AntiraidLevel"; + +export class GuildAntiraidLevels extends BaseGuildRepository { + protected antiraidLevels: Repository; + + constructor(guildId: string) { + super(guildId); + this.antiraidLevels = getRepository(AntiraidLevel); + } + + async get() { + const row = await this.antiraidLevels.findOne({ + where: { + guild_id: this.guildId, + }, + }); + + return row?.level ?? null; + } + + async set(level: string | null) { + if (level === null) { + await this.antiraidLevels.delete({ + guild_id: this.guildId, + }); + } else { + // Upsert: https://stackoverflow.com/a/47064558/316944 + await this.antiraidLevels + .createQueryBuilder() + .insert() + .values({ + guild_id: this.guildId, + level, + }) + .onConflict('("guild_id") DO UPDATE SET "guild_id" = :guildId') + .setParameter("guildId", this.guildId) + .execute(); + } + } +} diff --git a/backend/src/data/GuildArchives.ts b/backend/src/data/GuildArchives.ts index dedbb307..13cc4d82 100644 --- a/backend/src/data/GuildArchives.ts +++ b/backend/src/data/GuildArchives.ts @@ -71,10 +71,7 @@ export class GuildArchives extends BaseGuildRepository { return result.identifiers[0].id; } - async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) { - if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days"); - - const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild }); + protected async renderLinesFromSavedMessages(savedMessages: SavedMessage[], guild: Guild) { const msgLines = []; for (const msg of savedMessages) { const channel = guild.channels.get(msg.channel_id); @@ -89,11 +86,29 @@ export class GuildArchives extends BaseGuildRepository { }); msgLines.push(line); } + return msgLines; + } + + async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) { + if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days"); + + const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild }); + const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); const messagesStr = msgLines.join("\n"); return this.create([headerStr, messagesStr].join("\n\n"), expiresAt); } + async addSavedMessagesToArchive(archiveId: string, savedMessages: SavedMessage[], guild: Guild) { + const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild); + const messagesStr = msgLines.join("\n"); + + const archive = await this.find(archiveId); + archive.body += "\n" + messagesStr; + + await this.archives.update({ id: archiveId }, { body: archive.body }); + } + getUrl(baseUrl, archiveId) { return baseUrl ? `${baseUrl}/archives/${archiveId}` : `Archive ID: ${archiveId}`; } diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts index 0a66e352..6b558dc6 100644 --- a/backend/src/data/LogType.ts +++ b/backend/src/data/LogType.ts @@ -64,4 +64,9 @@ export enum LogType { REPEATED_MESSAGE, MESSAGE_DELETE_AUTO, + + SET_ANTIRAID_USER, + SET_ANTIRAID_AUTO, + + AUTOMOD_SPAM_NEW, } diff --git a/backend/src/data/entities/AntiraidLevel.ts b/backend/src/data/entities/AntiraidLevel.ts new file mode 100644 index 00000000..374ed358 --- /dev/null +++ b/backend/src/data/entities/AntiraidLevel.ts @@ -0,0 +1,11 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +@Entity("antiraid_levels") +export class AntiraidLevel { + @Column() + @PrimaryColumn() + guild_id: string; + + @Column() + level: string; +} diff --git a/backend/src/migrations/1580038836906-CreateAntiraidLevelsTable.ts b/backend/src/migrations/1580038836906-CreateAntiraidLevelsTable.ts new file mode 100644 index 00000000..d7621269 --- /dev/null +++ b/backend/src/migrations/1580038836906-CreateAntiraidLevelsTable.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateAntiraidLevelsTable1580038836906 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "antiraid_levels", + columns: [ + { + name: "guild_id", + type: "bigint", + unsigned: true, + isPrimary: true, + }, + { + name: "level", + type: "varchar", + length: "64", + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("antiraid_levels"); + } +} diff --git a/backend/src/plugins/AntiRaid.ts b/backend/src/plugins/AntiRaid.ts new file mode 100644 index 00000000..7413b2e2 --- /dev/null +++ b/backend/src/plugins/AntiRaid.ts @@ -0,0 +1,159 @@ +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 AntiRaidLevel = t.type({ + on_join: t.type({ + kick: t.boolean, + ban: t.boolean, + }), +}); + +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 AntiRaid 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/Automod.ts b/backend/src/plugins/Automod/Automod.ts similarity index 62% rename from backend/src/plugins/Automod.ts rename to backend/src/plugins/Automod/Automod.ts index ad0e234e..45eb75a2 100644 --- a/backend/src/plugins/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -1,4 +1,4 @@ -import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; +import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "../ZeppelinPlugin"; import * as t from "io-ts"; import { convertDelayStringToMS, @@ -15,109 +15,64 @@ import { SECONDS, stripObjectToScalars, tDeepPartial, + tDelayString, tNullable, UnknownUser, verboseChannelMention, -} from "../utils"; -import { configUtils, CooldownManager } from "knub"; -import { Member, TextChannel } from "eris"; +} from "../../utils"; +import { configUtils, CooldownManager, IPluginOptions, decorators as d, logger } from "knub"; +import { Member, Message, TextChannel, User } from "eris"; import escapeStringRegexp from "escape-string-regexp"; -import { SimpleCache } from "../SimpleCache"; -import { Queue } from "../Queue"; -import { ModActionsPlugin } from "./ModActions"; -import { MutesPlugin } from "./Mutes"; -import { LogsPlugin } from "./Logs"; -import { LogType } from "../data/LogType"; -import { TSafeRegex } from "../validatorUtils"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { GuildArchives } from "../data/GuildArchives"; -import { GuildLogs } from "../data/GuildLogs"; -import { SavedMessage } from "../data/entities/SavedMessage"; +import { SimpleCache } from "../../SimpleCache"; +import { Queue } from "../../Queue"; +import { ModActionsPlugin } from "../ModActions"; +import { MutesPlugin } from "../Mutes"; +import { LogsPlugin } from "../Logs"; +import { LogType } from "../../data/LogType"; +import { TSafeRegex } from "../../validatorUtils"; +import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { GuildArchives } from "../../data/GuildArchives"; +import { GuildLogs } from "../../data/GuildLogs"; +import { SavedMessage } from "../../data/entities/SavedMessage"; import moment from "moment-timezone"; -import { renderTemplate } from "../templateFormatter"; +import { renderTemplate } from "../../templateFormatter"; import { transliterate } from "transliteration"; import Timeout = NodeJS.Timeout; +import { IMatchParams } from "knub/dist/configUtils"; +import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; +import { + AnySpamTriggerMatchResult, + AnyTriggerMatchResult, + BaseTextSpamTrigger, + MessageInfo, + OtherRecentAction, + OtherSpamTriggerMatchResult, + OtherTriggerMatchResult, + RecentAction, + RecentActionType, + RecentSpam, + Rule, + TBaseSpamTrigger, + TBaseTextSpamTrigger, + TextRecentAction, + TextSpamTriggerMatchResult, + TextTriggerMatchResult, + TextTriggerWithMultipleMatchTypes, + TMatchInvitesTrigger, + TMatchLinksTrigger, + TMatchRegexTrigger, + TMatchWordsTrigger, + TMemberJoinTrigger, + TRule, +} from "./types"; +import { pluginInfo } from "./info"; -type MessageInfo = { channelId: string; messageId: string }; - -type TextTriggerWithMultipleMatchTypes = { - match_messages: boolean; - match_embeds: boolean; - match_visible_names: boolean; - match_usernames: boolean; - match_nicknames: boolean; - match_custom_status: boolean; -}; - -interface TriggerMatchResult { - trigger: string; - type: string; -} - -interface MessageTextTriggerMatchResult extends TriggerMatchResult { - type: "message" | "embed"; - str: string; - userId: string; - messageInfo: MessageInfo; - matchedValue: T; -} - -interface OtherTextTriggerMatchResult extends TriggerMatchResult { - type: "username" | "nickname" | "visiblename" | "customstatus"; - str: string; - userId: string; - matchedValue: T; -} - -type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; - -interface TextSpamTriggerMatchResult extends TriggerMatchResult { - type: "textspam"; - actionType: RecentActionType; - channelId: string; - userId: string; - messageInfos: MessageInfo[]; -} - -interface RaidSpamTriggerMatchResult extends TriggerMatchResult { - type: "raidspam"; - actionType: RecentActionType; - channelId: string; - userIds: string[]; - messageInfos: MessageInfo[]; -} - -interface OtherSpamTriggerMatchResult extends TriggerMatchResult { - type: "otherspam"; - actionType: RecentActionType; - userId: string; -} - -type AnyTriggerMatchResult = - | TextTriggerMatchResult - | TextSpamTriggerMatchResult - | RaidSpamTriggerMatchResult - | OtherSpamTriggerMatchResult; +const unactioned = (action: TextRecentAction | OtherRecentAction) => !action.actioned; /** - * CONFIG SCHEMA FOR TRIGGERS + * DEFAULTS */ -const MatchWordsTrigger = t.type({ - words: t.array(t.string), - case_sensitive: t.boolean, - only_full_words: t.boolean, - normalize: t.boolean, - loose_matching: t.boolean, - loose_matching_threshold: t.number, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -type TMatchWordsTrigger = t.TypeOf; const defaultMatchWordsTrigger: Partial = { case_sensitive: false, only_full_words: true, @@ -132,18 +87,6 @@ const defaultMatchWordsTrigger: Partial = { match_custom_status: false, }; -const MatchRegexTrigger = t.type({ - patterns: t.array(TSafeRegex), - case_sensitive: t.boolean, - normalize: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -type TMatchRegexTrigger = t.TypeOf; const defaultMatchRegexTrigger: Partial = { case_sensitive: false, normalize: false, @@ -155,20 +98,6 @@ const defaultMatchRegexTrigger: Partial = { match_custom_status: false, }; -const MatchInvitesTrigger = t.type({ - include_guilds: tNullable(t.array(t.string)), - exclude_guilds: tNullable(t.array(t.string)), - include_invite_codes: tNullable(t.array(t.string)), - exclude_invite_codes: tNullable(t.array(t.string)), - allow_group_dm_invites: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -type TMatchInvitesTrigger = t.TypeOf; const defaultMatchInvitesTrigger: Partial = { allow_group_dm_invites: false, match_messages: true, @@ -179,18 +108,6 @@ const defaultMatchInvitesTrigger: Partial = { match_custom_status: false, }; -const MatchLinksTrigger = t.type({ - include_domains: tNullable(t.array(t.string)), - exclude_domains: tNullable(t.array(t.string)), - include_subdomains: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -type TMatchLinksTrigger = t.TypeOf; const defaultMatchLinksTrigger: Partial = { include_subdomains: true, match_messages: true, @@ -201,126 +118,14 @@ const defaultMatchLinksTrigger: Partial = { match_custom_status: false, }; -const BaseSpamTrigger = t.type({ - amount: t.number, - within: t.string, -}); -const BaseTextSpamTrigger = t.intersection([ - BaseSpamTrigger, - t.type({ - per_channel: t.boolean, - }), -]); -type TBaseTextSpamTrigger = t.TypeOf; const defaultTextSpamTrigger: Partial> = { per_channel: true, }; -const MessageSpamTrigger = BaseTextSpamTrigger; -type TMessageSpamTrigger = t.TypeOf; -const MentionSpamTrigger = BaseTextSpamTrigger; -type TMentionSpamTrigger = t.TypeOf; -const LinkSpamTrigger = BaseTextSpamTrigger; -type TLinkSpamTrigger = t.TypeOf; -const AttachmentSpamTrigger = BaseTextSpamTrigger; -type TAttachmentSpamTrigger = t.TypeOf; -const EmojiSpamTrigger = BaseTextSpamTrigger; -type TEmojiSpamTrigger = t.TypeOf; -const LineSpamTrigger = BaseTextSpamTrigger; -type TLineSpamTrigger = t.TypeOf; -const CharacterSpamTrigger = BaseTextSpamTrigger; -type TCharacterSpamTrigger = t.TypeOf; -const VoiceMoveSpamTrigger = BaseSpamTrigger; -type TVoiceMoveSpamTrigger = t.TypeOf; - -/** - * CONFIG SCHEMA FOR ACTIONS - */ - -const CleanAction = t.boolean; - -const WarnAction = t.type({ - reason: t.string, -}); - -const MuteAction = t.type({ - duration: t.string, - reason: tNullable(t.string), -}); - -const KickAction = t.type({ - reason: tNullable(t.string), -}); - -const BanAction = t.type({ - reason: tNullable(t.string), -}); - -const AlertAction = t.type({ - channel: t.string, - text: t.string, -}); - -const ChangeNicknameAction = t.type({ - name: t.string, -}); - -const LogAction = t.boolean; - -const AddRolesAction = t.array(t.string); -const RemoveRolesAction = t.array(t.string); - -/** - * FULL CONFIG SCHEMA - */ - -const Rule = t.type({ - enabled: t.boolean, - name: t.string, - presets: tNullable(t.array(t.string)), - triggers: t.array( - t.type({ - match_words: tNullable(MatchWordsTrigger), - match_regex: tNullable(MatchRegexTrigger), - match_invites: tNullable(MatchInvitesTrigger), - match_links: tNullable(MatchLinksTrigger), - message_spam: tNullable(MessageSpamTrigger), - mention_spam: tNullable(MentionSpamTrigger), - link_spam: tNullable(LinkSpamTrigger), - attachment_spam: tNullable(AttachmentSpamTrigger), - emoji_spam: tNullable(EmojiSpamTrigger), - line_spam: tNullable(LineSpamTrigger), - character_spam: tNullable(CharacterSpamTrigger), - // voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO - // TODO: Duplicates trigger - }), - ), - actions: t.type({ - clean: tNullable(CleanAction), - warn: tNullable(WarnAction), - mute: tNullable(MuteAction), - kick: tNullable(KickAction), - ban: tNullable(BanAction), - alert: tNullable(AlertAction), - change_nickname: tNullable(ChangeNicknameAction), - log: tNullable(LogAction), - add_roles: tNullable(AddRolesAction), - remove_roles: tNullable(RemoveRolesAction), - }), - cooldown: tNullable(t.string), -}); -type TRule = t.TypeOf; - -const ConfigSchema = t.type({ - rules: t.record(t.string, Rule), -}); -type TConfigSchema = t.TypeOf; - -const PartialConfigSchema = tDeepPartial(ConfigSchema); - -/** - * DEFAULTS - */ +const defaultMemberJoinTrigger: Partial = { + only_new: false, + new_threshold: "1h", +}; const defaultTriggers = { match_words: defaultMatchWordsTrigger, @@ -334,165 +139,67 @@ const defaultTriggers = { emoji_spam: defaultTextSpamTrigger, line_spam: defaultTextSpamTrigger, character_spam: defaultTextSpamTrigger, + member_join: defaultMemberJoinTrigger, }; +/** + * CONFIG + */ + +const ConfigSchema = t.type({ + rules: t.record(t.string, Rule), + antiraid_levels: t.array(t.string), + can_set_antiraid: t.boolean, + can_view_antiraid: t.boolean, +}); +type TConfigSchema = t.TypeOf; + +const PartialConfigSchema = tDeepPartial(ConfigSchema); + +interface ICustomOverrides { + antiraid_level: string; +} + /** * MISC */ -enum RecentActionType { - Message = 1, - Mention, - Link, - Attachment, - Emoji, - Line, - Character, - VoiceChannelMove, -} - -interface BaseRecentAction { - identifier: string; - timestamp: number; - count: number; -} - -type TextRecentAction = BaseRecentAction & { - type: - | RecentActionType.Message - | RecentActionType.Mention - | RecentActionType.Link - | RecentActionType.Attachment - | RecentActionType.Emoji - | RecentActionType.Line - | RecentActionType.Character; - messageInfo: MessageInfo; -}; - -type OtherRecentAction = BaseRecentAction & { - type: RecentActionType.VoiceChannelMove; -}; - -type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number }; - -const SPAM_GRACE_PERIOD_LENGTH = 10 * SECONDS; +const RECENT_SPAM_EXPIRY_TIME = 30 * SECONDS; const RECENT_ACTION_EXPIRY_TIME = 2 * MINUTES; const MAX_RECENTLY_DELETED_MESSAGES = 10; const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES; const inviteCache = new SimpleCache(10 * MINUTES); +const RAID_SPAM_IDENTIFIER = "raid"; + /** * General plugin flow: - * - When a message is posted: - * 1. Run logRecentActionsForMessage() -- used for detecting spam - * 2. Run matchRuleToMessage() for each automod rule. This checks if any triggers in the rule match the message. - * 3. If a rule matched, run applyActionsOnMatch() for that rule/match + * + * - Message based triggers: + * 1. matchRuleToMessage() + * 2. if match -> applyActionsOnMatch() + * 3. if spam -> clearTextSpamRecentActions() + * + * - Non-message based non-spam triggers: + * 1. bespoke match function + * 2. if match -> applyActionsOnMatch() + * + * - Non-message based spam triggers: + * 1. matchOtherSpamInRule() + * 2. if match -> applyActionsOnMatch() + * 3. -> clearOtherSpamRecentActions() + * + * To log actions for spam detection, logRecentActionsForMessage() is called for each message, and several other events + * call addRecentAction() directly. These are then checked by matchRuleToMessage() and matchOtherSpamInRule() to detect + * spam. */ -export class AutomodPlugin extends ZeppelinPlugin { +export class AutomodPlugin extends ZeppelinPlugin { public static pluginName = "automod"; public static configSchema = ConfigSchema; public static dependencies = ["mod_actions", "mutes", "logs"]; - public static pluginInfo: PluginInfo = { - prettyName: "Automod", - description: trimPluginDescription(` - Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention. - `), - configurationGuide: trimPluginDescription(` - The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page. - - ### Simple word filter - Removes any messages that contain the word 'banana' and sends a warning to the user. - Moderators (level >= 50) are ignored by the filter based on the override. - - ~~~yml - automod: - config: - rules: - my_filter: - triggers: - - match_words: - words: ['banana'] - case_sensitive: false - only_full_words: true - actions: - clean: true - warn: - reason: 'Do not talk about bananas!' - overrides: - - level: '>=50' - config: - rules: - my_filter: - enabled: false - ~~~ - - ### Spam detection - This example includes 2 filters: - - - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds. - The messages are deleted and the user is muted for 5 minutes. - - The second filter is triggered if a user sends more than 2 emoji within 5 seconds. - The messages are deleted but the user is not muted. - - Moderators are ignored by both filters based on the override. - - ~~~yml - automod: - config: - rules: - my_spam_filter: - triggers: - - message_spam: - amount: 5 - within: 10s - - attachment_spam: - amount: 3 - within: 60s - actions: - clean: true - mute: - duration: 5m - reason: 'Auto-muted for spam' - my_second_filter: - triggers: - - message_spam: - amount: 5 - within: 10s - actions: - clean: true - overrides: - - level: '>=50' - config: - rules: - my_spam_filter: - enabled: false - my_second_filter: - enabled: false - ~~~ - - ### Custom status alerts - This example sends an alert any time a user with a matching custom status sends a message. - - ~~~yml - automod: - config: - rules: - bad_custom_statuses: - triggers: - - match_words: - words: ['banana'] - match_custom_status: true - actions: - alert: - channel: "473087035574321152" - text: |- - Bad custom status on user <@!{user.id}>: - {matchSummary} - ~~~ - `), - }; + public static pluginInfo = pluginInfo; protected unloaded = false; @@ -503,14 +210,12 @@ export class AutomodPlugin extends ZeppelinPlugin { protected recentActions: RecentAction[]; protected recentActionClearInterval: Timeout; - // After a spam trigger is tripped and the rule's action carried out, a short "grace period" will be placed on the user. - // During this grace period, if the user repeats the same type of recent action that tripped the rule, that message will - // be deleted and no further action will be carried out. This is mainly to account for the delay between the spam message - // being posted and the bot reacting to it, during which the user could keep posting more spam. - protected spamGracePeriods: Map; // Key = identifier-actionType - protected spamGracePriodClearInterval: Timeout; - - protected recentlyDeletedMessages: string[]; + // After a spam trigger is tripped and the rule's action carried out, a unique identifier is placed here so further + // spam (either messages that were sent before the bot managed to mute the user or, with global spam, other users + // continuing to spam) is "included" in the same match and doesn't generate duplicate cases or logs. + // Key: rule_name-match_identifier + protected recentSpam: Map; + protected recentSpamClearInterval: Timeout; protected recentNicknameChanges: Map; protected recentNicknameChangesClearInterval: Timeout; @@ -522,6 +227,10 @@ export class AutomodPlugin extends ZeppelinPlugin { protected savedMessages: GuildSavedMessages; protected archives: GuildArchives; protected guildLogs: GuildLogs; + protected antiraidLevels: GuildAntiraidLevels; + + protected loadedAntiraidLevel: boolean; + protected cachedAntiraidLevel: string | null; protected static preprocessStaticConfig(config: t.TypeOf) { if (config.rules) { @@ -558,22 +267,43 @@ export class AutomodPlugin extends ZeppelinPlugin { return config; } - public static getStaticDefaultOptions() { + public static getStaticDefaultOptions(): IPluginOptions { return { - rules: [], + config: { + rules: {}, + antiraid_levels: ["low", "medium", "high"], + can_set_antiraid: false, + can_view_antiraid: false, + }, + overrides: [ + { + level: ">=50", + config: { + can_view_antiraid: true, + }, + }, + { + level: ">=100", + config: { + can_set_antiraid: true, + }, + }, + ], }; } - protected onLoad() { + protected matchCustomOverrideCriteria(criteria: ICustomOverrides, matchParams: IMatchParams) { + return matchParams?.extra?.antiraid_level && matchParams.extra.antiraid_level === this.cachedAntiraidLevel; + } + + protected async onLoad() { this.automodQueue = new Queue(); this.recentActions = []; this.recentActionClearInterval = setInterval(() => this.clearOldRecentActions(), 1 * MINUTES); - this.spamGracePeriods = new Map(); - this.spamGracePriodClearInterval = setInterval(() => this.clearExpiredGracePeriods(), 1 * SECONDS); - - this.recentlyDeletedMessages = []; + this.recentSpam = new Map(); + this.recentSpamClearInterval = setInterval(() => this.clearExpiredRecentSpam(), 1 * SECONDS); this.recentNicknameChanges = new Map(); this.recentNicknameChangesClearInterval = setInterval(() => this.clearExpiredRecentNicknameChanges(), 30 * SECONDS); @@ -583,6 +313,9 @@ export class AutomodPlugin extends ZeppelinPlugin { this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); this.archives = GuildArchives.getGuildInstance(this.guildId); this.guildLogs = new GuildLogs(this.guildId); + this.antiraidLevels = GuildAntiraidLevels.getGuildInstance(this.guildId); + + this.cachedAntiraidLevel = await this.antiraidLevels.get(); this.onMessageCreateFn = msg => this.onMessageCreate(msg); this.savedMessages.events.on("create", this.onMessageCreateFn); @@ -604,7 +337,7 @@ export class AutomodPlugin extends ZeppelinPlugin { this.unloaded = true; this.savedMessages.events.off("create", this.onMessageCreateFn); clearInterval(this.recentActionClearInterval); - clearInterval(this.spamGracePriodClearInterval); + clearInterval(this.recentSpamClearInterval); clearInterval(this.recentNicknameChangesClearInterval); } @@ -731,11 +464,10 @@ export class AutomodPlugin extends ZeppelinPlugin { recentActionType: RecentActionType, trigger: TBaseTextSpamTrigger, msg: SavedMessage, - ): Partial { + ): Omit { const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within); - const recentActions = trigger.per_channel - ? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since) - : this.getMatchingRecentActions(recentActionType, msg.user_id, since); + const identifier = trigger.per_channel ? `${msg.channel_id}-${msg.user_id}` : msg.user_id; + const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since); const totalCount = recentActions.reduce((total, action) => { return total + action.count; }, 0); @@ -744,9 +476,31 @@ export class AutomodPlugin extends ZeppelinPlugin { return { type: "textspam", actionType: recentActionType, - channelId: trigger.per_channel ? msg.channel_id : null, - messageInfos: recentActions.map(action => (action as TextRecentAction).messageInfo), - userId: msg.user_id, + recentActions: recentActions as TextRecentAction[], + identifier, + }; + } + + return null; + } + + protected matchOtherSpamTrigger( + recentActionType: RecentActionType, + trigger: TBaseSpamTrigger, + identifier: string | null, + ): Omit { + const since = moment.utc().valueOf() - convertDelayStringToMS(trigger.within); + const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since) as OtherRecentAction[]; + const totalCount = recentActions.reduce((total, action) => { + return total + action.count; + }, 0); + + if (totalCount >= trigger.amount) { + return { + type: "otherspam", + actionType: recentActionType, + recentActions, + identifier, }; } @@ -758,7 +512,7 @@ export class AutomodPlugin extends ZeppelinPlugin { msg: SavedMessage, matchFn: (str: string) => T | Promise | null, ): Promise>> { - const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id }; + const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id, userId: msg.user_id }; const member = this.guild.members.get(msg.user_id); if (trigger.match_messages) { @@ -877,37 +631,71 @@ export class AutomodPlugin extends ZeppelinPlugin { if (trigger.message_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg); - if (match) return { ...match, trigger: "message_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "message_spam" }; } if (trigger.mention_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg); - if (match) return { ...match, trigger: "mention_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "mention_spam" }; } if (trigger.link_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg); - if (match) return { ...match, trigger: "link_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "link_spam" }; } if (trigger.attachment_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg); - if (match) return { ...match, trigger: "attachment_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "attachment_spam" }; } if (trigger.emoji_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg); - if (match) return { ...match, trigger: "emoji_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "emoji_spam" }; } if (trigger.line_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg); - if (match) return { ...match, trigger: "line_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "line_spam" }; } if (trigger.character_spam) { const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg); - if (match) return { ...match, trigger: "character_spam" } as TextSpamTriggerMatchResult; + if (match) return { ...match, rule, trigger: "character_spam" }; + } + } + + return null; + } + + protected async matchOtherSpamInRule(rule: TRule, userId: string): Promise { + if (!rule.enabled) return; + + for (const trigger of rule.triggers) { + if (trigger.member_join_spam) { + const match = this.matchOtherSpamTrigger(RecentActionType.MemberJoin, trigger.member_join_spam, null); + if (match) return { ...match, rule, trigger: "member_join_spam" }; + } + } + + return null; + } + + protected async matchMemberJoinTriggerInRule(rule: TRule, member: Member): Promise { + if (!rule.enabled) return; + + const result: OtherTriggerMatchResult = { trigger: "member_join", type: "other", userId: member.id }; + + for (const trigger of rule.triggers) { + if (trigger.member_join) { + if (trigger.member_join.only_new) { + const threshold = Date.now() - convertDelayStringToMS(trigger.member_join.new_threshold); + if (member.createdAt >= threshold) { + return result; + } + } else { + return result; + } } } @@ -915,21 +703,13 @@ export class AutomodPlugin extends ZeppelinPlugin { } protected async addRecentMessageAction(action: TextRecentAction) { - const gracePeriodKey = `${action.identifier}-${action.type}`; - if (this.spamGracePeriods.has(gracePeriodKey)) { - // If we're on spam detection grace period, just delete the message - if (!this.recentlyDeletedMessages.includes(action.messageInfo.messageId)) { - this.bot.deleteMessage(action.messageInfo.channelId, action.messageInfo.messageId); - - this.recentlyDeletedMessages.push(action.messageInfo.messageId); - if (this.recentlyDeletedMessages.length > MAX_RECENTLY_DELETED_MESSAGES) { - this.recentlyDeletedMessages.splice(0, this.recentlyDeletedMessages.length - MAX_RECENTLY_DELETED_MESSAGES); - } - } - - return; - } + this.recentActions.push({ + ...action, + expiresAt: Date.now() + RECENT_ACTION_EXPIRY_TIME, + }); + } + protected async addRecentAction(action: OtherRecentAction) { this.recentActions.push({ ...action, expiresAt: Date.now() + RECENT_ACTION_EXPIRY_TIME, @@ -943,7 +723,7 @@ export class AutomodPlugin extends ZeppelinPlugin { const timestamp = moment.utc(msg.posted_at).valueOf(); const globalIdentifier = msg.user_id; const perChannelIdentifier = `${msg.channel_id}-${msg.user_id}`; - const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id }; + const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id, userId: msg.user_id }; this.addRecentMessageAction({ type: RecentActionType.Message, @@ -1071,28 +851,16 @@ export class AutomodPlugin extends ZeppelinPlugin { } } - protected getMatchingRecentActions(type: RecentActionType, identifier: string, since: number) { + protected getMatchingRecentActions(type: RecentActionType, identifier: string | null, since: number) { return this.recentActions.filter(action => { - return action.type === type && action.identifier === identifier && action.timestamp >= since; + return action.type === type && (!identifier || action.identifier === identifier) && action.timestamp >= since; }); } - protected async activateGracePeriod(matchResult: TextSpamTriggerMatchResult) { - const expiresAt = Date.now() + SPAM_GRACE_PERIOD_LENGTH; - - // Global identifier - this.spamGracePeriods.set(`${matchResult.userId}-${matchResult.actionType}`, { expiresAt, deletedMessages: [] }); - // Per-channel identifier - this.spamGracePeriods.set(`${matchResult.channelId}-${matchResult.userId}-${matchResult.actionType}`, { - expiresAt, - deletedMessages: [], - }); - } - - protected async clearExpiredGracePeriods() { - for (const [key, info] of this.spamGracePeriods.entries()) { + protected async clearExpiredRecentSpam() { + for (const [key, info] of this.recentSpam.entries()) { if (info.expiresAt <= Date.now()) { - this.spamGracePeriods.delete(key); + this.recentSpam.delete(key); } } } @@ -1111,12 +879,6 @@ export class AutomodPlugin extends ZeppelinPlugin { } } - protected async clearSpecificRecentActions(type: RecentActionType, identifier: string) { - this.recentActions = this.recentActions.filter(info => { - return !(info.type === type && info.identifier === identifier); - }); - } - /** * Apply the actions of the specified rule on the matched message/member */ @@ -1125,26 +887,57 @@ export class AutomodPlugin extends ZeppelinPlugin { return; } - const matchSummary = this.getMatchSummary(matchResult); + const actionsTaken = []; + + let recentSpamKey: string = null; + let recentSpam: RecentSpam = null; + let spamUserIdsToAction: string[] = []; + + if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + recentSpamKey = `${rule.name}-${matchResult.identifier}`; + recentSpam = this.recentSpam.get(recentSpamKey); + + if (matchResult.type === "textspam") { + spamUserIdsToAction = matchResult.recentActions.map(action => action.messageInfo.userId); + } else if (matchResult.type === "otherspam") { + spamUserIdsToAction = matchResult.recentActions.map(action => action.userId); + } + + spamUserIdsToAction = Array.from(new Set(spamUserIdsToAction)).filter(id => !recentSpam?.actionedUsers.has(id)); + } + + let archiveId = recentSpam?.archiveId; + if (matchResult.type === "textspam") { + const messageInfos = matchResult.recentActions.filter(unactioned).map(a => a.messageInfo); + if (messageInfos.length) { + const savedMessages = await this.savedMessages.getMultiple(messageInfos.map(info => info.messageId)); + + if (archiveId) { + await this.archives.addSavedMessagesToArchive(archiveId, savedMessages, this.guild); + } else { + archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); + } + } + } + + const matchSummary = this.getMatchSummary(matchResult, archiveId); let caseExtraNote = `Matched automod rule "${rule.name}"`; if (matchSummary) { caseExtraNote += `\n${matchSummary}`; } - const actionsTaken = []; - - // Actions if (rule.actions.clean) { - const messagesToDelete: Array<{ channelId: string; messageId: string }> = []; + const messagesToDelete: MessageInfo[] = []; if (matchResult.type === "message" || matchResult.type === "embed") { messagesToDelete.push(matchResult.messageInfo); - } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { - messagesToDelete.push(...matchResult.messageInfos); + } else if (matchResult.type === "textspam") { + messagesToDelete.push(...matchResult.recentActions.filter(unactioned).map(a => a.messageInfo)); } for (const { channelId, messageId } of messagesToDelete) { + await this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, messageId); await this.bot.deleteMessage(channelId, messageId).catch(noop); } @@ -1159,21 +952,22 @@ export class AutomodPlugin extends ZeppelinPlugin { extraNotes: [caseExtraNote], }; - if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { - const member = await this.getMember(matchResult.userId); - if (member) { - await this.getModActions().warnMember(member, reason, caseArgs); - } - } else if (matchResult.type === "raidspam") { - for (const userId of matchResult.userIds) { - const member = await this.getMember(userId); - if (member) { - await this.getModActions().warnMember(member, reason, caseArgs); - } + let membersToWarn = []; + if (matchResult.type === "message" || matchResult.type === "embed") { + membersToWarn = [await this.getMember(matchResult.userId)]; + } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + for (const id of spamUserIdsToAction) { + membersToWarn.push(await this.getMember(id)); } } - actionsTaken.push("warn"); + if (membersToWarn.length) { + for (const member of membersToWarn) { + await this.getModActions().warnMember(member, reason, caseArgs); + } + + actionsTaken.push("warn"); + } } if (rule.actions.mute) { @@ -1184,15 +978,20 @@ export class AutomodPlugin extends ZeppelinPlugin { extraNotes: [caseExtraNote], }; - if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { - await this.getMutes().muteUser(matchResult.userId, duration, reason, caseArgs); - } else if (matchResult.type === "raidspam") { - for (const userId of matchResult.userIds) { - await this.getMutes().muteUser(userId, duration, reason, caseArgs); - } + let userIdsToMute = []; + if (matchResult.type === "message" || matchResult.type === "embed") { + userIdsToMute = [matchResult.userId]; + } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + userIdsToMute.push(...spamUserIdsToAction); } - actionsTaken.push("mute"); + if (userIdsToMute.length) { + for (const member of userIdsToMute) { + await this.getMutes().muteUser(member.id, duration, reason, caseArgs); + } + + actionsTaken.push("mute"); + } } if (rule.actions.kick) { @@ -1202,21 +1001,22 @@ export class AutomodPlugin extends ZeppelinPlugin { extraNotes: [caseExtraNote], }; - if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { - const member = await this.getMember(matchResult.userId); - if (member) { - await this.getModActions().kickMember(member, reason, caseArgs); - } - } else if (matchResult.type === "raidspam") { - for (const userId of matchResult.userIds) { - const member = await this.getMember(userId); - if (member) { - await this.getModActions().kickMember(member, reason, caseArgs); - } + let membersToKick = []; + if (matchResult.type === "message" || matchResult.type === "embed") { + membersToKick = [await this.getMember(matchResult.userId)]; + } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + for (const id of spamUserIdsToAction) { + membersToKick.push(await this.getMember(id)); } } - actionsTaken.push("kick"); + if (membersToKick.length) { + for (const member of membersToKick) { + await this.getModActions().kickMember(member, reason, caseArgs); + } + + actionsTaken.push("kick"); + } } if (rule.actions.ban) { @@ -1226,19 +1026,27 @@ export class AutomodPlugin extends ZeppelinPlugin { extraNotes: [caseExtraNote], }; - if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { - await this.getModActions().banUserId(matchResult.userId, reason, caseArgs); - } else if (matchResult.type === "raidspam") { - for (const userId of matchResult.userIds) { - await this.getModActions().banUserId(userId, reason, caseArgs); - } + let userIdsToBan = []; + if (matchResult.type === "message" || matchResult.type === "embed") { + userIdsToBan = [matchResult.userId]; + } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + userIdsToBan.push(...spamUserIdsToAction); } - actionsTaken.push("ban"); + if (userIdsToBan.length) { + for (const userId of userIdsToBan) { + await this.getModActions().banUserId(userId, reason, caseArgs); + } + + actionsTaken.push("ban"); + } } if (rule.actions.change_nickname) { - const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId]; + const userIdsToChange = + matchResult.type === "textspam" || matchResult.type === "otherspam" + ? [...spamUserIdsToAction] + : [matchResult.userId]; for (const userId of userIdsToChange) { if (this.recentNicknameChanges.has(userId)) continue; @@ -1258,7 +1066,11 @@ export class AutomodPlugin extends ZeppelinPlugin { } if (rule.actions.add_roles) { - const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId]; + const userIdsToChange = + matchResult.type === "textspam" || matchResult.type === "otherspam" + ? [...spamUserIdsToAction] + : [matchResult.userId]; + for (const userId of userIdsToChange) { const member = await this.getMember(userId); if (!member) continue; @@ -1284,7 +1096,11 @@ export class AutomodPlugin extends ZeppelinPlugin { } if (rule.actions.remove_roles) { - const userIdsToChange = matchResult.type === "raidspam" ? matchResult.userIds : [matchResult.userId]; + const userIdsToChange = + matchResult.type === "textspam" || matchResult.type === "otherspam" + ? [...spamUserIdsToAction] + : [matchResult.userId]; + for (const userId of userIdsToChange) { const member = await this.getMember(userId); if (!member) continue; @@ -1309,12 +1125,46 @@ export class AutomodPlugin extends ZeppelinPlugin { actionsTaken.push("remove roles"); } + if (rule.actions.set_antiraid_level !== undefined) { + await this.setAntiraidLevel(rule.actions.set_antiraid_level); + actionsTaken.push("set antiraid level"); + } + + if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + for (const action of matchResult.recentActions) { + action.actioned = true; + } + + if (recentSpam) { + for (const id of spamUserIdsToAction) { + recentSpam.actionedUsers.add(id); + } + } else { + const newRecentSpamEntry: RecentSpam = { + actionedUsers: new Set(spamUserIdsToAction), + expiresAt: Date.now() + RECENT_SPAM_EXPIRY_TIME, + archiveId, + }; + this.recentSpam.set(recentSpamKey, newRecentSpamEntry); + } + } + // Don't wait for the rest before continuing to other automod items in the queue (async () => { - const user = matchResult.type !== "raidspam" ? this.getUser(matchResult.userId) : new UnknownUser(); - const users = matchResult.type === "raidspam" ? matchResult.userIds.map(id => this.getUser(id)) : []; - const safeUser = stripObjectToScalars(user); - const safeUsers = users.map(u => stripObjectToScalars(u)); + let user; + let users; + let safeUser; + let safeUsers; + + if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + users = spamUserIdsToAction.map(id => this.getUser(id)); + } else { + user = this.getUser(matchResult.userId); + users = [user]; + } + + safeUser = user ? stripObjectToScalars(user) : null; + safeUsers = users.map(u => stripObjectToScalars(u)); const logData = { rule: rule.name, @@ -1323,6 +1173,13 @@ export class AutomodPlugin extends ZeppelinPlugin { actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "", matchSummary, }; + + if (recentSpam && !spamUserIdsToAction.length) { + // This action was part of a recent spam match and we didn't find any new users to action i.e. the only users + // who triggered this match had already been actioned. In that case, we don't need to post any new log messages. + return; + } + const logMessage = this.getLogs().getLogMessage(LogType.AUTOMOD_ACTION, logData); if (rule.actions.alert) { @@ -1359,9 +1216,11 @@ export class AutomodPlugin extends ZeppelinPlugin { protected checkAndUpdateCooldown(rule: TRule, matchResult: AnyTriggerMatchResult): boolean { let cooldownKey = rule.name + "-"; - if (matchResult.type === "textspam") { - cooldownKey += matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId; - } else if (matchResult.type === "message" || matchResult.type === "embed") { + if (matchResult.type === "textspam" || matchResult.type === "otherspam") { + logger.warn("Spam cooldowns are WIP and not currently functional"); + } + + if (matchResult.type === "message" || matchResult.type === "embed") { cooldownKey += matchResult.userId; } else if ( matchResult.type === "username" || @@ -1370,8 +1229,6 @@ export class AutomodPlugin extends ZeppelinPlugin { matchResult.type === "customstatus" ) { cooldownKey += matchResult.userId; - } else if (matchResult.type === "otherspam") { - cooldownKey += matchResult.userId; } else { cooldownKey = null; } @@ -1393,7 +1250,7 @@ export class AutomodPlugin extends ZeppelinPlugin { /** * Returns a text summary for the match result for use in logs/alerts */ - protected async getMatchSummary(matchResult: AnyTriggerMatchResult): Promise { + protected async getMatchSummary(matchResult: AnyTriggerMatchResult, archiveId: string = null): Promise { if (matchResult.type === "message" || matchResult.type === "embed") { const message = await this.savedMessages.find(matchResult.messageInfo.messageId); const channel = this.guild.channels.get(matchResult.messageInfo.channelId); @@ -1403,9 +1260,7 @@ export class AutomodPlugin extends ZeppelinPlugin { Matched ${this.getMatchedValueText(matchResult)} in message in ${channelMention}: ${messageSummary(message)} `); - } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { - const savedMessages = await this.savedMessages.getMultiple(matchResult.messageInfos.map(i => i.messageId)); - const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); + } else if (matchResult.type === "textspam") { const baseUrl = this.knub.getGlobalConfig().url; const archiveUrl = this.archives.getUrl(baseUrl, archiveId); @@ -1420,7 +1275,11 @@ export class AutomodPlugin extends ZeppelinPlugin { return `Matched ${this.getMatchedValueText(matchResult)} in visible name: ${matchResult.str}`; } else if (matchResult.type === "customstatus") { return `Matched ${this.getMatchedValueText(matchResult)} in custom status: ${matchResult.str}`; + } else if (matchResult.type === "otherspam") { + return `Matched other spam`; } + + return ""; } /** @@ -1466,4 +1325,85 @@ export class AutomodPlugin extends ZeppelinPlugin { } }); } + + /** + * When a new member joins, check for both join triggers and join spam triggers + */ + @d.event("guildMemberAdd") + protected onMemberJoin(_, member: Member) { + if (member.user.bot) return; + + this.automodQueue.add(async () => { + if (this.unloaded) return; + + await this.addRecentAction({ + identifier: RAID_SPAM_IDENTIFIER, + type: RecentActionType.MemberJoin, + userId: member.id, + timestamp: Date.now(), + count: 1, + }); + + const config = this.getConfigForMember(member); + + for (const [name, rule] of Object.entries(config.rules)) { + const spamMatch = await this.matchOtherSpamInRule(rule, member.id); + if (spamMatch) { + await this.applyActionsOnMatch(rule, spamMatch); + } + + const joinMatch = await this.matchMemberJoinTriggerInRule(rule, member); + if (joinMatch) { + await this.applyActionsOnMatch(rule, joinMatch); + } + } + }); + } + + protected async setAntiraidLevel(level: string | null, user?: User) { + this.cachedAntiraidLevel = null; + await this.antiraidLevels.set(null); + + if (user) { + this.guildLogs.log(LogType.SET_ANTIRAID_USER, { + level: level ?? "off", + user: stripObjectToScalars(user), + }); + } else { + this.guildLogs.log(LogType.SET_ANTIRAID_AUTO, { + level: level ?? "off", + }); + } + } + + @d.command("antiraid clear", [], { + aliases: ["antiraid reset", "antiraid none", "antiraid off"], + }) + @d.permission("can_set_antiraid") + public async clearAntiraidCmd(msg: Message) { + await this.setAntiraidLevel(null, msg.author); + this.sendSuccessMessage(msg.channel, "Anti-raid turned off"); + } + + @d.command("antiraid", "") + @d.permission("can_set_antiraid") + public async setAntiraidCmd(msg: Message, args: { level: string }) { + if (!this.getConfig().antiraid_levels.includes(args.level)) { + this.sendErrorMessage(msg.channel, "Unknown anti-raid level"); + return; + } + + await this.setAntiraidLevel(args.level, msg.author); + this.sendSuccessMessage(msg.channel, `Anti-raid set to **${args.level}**`); + } + + @d.command("antiraid") + @d.permission("can_view_antiraid") + public async viewAntiraidCmd(msg: Message, args: { level: string }) { + if (this.cachedAntiraidLevel) { + msg.channel.createMessage(`Anti-raid is set to **${this.cachedAntiraidLevel}**`); + } else { + msg.channel.createMessage("Anti-raid is off!"); + } + } } diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts new file mode 100644 index 00000000..fde24740 --- /dev/null +++ b/backend/src/plugins/Automod/info.ts @@ -0,0 +1,101 @@ +import { PluginInfo, trimPluginDescription } from "../ZeppelinPlugin"; + +export const pluginInfo: PluginInfo = { + prettyName: "Automod", + description: trimPluginDescription(` + Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention. + `), + configurationGuide: trimPluginDescription(` + The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page. + + ### Simple word filter + Removes any messages that contain the word 'banana' and sends a warning to the user. + Moderators (level >= 50) are ignored by the filter based on the override. + + ~~~yml + automod: + config: + rules: + my_filter: + triggers: + - match_words: + words: ['banana'] + case_sensitive: false + only_full_words: true + actions: + clean: true + warn: + reason: 'Do not talk about bananas!' + overrides: + - level: '>=50' + config: + rules: + my_filter: + enabled: false + ~~~ + + ### Spam detection + This example includes 2 filters: + + - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds. + The messages are deleted and the user is muted for 5 minutes. + - The second filter is triggered if a user sends more than 2 emoji within 5 seconds. + The messages are deleted but the user is not muted. + + Moderators are ignored by both filters based on the override. + + ~~~yml + automod: + config: + rules: + my_spam_filter: + triggers: + - message_spam: + amount: 5 + within: 10s + - attachment_spam: + amount: 3 + within: 60s + actions: + clean: true + mute: + duration: 5m + reason: 'Auto-muted for spam' + my_second_filter: + triggers: + - message_spam: + amount: 5 + within: 10s + actions: + clean: true + overrides: + - level: '>=50' + config: + rules: + my_spam_filter: + enabled: false + my_second_filter: + enabled: false + ~~~ + + ### Custom status alerts + This example sends an alert any time a user with a matching custom status sends a message. + + ~~~yml + automod: + config: + rules: + bad_custom_statuses: + triggers: + - match_words: + words: ['banana'] + match_custom_status: true + actions: + alert: + channel: "473087035574321152" + text: |- + Bad custom status on user <@!{user.id}>: + {matchSummary} + ~~~ + `), +}; diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts new file mode 100644 index 00000000..848c8403 --- /dev/null +++ b/backend/src/plugins/Automod/types.ts @@ -0,0 +1,303 @@ +import * as t from "io-ts"; +import { TSafeRegex } from "../../validatorUtils"; +import { tDelayString, tNullable } from "../../utils"; + +export enum RecentActionType { + Message = 1, + Mention, + Link, + Attachment, + Emoji, + Line, + Character, + VoiceChannelMove, + MemberJoin, +} + +export interface BaseRecentAction { + identifier: string; + timestamp: number; + count: number; + actioned?: boolean; +} + +export type TextRecentAction = BaseRecentAction & { + type: + | RecentActionType.Message + | RecentActionType.Mention + | RecentActionType.Link + | RecentActionType.Attachment + | RecentActionType.Emoji + | RecentActionType.Line + | RecentActionType.Character; + messageInfo: MessageInfo; +}; + +export type OtherRecentAction = BaseRecentAction & { + type: RecentActionType.VoiceChannelMove | RecentActionType.MemberJoin; + userId: string; +}; + +export type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number }; + +export interface RecentSpam { + archiveId: string; + actionedUsers: Set; + expiresAt: number; +} + +export type MessageInfo = { channelId: string; messageId: string; userId: string }; + +export type TextTriggerWithMultipleMatchTypes = { + match_messages: boolean; + match_embeds: boolean; + match_visible_names: boolean; + match_usernames: boolean; + match_nicknames: boolean; + match_custom_status: boolean; +}; + +export interface TriggerMatchResult { + trigger: string; + type: string; +} + +export interface MessageTextTriggerMatchResult extends TriggerMatchResult { + type: "message" | "embed"; + str: string; + userId: string; + messageInfo: MessageInfo; + matchedValue: T; +} + +export interface OtherTextTriggerMatchResult extends TriggerMatchResult { + type: "username" | "nickname" | "visiblename" | "customstatus"; + str: string; + userId: string; + matchedValue: T; +} + +export type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; + +export interface TextSpamTriggerMatchResult extends TriggerMatchResult { + type: "textspam"; + actionType: RecentActionType; + recentActions: TextRecentAction[]; + + // Rule that specified the criteria used for matching the spam + rule: TRule; + + // The identifier used to match the recentActions above. + // If not null, this should match the identifier of each of the recentActions above. + identifier: string; +} + +export interface OtherSpamTriggerMatchResult extends TriggerMatchResult { + type: "otherspam"; + actionType: RecentActionType; + recentActions: OtherRecentAction[]; + + // Rule that specified the criteria used for matching the spam + rule: TRule; + + // The identifier used to match the recentActions above. + // If not null, this should match the identifier of each of the recentActions above. + identifier: string; +} + +export interface OtherTriggerMatchResult extends TriggerMatchResult { + type: "other"; + userId: string; +} + +export type AnyTriggerMatchResult = + | TextTriggerMatchResult + | OtherTriggerMatchResult + | TextSpamTriggerMatchResult + | OtherSpamTriggerMatchResult; + +export type AnySpamTriggerMatchResult = TextSpamTriggerMatchResult | OtherSpamTriggerMatchResult; + +/** + * TRIGGERS + */ + +export const MatchWordsTrigger = t.type({ + words: t.array(t.string), + case_sensitive: t.boolean, + only_full_words: t.boolean, + normalize: t.boolean, + loose_matching: t.boolean, + loose_matching_threshold: t.number, + match_messages: t.boolean, + match_embeds: t.boolean, + match_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, +}); +export type TMatchWordsTrigger = t.TypeOf; + +export const MatchRegexTrigger = t.type({ + patterns: t.array(TSafeRegex), + case_sensitive: t.boolean, + normalize: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, +}); +export type TMatchRegexTrigger = t.TypeOf; + +export const MatchInvitesTrigger = t.type({ + include_guilds: tNullable(t.array(t.string)), + exclude_guilds: tNullable(t.array(t.string)), + include_invite_codes: tNullable(t.array(t.string)), + exclude_invite_codes: tNullable(t.array(t.string)), + allow_group_dm_invites: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, +}); +export type TMatchInvitesTrigger = t.TypeOf; + +export const MatchLinksTrigger = t.type({ + include_domains: tNullable(t.array(t.string)), + exclude_domains: tNullable(t.array(t.string)), + include_subdomains: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, +}); +export type TMatchLinksTrigger = t.TypeOf; + +export const BaseSpamTrigger = t.type({ + amount: t.number, + within: t.string, +}); +export type TBaseSpamTrigger = t.TypeOf; + +export const BaseTextSpamTrigger = t.intersection([ + BaseSpamTrigger, + t.type({ + per_channel: t.boolean, + }), +]); +export type TBaseTextSpamTrigger = t.TypeOf; + +export const MessageSpamTrigger = BaseTextSpamTrigger; +export type TMessageSpamTrigger = t.TypeOf; +export const MentionSpamTrigger = BaseTextSpamTrigger; +export type TMentionSpamTrigger = t.TypeOf; +export const LinkSpamTrigger = BaseTextSpamTrigger; +export type TLinkSpamTrigger = t.TypeOf; +export const AttachmentSpamTrigger = BaseTextSpamTrigger; +export type TAttachmentSpamTrigger = t.TypeOf; +export const EmojiSpamTrigger = BaseTextSpamTrigger; +export type TEmojiSpamTrigger = t.TypeOf; +export const LineSpamTrigger = BaseTextSpamTrigger; +export type TLineSpamTrigger = t.TypeOf; +export const CharacterSpamTrigger = BaseTextSpamTrigger; +export type TCharacterSpamTrigger = t.TypeOf; +export const VoiceMoveSpamTrigger = BaseSpamTrigger; +export type TVoiceMoveSpamTrigger = t.TypeOf; + +export const MemberJoinTrigger = t.type({ + only_new: t.boolean, + new_threshold: tDelayString, +}); +export type TMemberJoinTrigger = t.TypeOf; + +export const MemberJoinSpamTrigger = BaseSpamTrigger; +export type TMemberJoinSpamTrigger = t.TypeOf; + +/** + * ACTIONS + */ + +export const CleanAction = t.boolean; + +export const WarnAction = t.type({ + reason: t.string, +}); + +export const MuteAction = t.type({ + duration: t.string, + reason: tNullable(t.string), +}); + +export const KickAction = t.type({ + reason: tNullable(t.string), +}); + +export const BanAction = t.type({ + reason: tNullable(t.string), +}); + +export const AlertAction = t.type({ + channel: t.string, + text: t.string, +}); + +export const ChangeNicknameAction = t.type({ + name: t.string, +}); + +export const LogAction = t.boolean; + +export const AddRolesAction = t.array(t.string); +export const RemoveRolesAction = t.array(t.string); + +export const SetAntiraidLevelAction = t.string; + +/** + * RULES + */ + +export const Rule = t.type({ + enabled: t.boolean, + name: t.string, + presets: tNullable(t.array(t.string)), + triggers: t.array( + t.type({ + match_words: tNullable(MatchWordsTrigger), + match_regex: tNullable(MatchRegexTrigger), + match_invites: tNullable(MatchInvitesTrigger), + match_links: tNullable(MatchLinksTrigger), + message_spam: tNullable(MessageSpamTrigger), + mention_spam: tNullable(MentionSpamTrigger), + link_spam: tNullable(LinkSpamTrigger), + attachment_spam: tNullable(AttachmentSpamTrigger), + emoji_spam: tNullable(EmojiSpamTrigger), + line_spam: tNullable(LineSpamTrigger), + character_spam: tNullable(CharacterSpamTrigger), + member_join: tNullable(MemberJoinTrigger), + member_join_spam: tNullable(MemberJoinSpamTrigger), + // voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO + // TODO: Duplicates trigger + }), + ), + actions: t.type({ + clean: tNullable(CleanAction), + warn: tNullable(WarnAction), + mute: tNullable(MuteAction), + kick: tNullable(KickAction), + ban: tNullable(BanAction), + alert: tNullable(AlertAction), + change_nickname: tNullable(ChangeNicknameAction), + log: tNullable(LogAction), + add_roles: tNullable(AddRolesAction), + remove_roles: tNullable(RemoveRolesAction), + set_antiraid_level: tNullable(SetAntiraidLevelAction), + }), + cooldown: tNullable(t.string), +}); +export type TRule = t.TypeOf; diff --git a/backend/src/plugins/Logs.ts b/backend/src/plugins/Logs.ts index a3f7cf75..0199bb40 100644 --- a/backend/src/plugins/Logs.ts +++ b/backend/src/plugins/Logs.ts @@ -198,23 +198,30 @@ export class LogsPlugin extends ZeppelinPlugin { try { const values = { ...data, - userMention: async userOrMember => { - if (!userOrMember) return ""; + userMention: async inputUserOrMember => { + if (!inputUserOrMember) return ""; - let user; - let member; + const usersOrMembers = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember]; - if (userOrMember.user) { - member = userOrMember; - user = member.user; - } else { - user = userOrMember; - member = this.guild.members.get(user.id) || { id: user.id, user }; + const mentions = []; + for (const userOrMember of usersOrMembers) { + let user; + let member; + + if (userOrMember.user) { + member = userOrMember; + user = member.user; + } else { + user = userOrMember; + member = this.guild.members.get(user.id) || { id: user.id, user }; + } + + const memberConfig = this.getMatchingConfig({ member, userId: user.id }) || ({} as any); + + mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); } - const memberConfig = this.getMatchingConfig({ member, userId: user.id }) || ({} as any); - - return memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user); + return mentions.join(", "); }, channelMention: channel => { if (!channel) return ""; diff --git a/backend/src/plugins/Stats.ts b/backend/src/plugins/Stats.ts index 63c12856..e3b2a559 100644 --- a/backend/src/plugins/Stats.ts +++ b/backend/src/plugins/Stats.ts @@ -9,6 +9,8 @@ import escapeStringRegexp from "escape-string-regexp"; import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; +//region TYPES + const tBaseSource = t.type({ name: tAlphanumeric, track: t.boolean, @@ -50,8 +52,14 @@ const tConfigSchema = t.type({ type TConfigSchema = t.TypeOf; const tPartialConfigSchema = tDeepPartial(tConfigSchema); +//endregion +//region CONSTANTS + const DEFAULT_RETENTION_PERIOD = "4w"; +//endregion +//region PLUGIN + export class StatsPlugin extends ZeppelinPlugin { public static pluginName = "stats"; public static configSchema = tConfigSchema; @@ -71,20 +79,21 @@ export class StatsPlugin extends ZeppelinPlugin { }; } - protected static preprocessStaticConfig(config: t.TypeOf) { - // TODO: Limit min period, min period start date + protected static applyDefaultsToSource(source: Partial) { + if (source.track == null) { + source.track = true; + } + if (source.retention_period == null) { + source.retention_period = DEFAULT_RETENTION_PERIOD; + } + } + + protected static preprocessStaticConfig(config: t.TypeOf) { if (config.sources) { for (const [key, source] of Object.entries(config.sources)) { source.name = key; - - if (source.track == null) { - source.track = true; - } - - if (source.retention_period == null) { - source.retention_period = DEFAULT_RETENTION_PERIOD; - } + this.applyDefaultsToSource(source); } } diff --git a/backend/src/plugins/ZeppelinPlugin.ts b/backend/src/plugins/ZeppelinPlugin.ts index 2273f724..2fffcd27 100644 --- a/backend/src/plugins/ZeppelinPlugin.ts +++ b/backend/src/plugins/ZeppelinPlugin.ts @@ -62,7 +62,10 @@ export function trimPluginDescription(str) { const inviteCache = new SimpleCache>(10 * MINUTES, 200); -export class ZeppelinPlugin extends Plugin { +export class ZeppelinPlugin< + TConfig extends {} = IBasePluginConfig, + TCustomOverrideCriteria extends {} = {} +> extends Plugin { public static pluginInfo: PluginInfo; public static showInDocs: boolean = true; @@ -98,8 +101,11 @@ export class ZeppelinPlugin extends Plug /** * Wrapper to fetch the real default options from getStaticDefaultOptions() */ - protected getDefaultOptions(): IPluginOptions { - return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions; + protected getDefaultOptions(): IPluginOptions { + return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions< + TConfig, + TCustomOverrideCriteria + >; } /** @@ -165,14 +171,14 @@ export class ZeppelinPlugin extends Plug /** * Wrapper that calls mergeAndValidateStaticOptions() */ - protected getMergedOptions(): IPluginOptions { + protected getMergedOptions(): IPluginOptions { if (!this.mergedPluginOptions) { this.mergedPluginOptions = ((this.constructor as unknown) as typeof ZeppelinPlugin).mergeAndDecodeStaticOptions( this.pluginOptions, ); } - return this.mergedPluginOptions as IPluginOptions; + return this.mergedPluginOptions as IPluginOptions; } /** diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index fdc535d9..9826307e 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -26,7 +26,7 @@ import { CompanionChannelPlugin } from "./CompanionChannels"; import { LocatePlugin } from "./LocateUser"; import { GuildConfigReloader } from "./GuildConfigReloader"; import { ChannelArchiverPlugin } from "./ChannelArchiver"; -import { AutomodPlugin } from "./Automod"; +import { AutomodPlugin } from "./Automod/Automod"; import { RolesPlugin } from "./Roles"; import { AutoDeletePlugin } from "./AutoDelete";