diff --git a/backend/src/plugins/Spam/SpamPlugin.ts b/backend/src/plugins/Spam/SpamPlugin.ts new file mode 100644 index 00000000..36133078 --- /dev/null +++ b/backend/src/plugins/Spam/SpamPlugin.ts @@ -0,0 +1,74 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, SpamPluginType } from "./types"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildArchives } from "src/data/GuildArchives"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildMutes } from "src/data/GuildMutes"; +import { onMessageCreate } from "./util/onMessageCreate"; +import { clearOldRecentActions } from "./util/clearOldRecentActions"; +import { SpamVoiceJoinEvt, SpamVoiceSwitchEvt } from "./events/SpamVoiceEvt"; + +const defaultOptions: PluginOptions = { + config: { + max_censor: null, + max_messages: null, + max_mentions: null, + max_links: null, + max_attachments: null, + max_emojis: null, + max_newlines: null, + max_duplicates: null, + max_characters: null, + max_voice_moves: null, + }, + overrides: [ + { + level: ">=50", + config: { + max_messages: null, + max_mentions: null, + max_links: null, + max_attachments: null, + max_emojis: null, + max_newlines: null, + max_duplicates: null, + max_characters: null, + max_voice_moves: null, + }, + }, + ], +}; + +export const SpamPlugin = zeppelinPlugin()("spam", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + events: [ + SpamVoiceJoinEvt, + SpamVoiceSwitchEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.logs = new GuildLogs(guild.id); + state.archives = GuildArchives.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.mutes = GuildMutes.getGuildInstance(guild.id); + + state.recentActions = []; + state.expiryInterval = setInterval(() => clearOldRecentActions(pluginData), 1000 * 60); + state.lastHandledMsgIds = new Map(); + + state.spamDetectionQueue = Promise.resolve(); + + state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.savedMessages.events.on("create", state.onMessageCreateFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn); + }, +}); diff --git a/backend/src/plugins/Spam/events/SpamVoiceEvt.ts b/backend/src/plugins/Spam/events/SpamVoiceEvt.ts new file mode 100644 index 00000000..207bcbf5 --- /dev/null +++ b/backend/src/plugins/Spam/events/SpamVoiceEvt.ts @@ -0,0 +1,52 @@ +import { spamEvent, RecentActionType } from "../types"; +import { logAndDetectOtherSpam } from "../util/logAndDetectOtherSpam"; + +export const SpamVoiceJoinEvt = spamEvent({ + event: "voiceChannelJoin", + + async listener(meta) { + const member = meta.args.member; + const channel = meta.args.newChannel; + + const config = meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id }); + const maxVoiceMoves = config.max_voice_moves; + if (maxVoiceMoves) { + logAndDetectOtherSpam( + meta.pluginData, + RecentActionType.VoiceChannelMove, + maxVoiceMoves, + member.id, + 1, + "0", + Date.now(), + null, + "too many voice channel moves", + ); + } + }, +}); + +export const SpamVoiceSwitchEvt = spamEvent({ + event: "voiceChannelSwitch", + + async listener(meta) { + const member = meta.args.member; + const channel = meta.args.newChannel; + + const config = meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id }); + const maxVoiceMoves = config.max_voice_moves; + if (maxVoiceMoves) { + logAndDetectOtherSpam( + meta.pluginData, + RecentActionType.VoiceChannelMove, + maxVoiceMoves, + member.id, + 1, + "0", + Date.now(), + null, + "too many voice channel moves", + ); + } + }, +}); diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts new file mode 100644 index 00000000..67964e35 --- /dev/null +++ b/backend/src/plugins/Spam/types.ts @@ -0,0 +1,78 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener } from "knub"; +import { tNullable } from "src/utils"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildArchives } from "src/data/GuildArchives"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildMutes } from "src/data/GuildMutes"; + +const BaseSingleSpamConfig = t.type({ + interval: t.number, + count: t.number, + mute: tNullable(t.boolean), + mute_time: tNullable(t.number), + clean: tNullable(t.boolean), +}); +export type TBaseSingleSpamConfig = t.TypeOf; + +export const ConfigSchema = t.type({ + max_censor: tNullable(BaseSingleSpamConfig), + max_messages: tNullable(BaseSingleSpamConfig), + max_mentions: tNullable(BaseSingleSpamConfig), + max_links: tNullable(BaseSingleSpamConfig), + max_attachments: tNullable(BaseSingleSpamConfig), + max_emojis: tNullable(BaseSingleSpamConfig), + max_newlines: tNullable(BaseSingleSpamConfig), + max_duplicates: tNullable(BaseSingleSpamConfig), + max_characters: tNullable(BaseSingleSpamConfig), + max_voice_moves: tNullable(BaseSingleSpamConfig), +}); +export type TConfigSchema = t.TypeOf; + +export enum RecentActionType { + Message = 1, + Mention, + Link, + Attachment, + Emoji, + Newline, + Censor, + Character, + VoiceChannelMove, +} + +interface IRecentAction { + type: RecentActionType; + userId: string; + actionGroupId: string; + extraData: T; + timestamp: number; + count: number; +} + +export interface SpamPluginType extends BasePluginType { + config: TConfigSchema; + state: { + logs: GuildLogs; + archives: GuildArchives; + savedMessages: GuildSavedMessages; + mutes: GuildMutes; + + onMessageCreateFn; + + // Handle spam detection with a queue so we don't have overlapping detections on the same user + spamDetectionQueue: Promise; + + // List of recent potentially-spammy actions + recentActions: Array>; + + // A map of userId => channelId => msgId + // Keeps track of the last handled (= spam detected and acted on) message ID per user, per channel + // TODO: Prevent this from growing infinitely somehow + lastHandledMsgIds: Map>; + + expiryInterval; + }; +} + +export const spamEvent = eventListener(); diff --git a/backend/src/plugins/Spam/util/addRecentAction.ts b/backend/src/plugins/Spam/util/addRecentAction.ts new file mode 100644 index 00000000..d545e9ce --- /dev/null +++ b/backend/src/plugins/Spam/util/addRecentAction.ts @@ -0,0 +1,14 @@ +import { PluginData } from "knub"; +import { SpamPluginType, RecentActionType } from "../types"; + +export function addRecentAction( + pluginData: PluginData, + type: RecentActionType, + userId: string, + actionGroupId: string, + extraData: any, + timestamp: number, + count = 1, +) { + pluginData.state.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count }); +} diff --git a/backend/src/plugins/Spam/util/clearOldRecentActions.ts b/backend/src/plugins/Spam/util/clearOldRecentActions.ts new file mode 100644 index 00000000..6e3a1d24 --- /dev/null +++ b/backend/src/plugins/Spam/util/clearOldRecentActions.ts @@ -0,0 +1,7 @@ +const MAX_INTERVAL = 300; + +export function clearOldRecentActions(pluginData) { + // TODO: Figure out expiry time from longest interval in the config? + const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL; + pluginData.state.recentActions = pluginData.state.recentActions.filter(action => action.timestamp >= expiryTimestamp); +} diff --git a/backend/src/plugins/Spam/util/clearRecentUserActions.ts b/backend/src/plugins/Spam/util/clearRecentUserActions.ts new file mode 100644 index 00000000..f52730cf --- /dev/null +++ b/backend/src/plugins/Spam/util/clearRecentUserActions.ts @@ -0,0 +1,7 @@ +import { RecentActionType } from "../types"; + +export function clearRecentUserActions(pluginData, type: RecentActionType, userId: string, actionGroupId: string) { + pluginData.state.recentActions = pluginData.state.recentActions.filter(action => { + return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId; + }); +} diff --git a/backend/src/plugins/Spam/util/getRecentActionCount.ts b/backend/src/plugins/Spam/util/getRecentActionCount.ts new file mode 100644 index 00000000..776a0ab5 --- /dev/null +++ b/backend/src/plugins/Spam/util/getRecentActionCount.ts @@ -0,0 +1,17 @@ +import { RecentActionType } from "../types"; + +export function getRecentActionCount( + pluginData, + type: RecentActionType, + userId: string, + actionGroupId: string, + since: number, +) { + return pluginData.state.recentActions.reduce((count, action) => { + if (action.timestamp < since) return count; + if (action.type !== type) return count; + if (action.actionGroupId !== actionGroupId) return count; + if (action.userId !== userId) return false; + return count + action.count; + }, 0); +} diff --git a/backend/src/plugins/Spam/util/getRecentActions.ts b/backend/src/plugins/Spam/util/getRecentActions.ts new file mode 100644 index 00000000..bc74d016 --- /dev/null +++ b/backend/src/plugins/Spam/util/getRecentActions.ts @@ -0,0 +1,17 @@ +import { RecentActionType } from "../types"; + +export function getRecentActions( + pluginData, + type: RecentActionType, + userId: string, + actionGroupId: string, + since: number, +) { + return pluginData.state.recentActions.filter(action => { + if (action.timestamp < since) return false; + if (action.type !== type) return false; + if (action.actionGroupId !== actionGroupId) return false; + if (action.userId !== userId) return false; + return true; + }); +} diff --git a/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts new file mode 100644 index 00000000..79f03f99 --- /dev/null +++ b/backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts @@ -0,0 +1,167 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { RecentActionType, TBaseSingleSpamConfig, SpamPluginType } from "../types"; +import moment from "moment-timezone"; +import { MuteResult } from "src/plugins/Mutes/types"; +import { convertDelayStringToMS, trimLines, stripObjectToScalars, resolveMember, noop } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { CaseTypes } from "src/data/CaseTypes"; +import { logger } from "src/logger"; +import { PluginData } from "knub"; +import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; +import { addRecentAction } from "./addRecentAction"; +import { getRecentActionCount } from "./getRecentActionCount"; +import { getRecentActions } from "./getRecentActions"; +import { clearRecentUserActions } from "./clearRecentUserActions"; +import { saveSpamArchives } from "./saveSpamArchives"; + +export async function logAndDetectMessageSpam( + pluginData: PluginData, + savedMessage: SavedMessage, + type: RecentActionType, + spamConfig: TBaseSingleSpamConfig, + actionCount: number, + description: string, +) { + if (actionCount === 0) return; + + // Make sure we're not handling some messages twice + if (pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) { + const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id); + if (channelMap.has(savedMessage.channel_id)) { + const lastHandledMsgId = channelMap.get(savedMessage.channel_id); + if (lastHandledMsgId >= savedMessage.id) return; + } + } + + pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then( + async () => { + const timestamp = moment(savedMessage.posted_at).valueOf(); + const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id); + + // Log this action... + addRecentAction( + pluginData, + type, + savedMessage.user_id, + savedMessage.channel_id, + savedMessage, + timestamp, + actionCount, + ); + + // ...and then check if it trips the spam filters + const since = timestamp - 1000 * spamConfig.interval; + const recentActionsCount = getRecentActionCount( + pluginData, + type, + savedMessage.user_id, + savedMessage.channel_id, + since, + ); + + // If the user tripped the spam filter... + if (recentActionsCount > spamConfig.count) { + const recentActions = getRecentActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id, since); + + // Start by muting them, if enabled + let muteResult: MuteResult; + if (spamConfig.mute && member) { + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000; + muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { + caseArgs: { + modId: pluginData.client.user.id, + postInCaseLogOverride: false, + }, + }); + } + + // Get the offending message IDs + // We also get the IDs of any messages after the last offending message, to account for lag before detection + const savedMessages = recentActions.map(a => a.extraData as SavedMessage); + const msgIds = savedMessages.map(m => m.id); + const lastDetectedMsgId = msgIds[msgIds.length - 1]; + + const additionalMessages = await pluginData.state.savedMessages.getUserMessagesByChannelAfterId( + savedMessage.user_id, + savedMessage.channel_id, + lastDetectedMsgId, + ); + additionalMessages.forEach(m => msgIds.push(m.id)); + + // Then, if enabled, remove the spam messages + if (spamConfig.clean !== false) { + msgIds.forEach(id => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); + pluginData.client.deleteMessages(savedMessage.channel_id, msgIds).catch(noop); + } + + // Store the ID of the last handled message + const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages])); + uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1)); + const lastHandledMsgId = uniqueMessages.reduce((last: string, m: SavedMessage): string => { + return !last || m.id > last ? m.id : last; + }, null); + + if (!pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) { + pluginData.state.lastHandledMsgIds.set(savedMessage.user_id, new Map()); + } + + const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id); + channelMap.set(savedMessage.channel_id, lastHandledMsgId); + + // Clear the handled actions from recentActions + clearRecentUserActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id); + + // Generate a log from the detected messages + const channel = pluginData.guild.channels.get(savedMessage.channel_id); + const archiveUrl = await saveSpamArchives(pluginData, uniqueMessages); + + // Create a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + if (muteResult) { + // If the user was muted, the mute already generated a case - in that case, just update the case with extra details + // This will also post the case in the case log channel, which we didn't do with the mute initially to avoid + // posting the case on the channel twice: once with the initial reason, and then again with the note from here + const updateText = trimLines(` + Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) + ${archiveUrl} + `); + casesPlugin.createCaseNote({ + caseId: muteResult.case.id, + modId: muteResult.case.mod_id, + body: updateText, + automatic: true, + }); + } else { + // If the user was not muted, create a note case of the detected spam instead + const caseText = trimLines(` + Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) + ${archiveUrl} + `); + + casesPlugin.createCase({ + userId: savedMessage.user_id, + modId: pluginData.client.user.id, + type: CaseTypes.Note, + reason: caseText, + automatic: true, + }); + } + + // Create a log entry + pluginData.state.logs.log(LogType.MESSAGE_SPAM_DETECTED, { + member: stripObjectToScalars(member, ["user", "roles"]), + channel: stripObjectToScalars(channel), + description, + limit: spamConfig.count, + interval: spamConfig.interval, + archiveUrl, + }); + } + }, + err => { + logger.error(`Error while detecting spam:\n${err}`); + }, + ); +} diff --git a/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts new file mode 100644 index 00000000..7fc55d54 --- /dev/null +++ b/backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts @@ -0,0 +1,66 @@ +import { PluginData } from "knub"; +import { SpamPluginType, RecentActionType } from "../types"; +import { addRecentAction } from "./addRecentAction"; +import { getRecentActionCount } from "./getRecentActionCount"; +import { resolveMember, convertDelayStringToMS, stripObjectToScalars } from "src/utils"; +import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin"; +import { CasesPlugin } from "src/plugins/Cases/CasesPlugin"; +import { CaseTypes } from "src/data/CaseTypes"; +import { clearRecentUserActions } from "./clearRecentUserActions"; +import { LogType } from "src/data/LogType"; + +export async function logAndDetectOtherSpam( + pluginData: PluginData, + type: RecentActionType, + spamConfig: any, + userId: string, + actionCount: number, + actionGroupId: string, + timestamp: number, + extraData = null, + description: string, +) { + pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(async () => { + // Log this action... + addRecentAction(pluginData, type, userId, actionGroupId, extraData, timestamp, actionCount); + + // ...and then check if it trips the spam filters + const since = timestamp - 1000 * spamConfig.interval; + const recentActionsCount = getRecentActionCount(pluginData, type, userId, actionGroupId, since); + + if (recentActionsCount > spamConfig.count) { + const member = await resolveMember(pluginData.client, pluginData.guild, userId); + const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`; + + if (spamConfig.mute && member) { + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000; + await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { + caseArgs: { + modId: pluginData.client.user.id, + extraNotes: [`Details: ${details}`], + }, + }); + } else { + // If we're not muting the user, just add a note on them + const casesPlugin = pluginData.getPlugin(CasesPlugin); + await casesPlugin.createCase({ + userId, + modId: pluginData.client.user.id, + type: CaseTypes.Note, + reason: `Automatic spam detection: ${details}`, + }); + } + + // Clear recent cases + clearRecentUserActions(pluginData, RecentActionType.VoiceChannelMove, userId, actionGroupId); + + pluginData.state.logs.log(LogType.OTHER_SPAM_DETECTED, { + member: stripObjectToScalars(member, ["user", "roles"]), + description, + limit: spamConfig.count, + interval: spamConfig.interval, + }); + } + }); +} diff --git a/backend/src/plugins/Spam/util/logCensor.ts b/backend/src/plugins/Spam/util/logCensor.ts new file mode 100644 index 00000000..b069d9a3 --- /dev/null +++ b/backend/src/plugins/Spam/util/logCensor.ts @@ -0,0 +1,23 @@ +import { PluginData } from "knub"; +import { SpamPluginType, RecentActionType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam"; + +export async function logCensor(pluginData: PluginData, savedMessage: SavedMessage) { + const config = pluginData.config.getMatchingConfig({ + userId: savedMessage.user_id, + channelId: savedMessage.channel_id, + }); + const spamConfig = config.max_censor; + + if (spamConfig) { + logAndDetectMessageSpam( + pluginData, + savedMessage, + RecentActionType.Censor, + spamConfig, + 1, + "too many censored messages", + ); + } +} diff --git a/backend/src/plugins/Spam/util/onMessageCreate.ts b/backend/src/plugins/Spam/util/onMessageCreate.ts new file mode 100644 index 00000000..d1f4aecc --- /dev/null +++ b/backend/src/plugins/Spam/util/onMessageCreate.ts @@ -0,0 +1,86 @@ +import { PluginData } from "knub"; +import { SpamPluginType, RecentActionType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { getUserMentions, getRoleMentions, getUrlsInString, getEmojiInString } from "src/utils"; +import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam"; + +export async function onMessageCreate(pluginData: PluginData, savedMessage: SavedMessage) { + if (savedMessage.is_bot) return; + + const config = pluginData.config.getMatchingConfig({ + userId: savedMessage.user_id, + channelId: savedMessage.channel_id, + }); + + const maxMessages = config.max_messages; + if (maxMessages) { + logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Message, maxMessages, 1, "too many messages"); + } + + const maxMentions = config.max_mentions; + const mentions = savedMessage.data.content + ? [...getUserMentions(savedMessage.data.content), ...getRoleMentions(savedMessage.data.content)] + : []; + if (maxMentions && mentions.length) { + logAndDetectMessageSpam( + pluginData, + savedMessage, + RecentActionType.Mention, + maxMentions, + mentions.length, + "too many mentions", + ); + } + + const maxLinks = config.max_links; + if (maxLinks && savedMessage.data.content && typeof savedMessage.data.content === "string") { + const links = getUrlsInString(savedMessage.data.content); + logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Link, maxLinks, links.length, "too many links"); + } + + const maxAttachments = config.max_attachments; + if (maxAttachments && savedMessage.data.attachments) { + logAndDetectMessageSpam( + pluginData, + savedMessage, + RecentActionType.Attachment, + maxAttachments, + savedMessage.data.attachments.length, + "too many attachments", + ); + } + + const maxEmojis = config.max_emojis; + if (maxEmojis && savedMessage.data.content) { + const emojiCount = getEmojiInString(savedMessage.data.content).length; + logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Emoji, maxEmojis, emojiCount, "too many emoji"); + } + + const maxNewlines = config.max_newlines; + if (maxNewlines && savedMessage.data.content) { + const newlineCount = (savedMessage.data.content.match(/\n/g) || []).length; + logAndDetectMessageSpam( + pluginData, + savedMessage, + RecentActionType.Newline, + maxNewlines, + newlineCount, + "too many newlines", + ); + } + + const maxCharacters = config.max_characters; + if (maxCharacters && savedMessage.data.content) { + const characterCount = [...savedMessage.data.content.trim()].length; + logAndDetectMessageSpam( + pluginData, + savedMessage, + RecentActionType.Character, + maxCharacters, + characterCount, + "too many characters", + ); + } + + // TODO: Max duplicates check +} diff --git a/backend/src/plugins/Spam/util/saveSpamArchives.ts b/backend/src/plugins/Spam/util/saveSpamArchives.ts new file mode 100644 index 00000000..bc84e8bd --- /dev/null +++ b/backend/src/plugins/Spam/util/saveSpamArchives.ts @@ -0,0 +1,12 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import moment from "moment-timezone"; +import { getBaseUrl } from "src/pluginUtils"; + +const SPAM_ARCHIVE_EXPIRY_DAYS = 90; + +export async function saveSpamArchives(pluginData, savedMessages: SavedMessage[]) { + const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days"); + const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild, expiresAt); + + return pluginData.state.archives.getUrl(getBaseUrl, archiveId); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index b918a7e1..54b2cb26 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -22,6 +22,7 @@ import { RolesPlugin } from "./Roles/RolesPlugin"; import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; import { StarboardPlugin } from "./Starboard/StarboardPlugin"; import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; +import { SpamPlugin } from "./Spam/SpamPlugin"; import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; import { AutomodPlugin } from "./Automod/AutomodPlugin"; @@ -43,6 +44,7 @@ export const guildPlugins: Array> = [ RemindersPlugin, RolesPlugin, SlowmodePlugin, + SpamPlugin, StarboardPlugin, TagsPlugin, UsernameSaverPlugin,