diff --git a/backend/src/Queue.ts b/backend/src/Queue.ts index cb8179bd..38ab2560 100644 --- a/backend/src/Queue.ts +++ b/backend/src/Queue.ts @@ -41,4 +41,8 @@ export class Queue { setTimeout(resolve, this.timeout); }).then(() => this.next()); } + + public clear() { + this.queue.splice(0, this.queue.length); + } } diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 6e776961..86fbba80 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -3,7 +3,7 @@ */ import { Member } from "eris"; -import { configUtils, helpers, PluginData, PluginOptions } from "knub"; +import { configUtils, helpers, PluginBlueprint, PluginData, PluginOptions } from "knub"; import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils"; import { deepKeyIntersect, errorMessage, successMessage } from "./utils"; import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint"; @@ -27,8 +27,15 @@ export function hasPermission(pluginData: PluginData, permission: string, m return helpers.hasPermission(config, permission); } -export function getPluginConfigPreprocessor(blueprint: ZeppelinPluginBlueprint) { - return (options: PluginOptions) => { +export function getPluginConfigPreprocessor( + blueprint: ZeppelinPluginBlueprint, + customPreprocessor?: PluginBlueprint["configPreprocessor"], +) { + return async (options: PluginOptions) => { + if (customPreprocessor) { + options = await customPreprocessor(options); + } + const decodedConfig = blueprint.configSchema ? decodeAndValidateStrict(blueprint.configSchema, options.config) : options.config; diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts new file mode 100644 index 00000000..9c5ce269 --- /dev/null +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -0,0 +1,148 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { AutomodPluginType, ConfigSchema } from "./types"; +import { RunAutomodOnJoinEvt } from "./events/RunAutomodOnJoinEvt"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { runAutomodOnMessage } from "./events/runAutomodOnMessage"; +import { Queue } from "../../Queue"; +import { configUtils } from "knub"; +import { availableTriggers } from "./triggers/availableTriggers"; +import { StrictValidationError } from "../../validatorUtils"; +import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; +import { availableActions } from "./actions/availableActions"; +import { clearOldRecentActions } from "./functions/clearOldRecentActions"; +import { MINUTES, SECONDS } from "../../utils"; +import { clearOldRecentSpam } from "./functions/clearOldRecentSpam"; +import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; +import { GuildArchives } from "../../data/GuildArchives"; + +const defaultOptions = { + 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, + }, + }, + ], +}; + +/** + * Config preprocessor to set default values for triggers + */ +const configPreprocessor: ConfigPreprocessorFn = options => { + if (options.config?.rules) { + // Loop through each rule + for (const [name, rule] of Object.entries(options.config.rules)) { + rule["name"] = name; + + // If the rule doesn't have an explicitly set "enabled" property, set it to true + if (rule["enabled"] == null) { + rule["enabled"] = true; + } + + if (rule["affects_bots"] == null) { + rule["affects_bots"] = false; + } + + // Loop through the rule's triggers + if (rule["triggers"]) { + for (const triggerObj of rule["triggers"]) { + for (const triggerName in triggerObj) { + if (!availableTriggers[triggerName]) { + throw new StrictValidationError([`Unknown trigger '${triggerName}' in rule '${rule.name}'`]); + } + + const triggerBlueprint = availableTriggers[triggerName]; + triggerObj[triggerName] = configUtils.mergeConfig(triggerBlueprint.defaultConfig, triggerObj[triggerName]); + + if (triggerObj[triggerName].match_attachment_type) { + const white = triggerObj[triggerName].match_attachment_type.whitelist_enabled; + const black = triggerObj[triggerName].match_attachment_type.blacklist_enabled; + + if (white && black) { + throw new StrictValidationError([ + `Cannot have both blacklist and whitelist enabled at rule <${rule.name}/match_attachment_type>`, + ]); + } else if (!white && !black) { + throw new StrictValidationError([ + `Must have either blacklist or whitelist enabled at rule <${rule.name}/match_attachment_type>`, + ]); + } + } + } + } + } + + // Enable logging of automod actions by default + if (rule["actions"]) { + for (const actionName in rule.actions) { + if (!availableActions[actionName]) { + throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule.name}'`]); + } + } + + // if (rule["actions"]["log"] == null) { + // rule["actions"]["log"] = true; + // } + } + } + } + + return options; +}; + +export const AutomodPlugin = zeppelinPlugin()("automod", { + configSchema: ConfigSchema, + defaultOptions, + configPreprocessor, + + events: [ + RunAutomodOnJoinEvt, + // Messages use message events from SavedMessages, see onLoad below + ], + + onLoad(pluginData) { + pluginData.state.queue = new Queue(); + + pluginData.state.recentActions = []; + pluginData.state.clearRecentActionsInterval = setInterval(() => clearOldRecentActions(pluginData), 1 * MINUTES); + + pluginData.state.recentSpam = []; + pluginData.state.clearRecentSpamInterval = setInterval(() => clearOldRecentSpam(pluginData), 1 * SECONDS); + + pluginData.state.logs = new GuildLogs(pluginData.guild.id); + pluginData.state.savedMessages = GuildSavedMessages.getGuildInstance(pluginData.guild.id); + pluginData.state.antiraidLevels = GuildAntiraidLevels.getGuildInstance(pluginData.guild.id); + pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); + + pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false); + pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn); + + pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true); + pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn); + }, + + onUnload(pluginData) { + pluginData.state.queue.clear(); + + clearInterval(pluginData.state.clearRecentActionsInterval); + + clearInterval(pluginData.state.clearRecentSpamInterval); + + pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn); + pluginData.state.savedMessages.events.off("update", pluginData.state.onMessageUpdateFn); + }, +}); diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts new file mode 100644 index 00000000..24ec1542 --- /dev/null +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -0,0 +1,11 @@ +import * as t from "io-ts"; +import { CleanAction } from "./clean"; +import { AutomodActionBlueprint } from "../helpers"; + +export const availableActions: Record> = { + clean: CleanAction, +}; + +export const AvailableActions = t.type({ + clean: CleanAction.configType, +}); diff --git a/backend/src/plugins/Automod/actions/clean.ts b/backend/src/plugins/Automod/actions/clean.ts new file mode 100644 index 00000000..67fbf0b1 --- /dev/null +++ b/backend/src/plugins/Automod/actions/clean.ts @@ -0,0 +1,28 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; + +export const CleanAction = automodAction({ + configType: t.boolean, + + async apply({ pluginData, contexts }) { + const messageIdsToDeleteByChannelId: Map = new Map(); + for (const context of contexts) { + if (context.message) { + if (!messageIdsToDeleteByChannelId.has(context.message.channel_id)) { + messageIdsToDeleteByChannelId.set(context.message.channel_id, []); + } + + messageIdsToDeleteByChannelId.get(context.message.channel_id).push(context.message.id); + } + } + + for (const [channelId, messageIds] of messageIdsToDeleteByChannelId.entries()) { + for (const id of messageIds) { + pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id); + } + + await pluginData.client.deleteMessages(channelId, messageIds); + } + }, +}); diff --git a/backend/src/plugins/Automod/constants.ts b/backend/src/plugins/Automod/constants.ts new file mode 100644 index 00000000..3ebc278c --- /dev/null +++ b/backend/src/plugins/Automod/constants.ts @@ -0,0 +1,16 @@ +import { MINUTES, SECONDS } from "../../utils"; + +export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS; +export const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES; + +export enum RecentActionType { + Message = 1, + Mention, + Link, + Attachment, + Emoji, + Line, + Character, + VoiceChannelMove, + MemberJoin, +} diff --git a/backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts b/backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts new file mode 100644 index 00000000..83786f12 --- /dev/null +++ b/backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts @@ -0,0 +1,16 @@ +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import { eventListener, PluginData } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; + +export const RunAutomodOnJoinEvt = eventListener()( + "guildMemberAdd", + ({ pluginData, args: { member } }) => { + const context: AutomodContext = { + timestamp: Date.now(), + user: member.user, + }; + + pluginData.state.queue.add(() => runAutomod(pluginData, context)); + }, +); diff --git a/backend/src/plugins/Automod/events/runAutomodOnMessage.ts b/backend/src/plugins/Automod/events/runAutomodOnMessage.ts new file mode 100644 index 00000000..d90872b4 --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnMessage.ts @@ -0,0 +1,18 @@ +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import { PluginData } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; +import { addRecentActionsFromMessage } from "../functions/addRecentActionsFromMessage"; +import moment from "moment-timezone"; + +export function runAutomodOnMessage(pluginData: PluginData, message: SavedMessage, isEdit: boolean) { + const context: AutomodContext = { + timestamp: moment.utc(message.posted_at).valueOf(), + message, + }; + + pluginData.state.queue.add(async () => { + addRecentActionsFromMessage(pluginData, context); + await runAutomod(pluginData, context); + }); +} diff --git a/backend/src/plugins/Automod/functions/addRecentActionsFromMessage.ts b/backend/src/plugins/Automod/functions/addRecentActionsFromMessage.ts new file mode 100644 index 00000000..ad88d00d --- /dev/null +++ b/backend/src/plugins/Automod/functions/addRecentActionsFromMessage.ts @@ -0,0 +1,130 @@ +import moment from "moment-timezone"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { PluginData } from "knub"; +import { RECENT_ACTION_EXPIRY_TIME, RecentActionType } from "../constants"; +import { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from "../../../utils"; + +export function addRecentActionsFromMessage(pluginData: PluginData, context: AutomodContext) { + const globalIdentifier = context.message.user_id; + const perChannelIdentifier = `${context.message.channel_id}-${context.message.user_id}`; + const expiresAt = Date.now() + RECENT_ACTION_EXPIRY_TIME; + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Message, + identifier: globalIdentifier, + count: 1, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Message, + identifier: perChannelIdentifier, + count: 1, + }); + + const mentionCount = + getUserMentions(context.message.data.content || "").length + + getRoleMentions(context.message.data.content || "").length; + if (mentionCount) { + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Mention, + identifier: globalIdentifier, + count: mentionCount, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Mention, + identifier: perChannelIdentifier, + count: mentionCount, + }); + } + + const linkCount = getUrlsInString(context.message.data.content || "").length; + if (linkCount) { + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Link, + identifier: globalIdentifier, + count: linkCount, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Link, + identifier: perChannelIdentifier, + count: linkCount, + }); + } + + const attachmentCount = context.message.data.attachments && context.message.data.attachments.length; + if (attachmentCount) { + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Attachment, + identifier: globalIdentifier, + count: attachmentCount, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Attachment, + identifier: perChannelIdentifier, + count: attachmentCount, + }); + } + + const emojiCount = getEmojiInString(context.message.data.content || "").length; + if (emojiCount) { + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Emoji, + identifier: globalIdentifier, + count: emojiCount, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Emoji, + identifier: perChannelIdentifier, + count: emojiCount, + }); + } + + // + 1 is for the first line of the message (which doesn't have a line break) + const lineCount = context.message.data.content ? (context.message.data.content.match(/\n/g) || []).length + 1 : 0; + if (lineCount) { + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Line, + identifier: globalIdentifier, + count: lineCount, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Line, + identifier: perChannelIdentifier, + count: lineCount, + }); + } + + const characterCount = [...(context.message.data.content || "")].length; + if (characterCount) { + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Character, + identifier: globalIdentifier, + count: characterCount, + }); + + pluginData.state.recentActions.push({ + context, + type: RecentActionType.Character, + identifier: perChannelIdentifier, + count: characterCount, + }); + } +} diff --git a/backend/src/plugins/Automod/functions/clearOldRecentActions.ts b/backend/src/plugins/Automod/functions/clearOldRecentActions.ts new file mode 100644 index 00000000..7f933be9 --- /dev/null +++ b/backend/src/plugins/Automod/functions/clearOldRecentActions.ts @@ -0,0 +1,10 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { RECENT_ACTION_EXPIRY_TIME } from "../constants"; + +export function clearOldRecentActions(pluginData: PluginData) { + const now = Date.now(); + pluginData.state.recentActions = pluginData.state.recentActions.filter(info => { + return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now; + }); +} diff --git a/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts b/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts new file mode 100644 index 00000000..a05c699d --- /dev/null +++ b/backend/src/plugins/Automod/functions/clearOldRecentSpam.ts @@ -0,0 +1,10 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { RECENT_SPAM_EXPIRY_TIME } from "../constants"; + +export function clearOldRecentSpam(pluginData: PluginData) { + const now = Date.now(); + pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => { + return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now; + }); +} diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts new file mode 100644 index 00000000..7c5ca32e --- /dev/null +++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts @@ -0,0 +1,84 @@ +import { RecentActionType } from "../constants"; +import { automodTrigger } from "../helpers"; +import { getBaseUrl } from "../../../pluginUtils"; +import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils"; +import { humanizeDurationShort } from "../../../humanizeDurationShort"; +import { findRecentSpam } from "./findRecentSpam"; +import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions"; +import * as t from "io-ts"; + +const MessageSpamTriggerConfig = t.type({ + amount: t.number, + within: tDelayString, + per_channel: tNullable(t.boolean), +}); +type TMessageSpamTriggerConfig = t.TypeOf; + +const MessageSpamMatchResultType = t.type({ + archiveId: t.string, +}); +type TMessageSpamMatchResultType = t.TypeOf; + +export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) { + return automodTrigger({ + configType: MessageSpamTriggerConfig, + defaultConfig: {}, + + matchResultType: MessageSpamMatchResultType, + + async match({ pluginData, context, triggerConfig }) { + if (!context.message) { + return; + } + + const recentSpam = findRecentSpam(pluginData, spamType, context.message.user_id); + if (recentSpam) { + // TODO: Combine with old archive + return { + silentClean: true, + }; + } + + const within = convertDelayStringToMS(triggerConfig.within); + const matchedSpam = getMatchingMessageRecentActions( + pluginData, + context.message, + spamType, + triggerConfig.amount, + within, + triggerConfig.per_channel, + ); + + if (matchedSpam) { + // TODO: Generate archive link + const archiveId = "TODO"; + + pluginData.state.recentSpam.push({ + type: spamType, + userId: context.message.user_id, + archiveId, + timestamp: Date.now(), + }); + + return { + extraContexts: matchedSpam.recentActions + .map(action => action.context) + .filter(_context => _context !== context), + + extra: { + archiveId, + }, + }; + } + }, + + renderMatchInformation({ pluginData, matchResult, triggerConfig }) { + const baseUrl = getBaseUrl(pluginData); + const archiveUrl = pluginData.state.archives.getUrl(baseUrl, matchResult.extra.archiveId); + const withinMs = convertDelayStringToMS(triggerConfig.within); + const withinStr = humanizeDurationShort(withinMs); + + return `Matched ${prettyName} spam (${triggerConfig.amount} in ${withinStr}): ${archiveUrl}`; + }, + }); +} diff --git a/backend/src/plugins/Automod/functions/findRecentSpam.ts b/backend/src/plugins/Automod/functions/findRecentSpam.ts new file mode 100644 index 00000000..a22b91b5 --- /dev/null +++ b/backend/src/plugins/Automod/functions/findRecentSpam.ts @@ -0,0 +1,9 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { RecentActionType } from "../constants"; + +export function findRecentSpam(pluginData: PluginData, type: RecentActionType, userId: string) { + return pluginData.state.recentSpam.find(spam => { + return spam.type === type && spam.userId === userId; + }); +} diff --git a/backend/src/plugins/Automod/functions/getMatchingMessageRecentActions.ts b/backend/src/plugins/Automod/functions/getMatchingMessageRecentActions.ts new file mode 100644 index 00000000..410aeaf2 --- /dev/null +++ b/backend/src/plugins/Automod/functions/getMatchingMessageRecentActions.ts @@ -0,0 +1,28 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import moment from "moment-timezone"; +import { getMatchingRecentActions } from "./getMatchingRecentActions"; +import { RecentActionType } from "../constants"; + +export function getMatchingMessageRecentActions( + pluginData: PluginData, + message: SavedMessage, + type: RecentActionType, + count: number, + within: number, + perChannel: boolean, +) { + const since = moment.utc(message.posted_at).valueOf() - within; + const to = moment.utc(message.posted_at).valueOf(); + const identifier = perChannel ? `${message.channel_id}-${message.user_id}` : message.user_id; + const recentActions = getMatchingRecentActions(pluginData, type, identifier, since, to); + const totalCount = recentActions.reduce((total, action) => total + action.count, 0); + + if (totalCount >= count) { + return { + identifier, + recentActions, + }; + } +} diff --git a/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts b/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts new file mode 100644 index 00000000..9197e24d --- /dev/null +++ b/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts @@ -0,0 +1,20 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { RecentActionType } from "../constants"; + +export function getMatchingRecentActions( + pluginData: PluginData, + type: RecentActionType, + identifier: string | null, + since: number, + to: number, +) { + return pluginData.state.recentActions.filter(action => { + return ( + action.type === type && + (!identifier || action.identifier === identifier) && + action.context.timestamp >= since && + action.context.timestamp <= to + ); + }); +} diff --git a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts new file mode 100644 index 00000000..176bfa23 --- /dev/null +++ b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts @@ -0,0 +1,68 @@ +import * as t from "io-ts"; +import { SavedMessage } from "../../../data/entities/SavedMessage"; +import { resolveMember } from "../../../utils"; +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; + +type TextTriggerWithMultipleMatchTypes = { + match_messages: boolean; + match_embeds: boolean; + match_visible_names: boolean; + match_usernames: boolean; + match_nicknames: boolean; + match_custom_status: boolean; +}; + +export const MatchableTextType = t.union([ + t.literal("message"), + t.literal("embed"), + t.literal("visiblename"), + t.literal("username"), + t.literal("nickname"), + t.literal("customstatus"), +]); + +export type TMatchableTextType = t.TypeOf; + +type YieldedContent = [TMatchableTextType, string]; + +/** + * Generator function that allows iterating through matchable pieces of text of a SavedMessage + */ +export async function* matchMultipleTextTypesOnMessage( + pluginData: PluginData, + trigger: TextTriggerWithMultipleMatchTypes, + msg: SavedMessage, +): AsyncIterableIterator { + const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); + if (!member) return; + + if (trigger.match_messages && msg.data.content) { + yield ["message", msg.data.content]; + } + + if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) { + const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0])); + if (copiedEmbed.type === "video") { + copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched + } + yield ["embed", JSON.stringify(copiedEmbed)]; + } + + if (trigger.match_visible_names) { + yield ["visiblename", member.nick || msg.data.author.username]; + } + + if (trigger.match_usernames) { + yield ["username", `${msg.data.author.username}#${msg.data.author.discriminator}`]; + } + + if (trigger.match_nicknames && member.nick) { + yield ["nickname", member.nick]; + } + + // type 4 = custom status + if (trigger.match_custom_status && member.game?.type === 4 && member.game?.state) { + yield ["customstatus", member.game.state]; + } +} diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts new file mode 100644 index 00000000..f4951160 --- /dev/null +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -0,0 +1,83 @@ +import { PluginData } from "knub"; +import { AutomodContext, AutomodPluginType, TRule } from "../types"; +import { availableTriggers } from "../triggers/availableTriggers"; +import { availableActions } from "../actions/availableActions"; +import { AutomodTriggerMatchResult } from "../helpers"; +import { CleanAction } from "../actions/clean"; + +export async function runAutomod(pluginData: PluginData, context: AutomodContext) { + const userId = context.user?.id || context.message?.user_id; + const member = userId && pluginData.guild.members.get(userId); + const channelId = context.message?.channel_id; + const channel = channelId && pluginData.guild.channels.get(channelId); + const categoryId = channel?.parentID; + + const config = pluginData.config.getMatchingConfig({ + channelId, + categoryId, + userId, + member, + }); + + for (const [ruleName, rule] of Object.entries(config.rules)) { + if (rule.enabled === false) continue; + + let matchResult: AutomodTriggerMatchResult; + let matchSummary: string; + let contexts: AutomodContext[]; + + triggerLoop: for (const triggerItem of rule.triggers) { + for (const [triggerName, triggerConfig] of Object.entries(triggerItem)) { + const trigger = availableTriggers[triggerName]; + matchResult = await trigger.match({ + ruleName, + pluginData, + context, + triggerConfig, + }); + + if (matchResult) { + contexts = [context, ...(matchResult.extraContexts || [])]; + + for (const _context of contexts) { + _context.actioned = true; + } + + if (matchResult.silentClean) { + await CleanAction.apply({ + ruleName, + pluginData, + contexts, + actionConfig: true, + }); + return; + } + + matchSummary = await trigger.renderMatchInformation({ + ruleName, + pluginData, + contexts, + matchResult, + triggerConfig, + }); + + break triggerLoop; + } + } + } + + if (matchResult) { + for (const [actionName, actionConfig] of Object.entries(rule.actions)) { + const action = availableActions[actionName]; + action.apply({ + ruleName, + pluginData, + contexts, + actionConfig, + }); + } + + break; + } + } +} diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts new file mode 100644 index 00000000..b2c020d5 --- /dev/null +++ b/backend/src/plugins/Automod/helpers.ts @@ -0,0 +1,60 @@ +import { PluginData } from "knub"; +import { Awaitable } from "knub/dist/utils"; +import * as t from "io-ts"; +import { AutomodContext, AutomodPluginType } from "./types"; + +export interface AutomodTriggerMatchResult { + extraContexts?: AutomodContext[]; + extra?: TExtra; + + silentClean?: boolean; +} + +type AutomodTriggerMatchFn = (meta: { + ruleName: string; + pluginData: PluginData; + context: AutomodContext; + triggerConfig: TConfigType; +}) => Awaitable>; + +type AutomodTriggerRenderMatchInformationFn = (meta: { + ruleName: string; + pluginData: PluginData; + contexts: AutomodContext[]; + triggerConfig: TConfigType; + matchResult: AutomodTriggerMatchResult; +}) => Awaitable; + +export interface AutomodTriggerBlueprint { + configType: TConfigType; + defaultConfig: Partial>; + + matchResultType: TMatchResultExtra; + + match: AutomodTriggerMatchFn, t.TypeOf>; + renderMatchInformation: AutomodTriggerRenderMatchInformationFn, t.TypeOf>; +} + +export function automodTrigger( + blueprint: AutomodTriggerBlueprint, +): AutomodTriggerBlueprint { + return blueprint; +} + +type AutomodActionApplyFn = (meta: { + ruleName: string; + pluginData: PluginData; + contexts: AutomodContext[]; + actionConfig: TConfigType; +}) => Awaitable; + +export interface AutomodActionBlueprint { + configType: TConfigType; + apply: AutomodActionApplyFn>; +} + +export function automodAction( + blueprint: AutomodActionBlueprint, +): AutomodActionBlueprint { + return blueprint; +} diff --git a/backend/src/plugins/Automod/triggers/attachmentSpam.ts b/backend/src/plugins/Automod/triggers/attachmentSpam.ts new file mode 100644 index 00000000..79c46d84 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/attachmentSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const AttachmentSpamTrigger = createMessageSpamTrigger(RecentActionType.Attachment, "attachment"); diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts new file mode 100644 index 00000000..31c2ef83 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -0,0 +1,37 @@ +import * as t from "io-ts"; +import { MatchWordsTrigger } from "./matchWords"; +import { AutomodTriggerBlueprint } from "../helpers"; +import { MessageSpamTrigger } from "./messageSpam"; +import { MentionSpamTrigger } from "./mentionSpam"; +import { LinkSpamTrigger } from "./linkSpam"; +import { AttachmentSpamTrigger } from "./attachmentSpam"; +import { EmojiSpamTrigger } from "./emojiSpam"; +import { LineSpamTrigger } from "./lineSpam"; +import { CharacterSpamTrigger } from "./characterSpam"; +import { MatchRegexTrigger } from "./matchRegex"; + +export const availableTriggers: Record> = { + match_words: MatchWordsTrigger, + match_regex: MatchRegexTrigger, + + message_spam: MessageSpamTrigger, + mention_spam: MentionSpamTrigger, + link_spam: LinkSpamTrigger, + attachment_spam: AttachmentSpamTrigger, + emoji_spam: EmojiSpamTrigger, + line_spam: LineSpamTrigger, + character_spam: CharacterSpamTrigger, +}; + +export const AvailableTriggers = t.type({ + match_words: MatchWordsTrigger.configType, + match_regex: MatchRegexTrigger.configType, + + message_spam: MessageSpamTrigger.configType, + mention_spam: MentionSpamTrigger.configType, + link_spam: LinkSpamTrigger.configType, + attachment_spam: AttachmentSpamTrigger.configType, + emoji_spam: EmojiSpamTrigger.configType, + line_spam: LineSpamTrigger.configType, + character_spam: CharacterSpamTrigger.configType, +}); diff --git a/backend/src/plugins/Automod/triggers/characterSpam.ts b/backend/src/plugins/Automod/triggers/characterSpam.ts new file mode 100644 index 00000000..5412ffb2 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/characterSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const CharacterSpamTrigger = createMessageSpamTrigger(RecentActionType.Character, "character"); diff --git a/backend/src/plugins/Automod/triggers/emojiSpam.ts b/backend/src/plugins/Automod/triggers/emojiSpam.ts new file mode 100644 index 00000000..018cb891 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/emojiSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const EmojiSpamTrigger = createMessageSpamTrigger(RecentActionType.Emoji, "emoji"); diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts new file mode 100644 index 00000000..56770f72 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts @@ -0,0 +1,27 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +export const ExampleTrigger = automodTrigger({ + configType: t.type({ + some: t.number, + value: t.string, + }), + + defaultConfig: {}, + + matchResultType: t.type({ + thing: t.string, + }), + + async match() { + return { + extra: { + thing: "hi", + }, + }; + }, + + renderMatchInformation() { + return ""; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/lineSpam.ts b/backend/src/plugins/Automod/triggers/lineSpam.ts new file mode 100644 index 00000000..2a54f1b4 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/lineSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const LineSpamTrigger = createMessageSpamTrigger(RecentActionType.Line, "line"); diff --git a/backend/src/plugins/Automod/triggers/linkSpam.ts b/backend/src/plugins/Automod/triggers/linkSpam.ts new file mode 100644 index 00000000..0278d8d5 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/linkSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const LinkSpamTrigger = createMessageSpamTrigger(RecentActionType.Link, "link"); diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts new file mode 100644 index 00000000..20dc7f60 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchRegex.ts @@ -0,0 +1,72 @@ +import * as t from "io-ts"; +import { transliterate } from "transliteration"; +import escapeStringRegexp from "escape-string-regexp"; +import { automodTrigger } from "../helpers"; +import { disableInlineCode, verboseChannelMention } from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; + +export const MatchRegexTrigger = automodTrigger({ + configType: t.type({ + patterns: t.array(t.string), + 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, + }), + + defaultConfig: { + case_sensitive: false, + normalize: false, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, + }, + + matchResultType: t.type({ + pattern: t.string, + type: MatchableTextType, + }), + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { + if (trigger.normalize) { + str = transliterate(str); + } + + for (const pattern of trigger.patterns) { + const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + const test = regex.test(str); + if (test) { + return { + extra: { + pattern, + type, + }, + }; + } + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + return `Matched regex \`${disableInlineCode(matchResult.extra.pattern)}\` in message (\`${ + contexts[0].message.id + }\`) in ${prettyChannel}:`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts new file mode 100644 index 00000000..97390544 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchWords.ts @@ -0,0 +1,90 @@ +import * as t from "io-ts"; +import { transliterate } from "transliteration"; +import escapeStringRegexp from "escape-string-regexp"; +import { automodTrigger } from "../helpers"; +import { disableInlineCode, verboseChannelMention } from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; + +export const MatchWordsTrigger = automodTrigger({ + configType: 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, + }), + + defaultConfig: { + case_sensitive: false, + only_full_words: true, + normalize: false, + loose_matching: false, + loose_matching_threshold: 4, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, + }, + + matchResultType: t.type({ + word: t.string, + type: MatchableTextType, + }), + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { + if (trigger.normalize) { + str = transliterate(str); + } + + const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64); + + for (const word of trigger.words) { + // When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other + // characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a + let pattern = trigger.loose_matching + ? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`) + : escapeStringRegexp(word); + + if (trigger.only_full_words) { + pattern = `\\b${pattern}\\b`; + } + + const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + const test = regex.test(str); + if (test) { + return { + extra: { + word, + type, + }, + }; + } + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + return `Matched word \`${disableInlineCode(matchResult.extra.word)}\` in message (\`${ + contexts[0].message.id + }\`) in ${prettyChannel}:`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/mentionSpam.ts b/backend/src/plugins/Automod/triggers/mentionSpam.ts new file mode 100644 index 00000000..fdcd8f46 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/mentionSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const MentionSpamTrigger = createMessageSpamTrigger(RecentActionType.Mention, "mention"); diff --git a/backend/src/plugins/Automod/triggers/messageSpam.ts b/backend/src/plugins/Automod/triggers/messageSpam.ts new file mode 100644 index 00000000..91b4f63b --- /dev/null +++ b/backend/src/plugins/Automod/triggers/messageSpam.ts @@ -0,0 +1,4 @@ +import { RecentActionType } from "../constants"; +import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger"; + +export const MessageSpamTrigger = createMessageSpamTrigger(RecentActionType.Message, "message"); diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts new file mode 100644 index 00000000..e8b51ad2 --- /dev/null +++ b/backend/src/plugins/Automod/types.ts @@ -0,0 +1,88 @@ +import * as t from "io-ts"; +import { tNullable, UnknownUser } from "../../utils"; +import { BasePluginType } from "knub"; +import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { GuildLogs } from "../../data/GuildLogs"; +import { SavedMessage } from "../../data/entities/SavedMessage"; +import { User } from "eris"; +import { AvailableTriggers } from "./triggers/availableTriggers"; +import { AvailableActions } from "./actions/availableActions"; +import { Queue } from "../../Queue"; +import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; +import { GuildArchives } from "../../data/GuildArchives"; +import { RecentActionType } from "./constants"; +import Timeout = NodeJS.Timeout; + +export const Rule = t.type({ + enabled: t.boolean, + name: t.string, + presets: tNullable(t.array(t.string)), + affects_bots: t.boolean, + triggers: t.array(t.partial(AvailableTriggers.props)), + actions: t.partial(AvailableActions.props), + cooldown: tNullable(t.string), +}); +export type TRule = t.TypeOf; + +export 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, +}); +export type TConfigSchema = t.TypeOf; + +export interface AutomodPluginType extends BasePluginType { + config: TConfigSchema; + state: { + /** + * Automod checks/actions are handled in a queue so we don't get overlap on the same user + */ + queue: Queue; + + /** + * Recent actions are used for spam triggers + */ + recentActions: RecentAction[]; + clearRecentActionsInterval: Timeout; + + /** + * 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 + */ + recentSpam: RecentSpam[]; + clearRecentSpamInterval: Timeout; + + savedMessages: GuildSavedMessages; + logs: GuildLogs; + antiraidLevels: GuildAntiraidLevels; + archives: GuildArchives; + + onMessageCreateFn: any; + onMessageUpdateFn: any; + }; +} + +export interface AutomodContext { + timestamp: number; + actioned?: boolean; + + user?: User | UnknownUser; + message?: SavedMessage; +} + +export interface RecentAction { + type: RecentActionType; + identifier: string; + count: number; + context: AutomodContext; +} + +export interface RecentSpam { + archiveId: string; + type: RecentActionType; + userId: string; + timestamp: number; +} diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts index 5e05b313..671ada10 100644 --- a/backend/src/plugins/ZeppelinPluginBlueprint.ts +++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts @@ -26,12 +26,12 @@ export function zeppelinPlugin(): < >( name: string, blueprint: TPartialBlueprint, -) => TPartialBlueprint & { name: string }; +) => TPartialBlueprint & { name: string; configPreprocessor: PluginBlueprint["configPreprocessor"] }; export function zeppelinPlugin(...args) { if (args.length) { const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters)); - blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint); + blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint, blueprint.configPreprocessor); return blueprint; } else { return zeppelinPlugin; diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 6416517e..ea38e264 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 { AutomodPlugin } from "./Automod/AutomodPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -47,6 +48,7 @@ export const guildPlugins: Array> = [ WelcomeMessagePlugin, CasesPlugin, MutesPlugin, + AutomodPlugin, ]; // prettier-ignore