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..3edffc37 --- /dev/null +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -0,0 +1,159 @@ +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"; +import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChanges"; + +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.recentNicknameChanges = new Map(); + pluginData.state.clearRecentNicknameChangesInterval = setInterval( + () => clearOldRecentNicknameChanges(pluginData), + 30 * SECONDS, + ); + + pluginData.state.cachedAntiraidLevel = null; // TODO + + 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); + + clearInterval(pluginData.state.clearRecentNicknameChangesInterval); + + 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/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts new file mode 100644 index 00000000..47b55c2c --- /dev/null +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -0,0 +1,35 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, resolveMember, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; + +export const AddRolesAction = automodAction({ + configType: t.array(t.string), + + async apply({ pluginData, contexts, actionConfig }) { + const members = contexts.map(c => c.member).filter(Boolean); + const uniqueMembers = new Set(members); + + await Promise.all( + Array.from(uniqueMembers.values()).map(async member => { + const memberRoles = new Set(member.roles); + for (const roleId of actionConfig) { + memberRoles.add(roleId); + } + + if (memberRoles.size === member.roles.length) { + // No role changes + return; + } + + const rolesArr = Array.from(memberRoles.values()); + await member.edit({ + roles: rolesArr, + }); + member.roles = rolesArr; // Make sure we know of the new roles internally as well + }), + ); + }, +}); diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts new file mode 100644 index 00000000..a14a2843 --- /dev/null +++ b/backend/src/plugins/Automod/actions/alert.ts @@ -0,0 +1,48 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, messageLink, resolveMember, stripObjectToScalars, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; +import { TextChannel } from "eris"; +import { renderTemplate } from "../../../templateFormatter"; + +export const AlertAction = automodAction({ + configType: t.type({ + channel: t.string, + text: t.string, + }), + + async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) { + const channel = pluginData.guild.channels.get(actionConfig.channel); + + if (channel && channel instanceof TextChannel) { + const text = actionConfig.text; + const theMessageLink = + contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id); + + const safeUsers = contexts.map(c => c.user && stripObjectToScalars(c.user)).filter(Boolean); + const safeUser = safeUsers[0]; + + const takenActions = Object.keys(pluginData.config.get().rules[ruleName].actions); + // TODO: Generate logMessage + const logMessage = ""; + + const rendered = await renderTemplate(actionConfig.text, { + rule: ruleName, + user: safeUser, + users: safeUsers, + text, + matchSummary: matchResult.summary, + messageLink: theMessageLink, + logMessage, + }); + channel.createMessage(rendered); + } else { + // TODO: Post BOT_ALERT log + /*this.getLogs().log(LogType.BOT_ALERT, { + body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${rule.name}**`, + });*/ + } + }, +}); 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/ban.ts b/backend/src/plugins/Automod/actions/ban.ts new file mode 100644 index 00000000..8f433023 --- /dev/null +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -0,0 +1,35 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, resolveMember, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; + +export const BanAction = automodAction({ + configType: t.type({ + reason: tNullable(t.string), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), + deleteMessageDays: tNullable(t.number), + }), + + async apply({ pluginData, contexts, actionConfig }) { + const reason = actionConfig.reason || "Kicked automatically"; + const contactMethods = resolveActionContactMethods(pluginData, actionConfig); + const deleteMessageDays = actionConfig.deleteMessageDays; + + const caseArgs = { + modId: pluginData.client.user.id, + extraNotes: [ + /* TODO */ + ], + }; + + const userIdsToBan = contexts.map(c => c.user?.id).filter(Boolean); + + const modActions = pluginData.getPlugin(ModActionsPlugin); + for (const userId of userIdsToBan) { + await modActions.banUserId(userId, reason, { contactMethods, caseArgs, deleteMessageDays }); + } + }, +}); diff --git a/backend/src/plugins/Automod/actions/changeNickname.ts b/backend/src/plugins/Automod/actions/changeNickname.ts new file mode 100644 index 00000000..061bf98a --- /dev/null +++ b/backend/src/plugins/Automod/actions/changeNickname.ts @@ -0,0 +1,27 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, resolveMember, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; + +export const ChangeNicknameAction = automodAction({ + configType: t.type({ + name: t.string, + }), + + async apply({ pluginData, contexts, actionConfig }) { + const members = contexts.map(c => c.member).filter(Boolean); + const uniqueMembers = new Set(members); + + for (const member of uniqueMembers) { + if (pluginData.state.recentNicknameChanges.has(member.id)) continue; + + member.edit({ nick: actionConfig.name }).catch(err => { + /* TODO: Log this error */ + }); + + pluginData.state.recentNicknameChanges.set(member.id, { timestamp: Date.now() }); + } + }, +}); 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/actions/exampleAction.ts b/backend/src/plugins/Automod/actions/exampleAction.ts new file mode 100644 index 00000000..ba5c0e27 --- /dev/null +++ b/backend/src/plugins/Automod/actions/exampleAction.ts @@ -0,0 +1,12 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; + +export const ExampleAction = automodAction({ + configType: t.type({ + someValue: t.string, + }), + + async apply({ pluginData, contexts, actionConfig }) { + // TODO: Everything + }, +}); diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts new file mode 100644 index 00000000..09cb832f --- /dev/null +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -0,0 +1,34 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, resolveMember, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; + +export const KickAction = automodAction({ + configType: t.type({ + reason: tNullable(t.string), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), + }), + + async apply({ pluginData, contexts, actionConfig }) { + const reason = actionConfig.reason || "Kicked automatically"; + const contactMethods = resolveActionContactMethods(pluginData, actionConfig); + + const caseArgs = { + modId: pluginData.client.user.id, + extraNotes: [ + /* TODO */ + ], + }; + + const userIdsToKick = contexts.map(c => c.user?.id).filter(Boolean); + const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id)); + + const modActions = pluginData.getPlugin(ModActionsPlugin); + for (const member of membersToKick) { + await modActions.kickMember(member, reason, { contactMethods, caseArgs }); + } + }, +}); diff --git a/backend/src/plugins/Automod/actions/log.ts b/backend/src/plugins/Automod/actions/log.ts new file mode 100644 index 00000000..a2038412 --- /dev/null +++ b/backend/src/plugins/Automod/actions/log.ts @@ -0,0 +1,10 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; + +export const LogAction = automodAction({ + configType: t.boolean, + + async apply({ pluginData, contexts, actionConfig }) { + // TODO: Everything + }, +}); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts new file mode 100644 index 00000000..bf67a269 --- /dev/null +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -0,0 +1,36 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, convertDelayStringToMS, resolveMember, tDelayString, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; +import { MutesPlugin } from "../../Mutes/MutesPlugin"; + +export const MuteAction = automodAction({ + configType: t.type({ + reason: tNullable(t.string), + duration: tNullable(tDelayString), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), + }), + + async apply({ pluginData, contexts, actionConfig }) { + const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration) : null; + const reason = actionConfig.reason || "Muted automatically"; + const contactMethods = resolveActionContactMethods(pluginData, actionConfig); + + const caseArgs = { + modId: pluginData.client.user.id, + extraNotes: [ + /* TODO */ + ], + }; + + const userIdsToMute = contexts.map(c => c.user?.id).filter(Boolean); + + const mutes = pluginData.getPlugin(MutesPlugin); + for (const userId of userIdsToMute) { + await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }); + } + }, +}); diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts new file mode 100644 index 00000000..fd27bb1d --- /dev/null +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -0,0 +1,35 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, resolveMember, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; + +export const RemoveRolesAction = automodAction({ + configType: t.array(t.string), + + async apply({ pluginData, contexts, actionConfig }) { + const members = contexts.map(c => c.member).filter(Boolean); + const uniqueMembers = new Set(members); + + await Promise.all( + Array.from(uniqueMembers.values()).map(async member => { + const memberRoles = new Set(member.roles); + for (const roleId of actionConfig) { + memberRoles.delete(roleId); + } + + if (memberRoles.size === member.roles.length) { + // No role changes + return; + } + + const rolesArr = Array.from(memberRoles.values()); + await member.edit({ + roles: rolesArr, + }); + member.roles = rolesArr; // Make sure we know of the new roles internally as well + }), + ); + }, +}); diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts new file mode 100644 index 00000000..85353820 --- /dev/null +++ b/backend/src/plugins/Automod/actions/reply.ts @@ -0,0 +1,63 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { + convertDelayStringToMS, + noop, + renderRecursively, + stripObjectToScalars, + tDelayString, + tMessageContent, + tNullable, +} from "../../../utils"; +import { TextChannel } from "eris"; +import { AutomodContext } from "../types"; +import { renderTemplate } from "../../../templateFormatter"; + +export const ReplyAction = automodAction({ + configType: t.union([ + t.string, + t.type({ + text: tMessageContent, + auto_delete: tNullable(t.union([tDelayString, t.number])), + }), + ]), + + async apply({ pluginData, contexts, actionConfig }) { + const contextsWithTextChannels = contexts + .filter(c => c.message?.channel_id) + .filter(c => pluginData.guild.channels.get(c.message.channel_id) instanceof TextChannel); + + const contextsByChannelId = contextsWithTextChannels.reduce((map: Map, context) => { + if (!map.has(context.message.channel_id)) { + map.set(context.message.channel_id, []); + } + + map.get(context.message.channel_id).push(context); + return map; + }, new Map()); + + for (const [channelId, _contexts] of contextsByChannelId.entries()) { + const users = Array.from(new Set(_contexts.map(c => c.user).filter(Boolean))); + const user = users[0]; + + const renderReplyText = async str => + renderTemplate(str, { + user: stripObjectToScalars(user), + }); + const formatted = + typeof actionConfig === "string" + ? await renderReplyText(actionConfig) + : await renderRecursively(actionConfig.text, renderReplyText); + + if (formatted) { + const channel = pluginData.guild.channels.get(channelId) as TextChannel; + const replyMsg = await channel.createMessage(formatted); + + if (typeof actionConfig === "object" && actionConfig.auto_delete) { + const delay = convertDelayStringToMS(String(actionConfig.auto_delete)); + setTimeout(() => replyMsg.delete().catch(noop), delay); + } + } + } + }, +}); diff --git a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts new file mode 100644 index 00000000..9a7ccc0e --- /dev/null +++ b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts @@ -0,0 +1,11 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { setAntiraidLevel } from "../functions/setAntiraidLevel"; + +export const SetAntiraidLevelAction = automodAction({ + configType: t.string, + + async apply({ pluginData, contexts, actionConfig }) { + setAntiraidLevel(pluginData, actionConfig); + }, +}); diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts new file mode 100644 index 00000000..2062d2a2 --- /dev/null +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -0,0 +1,34 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { LogType } from "../../../data/LogType"; +import { asyncMap, resolveMember, tNullable } from "../../../utils"; +import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; +import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; + +export const WarnAction = automodAction({ + configType: t.type({ + reason: tNullable(t.string), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), + }), + + async apply({ pluginData, contexts, actionConfig }) { + const reason = actionConfig.reason || "Warned automatically"; + const contactMethods = resolveActionContactMethods(pluginData, actionConfig); + + const caseArgs = { + modId: pluginData.client.user.id, + extraNotes: [ + /* TODO */ + ], + }; + + const userIdsToWarn = contexts.map(c => c.user?.id).filter(Boolean); + const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id)); + + const modActions = pluginData.getPlugin(ModActionsPlugin); + for (const member of membersToWarn) { + await modActions.warnMember(member, reason, { contactMethods, caseArgs }); + } + }, +}); diff --git a/backend/src/plugins/Automod/constants.ts b/backend/src/plugins/Automod/constants.ts new file mode 100644 index 00000000..a2445c3e --- /dev/null +++ b/backend/src/plugins/Automod/constants.ts @@ -0,0 +1,17 @@ +import { MINUTES, SECONDS } from "../../utils"; + +export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS; +export const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES; +export const RECENT_NICKNAME_CHANGE_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..596feda6 --- /dev/null +++ b/backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts @@ -0,0 +1,27 @@ +import { eventListener } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; +import { RecentActionType } from "../constants"; + +export const RunAutomodOnJoinEvt = eventListener()( + "guildMemberAdd", + ({ pluginData, args: { member } }) => { + const context: AutomodContext = { + timestamp: Date.now(), + user: member.user, + member, + joined: true, + }; + + pluginData.state.queue.add(() => { + pluginData.state.recentActions.push({ + type: RecentActionType.MemberJoin, + context, + count: 1, + identifier: null, + }); + + 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..2fb7e632 --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnMessage.ts @@ -0,0 +1,23 @@ +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 user = pluginData.client.users.get(message.user_id); + const member = pluginData.guild.members.get(message.user_id); + + const context: AutomodContext = { + timestamp: moment.utc(message.posted_at).valueOf(), + message, + user, + member, + }; + + 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..91f4ada5 --- /dev/null +++ b/backend/src/plugins/Automod/functions/addRecentActionsFromMessage.ts @@ -0,0 +1,129 @@ +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/clearOldNicknameChanges.ts b/backend/src/plugins/Automod/functions/clearOldNicknameChanges.ts new file mode 100644 index 00000000..2f503cf8 --- /dev/null +++ b/backend/src/plugins/Automod/functions/clearOldNicknameChanges.ts @@ -0,0 +1,12 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { RECENT_NICKNAME_CHANGE_EXPIRY_TIME, RECENT_SPAM_EXPIRY_TIME } from "../constants"; + +export function clearOldRecentNicknameChanges(pluginData: PluginData) { + const now = Date.now(); + for (const [userId, { timestamp }] of pluginData.state.recentNicknameChanges) { + if (timestamp + RECENT_NICKNAME_CHANGE_EXPIRY_TIME <= now) { + pluginData.state.recentNicknameChanges.delete(userId); + } + } +} 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..29222c41 --- /dev/null +++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts @@ -0,0 +1,81 @@ +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; + +interface TMessageSpamMatchResultType { + archiveId: string; +} + +export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) { + return automodTrigger()({ + configType: MessageSpamTriggerConfig, + defaultConfig: {}, + + 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, + userIds: [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..1531574f --- /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 && (!userId || spam.userIds.includes(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..70ed5472 --- /dev/null +++ b/backend/src/plugins/Automod/functions/getMatchingRecentActions.ts @@ -0,0 +1,22 @@ +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, +) { + to = to || Date.now(); + + 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..16fb72d9 --- /dev/null +++ b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts @@ -0,0 +1,58 @@ +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 type MatchableTextType = "message" | "embed" | "visiblename" | "username" | "nickname" | "customstatus"; + +type YieldedContent = [MatchableTextType, 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/resolveActionContactMethods.ts b/backend/src/plugins/Automod/functions/resolveActionContactMethods.ts new file mode 100644 index 00000000..6ae3fca0 --- /dev/null +++ b/backend/src/plugins/Automod/functions/resolveActionContactMethods.ts @@ -0,0 +1,32 @@ +import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { TextChannel } from "eris"; +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; + +export function resolveActionContactMethods( + pluginData: PluginData, + actionConfig: { + notify?: string; + notifyChannel?: string; + }, +): UserNotificationMethod[] | null { + if (actionConfig.notify === "dm") { + return [{ type: "dm" }]; + } else if (actionConfig.notify === "channel") { + if (!actionConfig.notifyChannel) { + throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL); + } + + const channel = pluginData.guild.channels.get(actionConfig.notifyChannel); + if (!(channel instanceof TextChannel)) { + throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); + } + + return [{ type: "channel", channel }]; + } else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) { + return []; + } + + return null; +} diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts new file mode 100644 index 00000000..428acf6e --- /dev/null +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -0,0 +1,86 @@ +import { PluginData } from "knub"; +import { AutomodContext, AutomodPluginType } 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 user = userId && pluginData.client.users.get(userId); + 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; + if (!rule.affects_bots && user.bot) continue; + + let matchResult: AutomodTriggerMatchResult; + 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; + } + + matchResult.summary = await trigger.renderMatchInformation({ + ruleName, + pluginData, + contexts, + matchResult, + triggerConfig, + }); + + if (matchResult.silentClean) { + await CleanAction.apply({ + ruleName, + pluginData, + contexts, + actionConfig: true, + matchResult, + }); + return; + } + + break triggerLoop; + } + } + } + + if (matchResult) { + for (const [actionName, actionConfig] of Object.entries(rule.actions)) { + const action = availableActions[actionName]; + action.apply({ + ruleName, + pluginData, + contexts, + actionConfig, + matchResult, + }); + } + + break; + } + } +} diff --git a/backend/src/plugins/Automod/functions/setAntiraidLevel.ts b/backend/src/plugins/Automod/functions/setAntiraidLevel.ts new file mode 100644 index 00000000..913f66e5 --- /dev/null +++ b/backend/src/plugins/Automod/functions/setAntiraidLevel.ts @@ -0,0 +1,18 @@ +import { User } from "eris"; +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; + +export async function setAntiraidLevel( + pluginData: PluginData, + newLevel: string | null, + user?: User, +) { + pluginData.state.cachedAntiraidLevel = newLevel; + await pluginData.state.antiraidLevels.set(newLevel); + + if (user) { + // TODO: Log user action + } else { + // TODO: Log automatic action + } +} diff --git a/backend/src/plugins/Automod/functions/sumRecentActionCounts.ts b/backend/src/plugins/Automod/functions/sumRecentActionCounts.ts new file mode 100644 index 00000000..021e44e6 --- /dev/null +++ b/backend/src/plugins/Automod/functions/sumRecentActionCounts.ts @@ -0,0 +1,5 @@ +import { RecentAction } from "../types"; + +export function sumRecentActionCounts(actions: RecentAction[]) { + return actions.reduce((total, action) => total + action.count, 0); +} diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts new file mode 100644 index 00000000..0dcce48f --- /dev/null +++ b/backend/src/plugins/Automod/helpers.ts @@ -0,0 +1,71 @@ +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; // TODO: Maybe generalize to a "silent" value in general, which mutes alert/log + + summary?: string; +} + +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>; + + match: AutomodTriggerMatchFn, TMatchResultExtra>; + renderMatchInformation: AutomodTriggerRenderMatchInformationFn, TMatchResultExtra>; +} + +export function automodTrigger(): ( + blueprint: AutomodTriggerBlueprint, +) => AutomodTriggerBlueprint; + +export function automodTrigger( + blueprint: AutomodTriggerBlueprint, +): AutomodTriggerBlueprint; + +export function automodTrigger(...args) { + if (args.length) { + return args[0]; + } else { + return automodTrigger; + } +} + +type AutomodActionApplyFn = (meta: { + ruleName: string; + pluginData: PluginData; + contexts: AutomodContext[]; + actionConfig: TConfigType; + matchResult: AutomodTriggerMatchResult; +}) => 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..85e8cb6b --- /dev/null +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -0,0 +1,52 @@ +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"; +import { MatchInvitesTrigger } from "./matchInvites"; +import { MatchLinksTrigger } from "./matchLinks"; +import { MatchAttachmentTypeTrigger } from "./matchAttachmentType"; +import { MemberJoinSpamTrigger } from "./memberJoinSpam"; +import { MemberJoinTrigger } from "./memberJoin"; + +export const availableTriggers: Record> = { + match_words: MatchWordsTrigger, + match_regex: MatchRegexTrigger, + match_invites: MatchInvitesTrigger, + match_links: MatchLinksTrigger, + match_attachment_type: MatchAttachmentTypeTrigger, + member_join: MemberJoinTrigger, + + message_spam: MessageSpamTrigger, + mention_spam: MentionSpamTrigger, + link_spam: LinkSpamTrigger, + attachment_spam: AttachmentSpamTrigger, + emoji_spam: EmojiSpamTrigger, + line_spam: LineSpamTrigger, + character_spam: CharacterSpamTrigger, + member_join_spam: MemberJoinSpamTrigger, +}; + +export const AvailableTriggers = t.type({ + match_words: MatchWordsTrigger.configType, + match_regex: MatchRegexTrigger.configType, + match_invites: MatchInvitesTrigger.configType, + match_links: MatchLinksTrigger.configType, + match_attachment_type: MatchAttachmentTypeTrigger.configType, + member_join: MemberJoinTrigger.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, + member_join_spam: MemberJoinSpamTrigger.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..7098e713 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts @@ -0,0 +1,31 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; + +interface ExampleMatchResultType { + isBanana: boolean; +} + +export const ExampleTrigger = automodTrigger()({ + configType: t.type({ + allowedFruits: t.array(t.string), + }), + + defaultConfig: { + allowedFruits: ["peach", "banana"], + }, + + async match({ triggerConfig, context }) { + const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit); + if (foundFruit) { + return { + extra: { + isBanana: foundFruit === "banana", + }, + }; + } + }, + + renderMatchInformation({ matchResult }) { + return `Matched fruit, isBanana: ${matchResult.extra.isBanana ? "yes" : "no"}`; + }, +}); 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/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts new file mode 100644 index 00000000..4fdf8887 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts @@ -0,0 +1,73 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { asSingleLine, disableCodeBlocks, disableInlineCode, verboseChannelMention } from "../../../utils"; + +interface MatchResultType { + matchedType: string; + mode: "blacklist" | "whitelist"; +} + +export const MatchAttachmentTypeTrigger = automodTrigger()({ + configType: t.type({ + filetype_blacklist: t.array(t.string), + blacklist_enabled: t.boolean, + filetype_whitelist: t.array(t.string), + whitelist_enabled: t.boolean, + }), + + defaultConfig: { + filetype_blacklist: [], + blacklist_enabled: false, + filetype_whitelist: [], + whitelist_enabled: false, + }, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + if (!context.message.data.attachments) return null; + const attachments: any[] = context.message.data.attachments; + + for (const attachment of attachments) { + const attachmentType = attachment.filename.split(`.`).pop(); + + if (trigger.blacklist_enabled && trigger.filetype_blacklist.includes(attachmentType)) { + return { + extra: { + matchedType: attachmentType, + mode: "blacklist", + }, + }; + } + + if (trigger.whitelist_enabled && !trigger.filetype_whitelist.includes(attachmentType)) { + return { + extra: { + matchedType: attachmentType, + mode: "whitelist", + }, + }; + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + return ( + asSingleLine(` + Matched attachment type \`${disableInlineCode(matchResult.extra.matchedType)}\` + (${matchResult.extra.mode === "blacklist" ? "(blacklisted)" : "(not in whitelist)"}) + in message (\`${contexts[0].message.id}\`) in ${prettyChannel}: + `) + + "\n```" + + disableCodeBlocks(contexts[0].message.data.content) + + "```" + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchInvites.ts b/backend/src/plugins/Automod/triggers/matchInvites.ts new file mode 100644 index 00000000..0f1d3d94 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchInvites.ts @@ -0,0 +1,101 @@ +import * as t from "io-ts"; +import { GuildInvite } from "eris"; +import { automodTrigger } from "../helpers"; +import { + disableCodeBlocks, + getInviteCodesInString, + isGuildInvite, + resolveInvite, + tNullable, + verboseChannelMention, +} from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; + +interface MatchResultType { + type: MatchableTextType; + code: string; + invite?: GuildInvite; +} + +export const MatchInvitesTrigger = automodTrigger()({ + configType: 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, + }), + + defaultConfig: { + allow_group_dm_invites: false, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, + }, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { + const inviteCodes = getInviteCodesInString(str); + if (inviteCodes.length === 0) return null; + + const uniqueInviteCodes = Array.from(new Set(inviteCodes)); + + for (const code of uniqueInviteCodes) { + if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) { + return { extra: { type, code } }; + } + if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { + return { extra: { type, code } }; + } + } + + for (const code of uniqueInviteCodes) { + const invite = await resolveInvite(pluginData.client, code); + if (!invite || !isGuildInvite(invite)) return { code }; + + if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { + return { extra: { type, code, invite } }; + } + if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { + return { extra: { type, code, invite } }; + } + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + let matchedText; + + if (matchResult.extra.invite) { + const invite = matchResult.extra.invite as GuildInvite; + matchedText = `invite code \`${matchResult.extra.code}\` (**${invite.guild.name}**, \`${invite.guild.id}\`)`; + } else { + matchedText = `invite code \`${matchResult.extra.code}\``; + } + + return ( + `${matchedText} in message (\`${contexts[0].message.id}\`) in ${prettyChannel}:\n` + + "```" + + disableCodeBlocks(contexts[0].message.data.content) + + "```" + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts new file mode 100644 index 00000000..c5235e3e --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchLinks.ts @@ -0,0 +1,147 @@ +import * as t from "io-ts"; +import escapeStringRegexp from "escape-string-regexp"; +import { automodTrigger } from "../helpers"; +import { + asSingleLine, + disableCodeBlocks, + disableInlineCode, + getUrlsInString, + tNullable, + verboseChannelMention, +} from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; +import { TSafeRegex } from "../../../validatorUtils"; + +interface MatchResultType { + type: MatchableTextType; + link: string; +} + +export const MatchLinksTrigger = automodTrigger()({ + configType: t.type({ + include_domains: tNullable(t.array(t.string)), + exclude_domains: tNullable(t.array(t.string)), + include_subdomains: t.boolean, + include_words: tNullable(t.array(t.string)), + exclude_words: tNullable(t.array(t.string)), + include_regex: tNullable(t.array(TSafeRegex)), + exclude_regex: tNullable(t.array(TSafeRegex)), + only_real_links: 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: { + include_subdomains: true, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, + only_real_links: true, + }, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + typeLoop: for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { + const links = getUrlsInString(str, true); + + for (const link of links) { + // "real link" = a link that Discord highlights + if (trigger.only_real_links && !link.input.match(/^https?:\/\//i)) { + continue; + } + + const normalizedHostname = link.hostname.toLowerCase(); + + // Exclude > Include + // In order of specificity, regex > word > domain + + if (trigger.exclude_regex) { + for (const pattern of trigger.exclude_regex) { + if (pattern.test(link.input)) { + continue typeLoop; + } + } + } + + if (trigger.include_regex) { + for (const pattern of trigger.include_regex) { + if (pattern.test(link.input)) { + return { extra: { type, link: link.input } }; + } + } + } + + if (trigger.exclude_words) { + for (const word of trigger.exclude_words) { + const regex = new RegExp(escapeStringRegexp(word), "i"); + if (regex.test(link.input)) { + continue typeLoop; + } + } + } + + if (trigger.include_words) { + for (const word of trigger.include_words) { + const regex = new RegExp(escapeStringRegexp(word), "i"); + if (regex.test(link.input)) { + return { extra: { type, link: link.input } }; + } + } + } + + if (trigger.exclude_domains) { + for (const domain of trigger.exclude_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + continue typeLoop; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + continue typeLoop; + } + } + + return { extra: { type, link: link.toString() } }; + } + + if (trigger.include_domains) { + for (const domain of trigger.include_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + return { extra: { type, link: domain } }; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + return { extra: { type, link: domain } }; + } + } + } + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + return ( + asSingleLine(` + Matched link \`${disableInlineCode(matchResult.extra.link)}\` + in message (\`${contexts[0].message.id}\`) in ${prettyChannel}: + `) + + "\n```" + + disableCodeBlocks(contexts[0].message.data.content) + + "```" + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts new file mode 100644 index 00000000..fdfd3f06 --- /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 { automodTrigger } from "../helpers"; +import { disableInlineCode, verboseChannelMention } from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; +import { TSafeRegex } from "../../../validatorUtils"; + +interface MatchResultType { + pattern: string; + type: MatchableTextType; +} + +export const MatchRegexTrigger = automodTrigger()({ + configType: 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, + }), + + 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, + }, + + 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 sourceRegex of trigger.patterns) { + const regex = new RegExp(sourceRegex.source, trigger.case_sensitive ? "" : "i"); + const test = regex.test(str); + if (test) { + return { + extra: { + pattern: sourceRegex.source, + 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..581d3362 --- /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"; + +interface MatchResultType { + word: string; + type: MatchableTextType; +} + +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, + }, + + 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/memberJoin.ts b/backend/src/plugins/Automod/triggers/memberJoin.ts new file mode 100644 index 00000000..3efca615 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/memberJoin.ts @@ -0,0 +1,34 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { convertDelayStringToMS, tDelayString } from "../../../utils"; + +export const MemberJoinTrigger = automodTrigger()({ + configType: t.type({ + only_new: t.boolean, + new_threshold: tDelayString, + }), + + defaultConfig: { + only_new: false, + new_threshold: "1h", + }, + + async match({ pluginData, context, triggerConfig }) { + if (!context.joined || !context.member) { + return; + } + + if (triggerConfig.only_new) { + const threshold = Date.now() - convertDelayStringToMS(triggerConfig.new_threshold); + if (context.member.createdAt >= threshold) { + return {}; + } + } + + return {}; + }, + + renderMatchInformation({ pluginData, contexts, triggerConfig }) { + return null; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts new file mode 100644 index 00000000..bd678fbe --- /dev/null +++ b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts @@ -0,0 +1,55 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { convertDelayStringToMS, tDelayString } from "../../../utils"; +import { getMatchingRecentActions } from "../functions/getMatchingRecentActions"; +import { RecentActionType } from "../constants"; +import { sumRecentActionCounts } from "../functions/sumRecentActionCounts"; +import { findRecentSpam } from "../functions/findRecentSpam"; + +export const MemberJoinSpamTrigger = automodTrigger()({ + configType: t.type({ + amount: t.number, + within: tDelayString, + }), + + defaultConfig: {}, + + async match({ pluginData, context, triggerConfig }) { + if (!context.joined || !context.member) { + return; + } + + const recentSpam = findRecentSpam(pluginData, RecentActionType.MemberJoin); + if (recentSpam) { + context.actioned = true; + return {}; + } + + const since = Date.now() - convertDelayStringToMS(triggerConfig.within); + const matchingActions = getMatchingRecentActions(pluginData, RecentActionType.MemberJoin, null, since); + const totalCount = sumRecentActionCounts(matchingActions); + + if (totalCount >= triggerConfig.amount) { + const contexts = [context, ...matchingActions.map(a => a.context).filter(c => c !== context)]; + + for (const _context of contexts) { + _context.actioned = true; + } + + pluginData.state.recentSpam.push({ + type: RecentActionType.MemberJoin, + timestamp: Date.now(), + archiveId: null, + userIds: [], + }); + + return { + extraContexts: contexts, + }; + } + }, + + renderMatchInformation({ pluginData, contexts, triggerConfig }) { + return null; + }, +}); 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..f77028e6 --- /dev/null +++ b/backend/src/plugins/Automod/types.ts @@ -0,0 +1,95 @@ +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 { Member, 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; + + recentNicknameChanges: Map; + clearRecentNicknameChangesInterval: Timeout; + + cachedAntiraidLevel: string | null; + + savedMessages: GuildSavedMessages; + logs: GuildLogs; + antiraidLevels: GuildAntiraidLevels; + archives: GuildArchives; + + onMessageCreateFn: any; + onMessageUpdateFn: any; + }; +} + +export interface AutomodContext { + timestamp: number; + actioned?: boolean; + + user?: User | UnknownUser; + message?: SavedMessage; + member?: Member; + joined?: boolean; +} + +export interface RecentAction { + type: RecentActionType; + identifier: string; + count: number; + context: AutomodContext; +} + +export interface RecentSpam { + archiveId: string; + type: RecentActionType; + userIds: string[]; + timestamp: number; +} diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index fe918678..86a589a7 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -1,7 +1,7 @@ import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; import { CasesPlugin } from "../Cases/CasesPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; -import { ConfigSchema, ModActionsPluginType } from "./types"; +import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types"; import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt"; import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt"; import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt"; @@ -28,6 +28,10 @@ import { GuildMutes } from "src/data/GuildMutes"; import { GuildCases } from "src/data/GuildCases"; import { GuildLogs } from "src/data/GuildLogs"; import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd"; +import { warnMember } from "./functions/warnMember"; +import { Member } from "eris"; +import { kickMember } from "./functions/kickMember"; +import { banUserId } from "./functions/banUserId"; const defaultOptions = { config: { @@ -119,6 +123,26 @@ export const ModActionsPlugin = zeppelinPlugin()("mod_acti UnhideCaseCmd, ], + public: { + warnMember(pluginData) { + return (member: Member, reason: string, warnOptions?: WarnOptions) => { + warnMember(pluginData, member, reason, warnOptions); + }; + }, + + kickMember(pluginData) { + return (member: Member, reason: string, kickOptions?: KickOptions) => { + kickMember(pluginData, member, reason, kickOptions); + }; + }, + + banUserId(pluginData) { + return (userId: string, reason?: string, banOptions?: BanOptions) => { + banUserId(pluginData, userId, reason, banOptions); + }; + }, + }, + onLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts new file mode 100644 index 00000000..413f29da --- /dev/null +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -0,0 +1,70 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, ReactionRolesPluginType } from "./types"; +import { GuildReactionRoles } from "src/data/GuildReactionRoles"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { Queue } from "src/Queue"; +import { autoRefreshLoop } from "./util/autoRefreshLoop"; +import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd"; +import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd"; +import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd"; +import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt"; + +const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API + +const defaultOptions: PluginOptions = { + config: { + auto_refresh_interval: MIN_AUTO_REFRESH, + + can_manage: false, + }, + + overrides: [ + { + level: ">=100", + config: { + can_manage: true, + }, + }, + ], +}; + +export const ReactionRolesPlugin = zeppelinPlugin()("reaction_roles", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + RefreshReactionRolesCmd, + ClearReactionRolesCmd, + InitReactionRolesCmd, + ], + + // prettier-ignore + events: [ + AddReactionRoleEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.reactionRemoveQueue = new Queue(); + state.roleChangeQueue = new Queue(); + state.pendingRoleChanges = new Map(); + state.pendingRefreshes = new Set(); + + let autoRefreshInterval = pluginData.config.get().auto_refresh_interval; + if (autoRefreshInterval != null) { + autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval); + autoRefreshLoop(pluginData, autoRefreshInterval); + } + }, + + onUnload(pluginData) { + if (pluginData.state.autoRefreshTimeout) { + clearTimeout(pluginData.state.autoRefreshTimeout); + } + }, +}); diff --git a/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts new file mode 100644 index 00000000..f08e07bc --- /dev/null +++ b/backend/src/plugins/ReactionRoles/commands/ClearReactionRolesCmd.ts @@ -0,0 +1,35 @@ +import { reactionRolesCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { TextChannel } from "eris"; + +export const ClearReactionRolesCmd = reactionRolesCmd({ + trigger: "reaction_roles clear", + permission: "can_manage", + + signature: { + messageId: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + const existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.messageId); + if (!existingReactionRoles) { + sendErrorMessage(pluginData, msg.channel, "Message doesn't have reaction roles on it"); + return; + } + + pluginData.state.reactionRoles.removeFromMessage(args.messageId); + + const channel = pluginData.guild.channels.get(savedMessage.channel_id) as TextChannel; + const targetMessage = await channel.getMessage(savedMessage.id); + await targetMessage.removeReactions(); + + sendSuccessMessage(pluginData, msg.channel, "Reaction roles cleared"); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts new file mode 100644 index 00000000..8f39258d --- /dev/null +++ b/backend/src/plugins/ReactionRoles/commands/InitReactionRolesCmd.ts @@ -0,0 +1,107 @@ +import { reactionRolesCmd, TReactionRolePair } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { TextChannel } from "eris"; +import { RecoverablePluginError, ERRORS } from "src/RecoverablePluginError"; +import { canUseEmoji } from "src/utils"; +import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage"; + +const CLEAR_ROLES_EMOJI = "❌"; + +export const InitReactionRolesCmd = reactionRolesCmd({ + trigger: "reaction_roles", + permission: "can_manage", + + signature: { + messageId: ct.string(), + reactionRolePairs: ct.string({ catchAll: true }), + + exclusive: ct.bool({ option: true, isSwitch: true, shortcut: "e" }), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + const channel = (await pluginData.guild.channels.get(savedMessage.channel_id)) as TextChannel; + if (!channel || !(channel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Channel no longer exists"); + return; + } + + const targetMessage = await channel.getMessage(args.messageId); + if (!targetMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message (2)"); + return; + } + + // Clear old reaction roles for the message from the DB + await pluginData.state.reactionRoles.removeFromMessage(targetMessage.id); + + // Turn "emoji = role" pairs into an array of tuples of the form [emoji, roleId] + // Emoji is either a unicode emoji or the snowflake of a custom emoji + const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs + .trim() + .split("\n") + .map(v => v.split("=").map(v => v.trim())) // tslint:disable-line + .map( + (pair): TReactionRolePair => { + const customEmojiMatch = pair[0].match(/^$/); + if (customEmojiMatch) { + return [customEmojiMatch[2], pair[1], customEmojiMatch[1]]; + } else { + return pair as TReactionRolePair; + } + }, + ); + + // Verify the specified emojis and roles are valid and usable + for (const pair of emojiRolePairs) { + if (pair[0] === CLEAR_ROLES_EMOJI) { + sendErrorMessage( + pluginData, + msg.channel, + `The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`, + ); + return; + } + + try { + if (!canUseEmoji(pluginData.client, pair[0])) { + sendErrorMessage( + pluginData, + msg.channel, + "I can only use regular emojis and custom emojis from servers I'm on", + ); + return; + } + } catch (e) { + if (e instanceof RecoverablePluginError && e.code === ERRORS.INVALID_EMOJI) { + sendErrorMessage(pluginData, msg.channel, `Invalid emoji: ${pair[0]}`); + return; + } + + throw e; + } + + if (!pluginData.guild.roles.has(pair[1])) { + sendErrorMessage(pluginData, msg.channel, `Unknown role ${pair[1]}`); + return; + } + } + + // Save the new reaction roles to the database + for (const pair of emojiRolePairs) { + await pluginData.state.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive); + } + + // Apply the reactions themselves + const reactionRoles = await pluginData.state.reactionRoles.getForMessage(targetMessage.id); + await applyReactionRoleReactionsToMessage(pluginData, targetMessage.channel.id, targetMessage.id, reactionRoles); + + sendSuccessMessage(pluginData, msg.channel, "Reaction roles added"); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts new file mode 100644 index 00000000..28039503 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/commands/RefreshReactionRolesCmd.ts @@ -0,0 +1,31 @@ +import { reactionRolesCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { refreshReactionRoles } from "../util/refreshReactionRoles"; + +export const RefreshReactionRolesCmd = reactionRolesCmd({ + trigger: "reaction_roles refresh", + permission: "can_manage", + + signature: { + messageId: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + console.log("ah"); + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + if (pluginData.state.pendingRefreshes.has(`${savedMessage.channel_id}-${savedMessage.id}`)) { + sendErrorMessage(pluginData, msg.channel, "Another refresh in progress"); + return; + } + + await refreshReactionRoles(pluginData, savedMessage.channel_id, savedMessage.id); + + sendSuccessMessage(pluginData, msg.channel, "Reaction roles refreshed"); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts b/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts new file mode 100644 index 00000000..9b7b045f --- /dev/null +++ b/backend/src/plugins/ReactionRoles/events/AddReactionRoleEvt.ts @@ -0,0 +1,63 @@ +import { reactionRolesEvent } from "../types"; +import { resolveMember, noop, sleep } from "src/utils"; +import { addMemberPendingRoleChange } from "../util/addMemberPendingRoleChange"; +import { Message } from "eris"; + +const CLEAR_ROLES_EMOJI = "❌"; + +export const AddReactionRoleEvt = reactionRolesEvent({ + event: "messageReactionAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + const msg = meta.args.message as Message; + const emoji = meta.args.emoji; + const userId = meta.args.userID; + + // Make sure this message has reaction roles on it + const reactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id); + if (reactionRoles.length === 0) return; + + const member = await resolveMember(pluginData.client, pluginData.guild, userId); + if (!member) return; + + if (emoji.name === CLEAR_ROLES_EMOJI) { + // User reacted with "clear roles" emoji -> clear their roles + const reactionRoleRoleIds = reactionRoles.map(rr => rr.role_id); + for (const roleId of reactionRoleRoleIds) { + addMemberPendingRoleChange(pluginData, userId, "-", roleId); + } + + pluginData.state.reactionRemoveQueue.add(async () => { + await msg.channel.removeMessageReaction(msg.id, CLEAR_ROLES_EMOJI, userId); + }); + } else { + // User reacted with a reaction role emoji -> add the role + const matchingReactionRole = await pluginData.state.reactionRoles.getByMessageAndEmoji( + msg.id, + emoji.id || emoji.name, + ); + if (!matchingReactionRole) return; + + // If the reaction role is exclusive, remove any other roles in the message first + if (matchingReactionRole.is_exclusive) { + const messageReactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id); + for (const reactionRole of messageReactionRoles) { + addMemberPendingRoleChange(pluginData, userId, "-", reactionRole.role_id); + } + } + + addMemberPendingRoleChange(pluginData, userId, "+", matchingReactionRole.role_id); + } + + // Remove the reaction after a small delay + setTimeout(() => { + pluginData.state.reactionRemoveQueue.add(async () => { + const reaction = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; + const wait = sleep(1500); + await msg.channel.removeMessageReaction(msg.id, reaction, userId).catch(noop); + await wait; + }); + }, 1500); + }, +}); diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts new file mode 100644 index 00000000..434a4e7d --- /dev/null +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -0,0 +1,44 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener, command, PluginData } from "knub"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildReactionRoles } from "src/data/GuildReactionRoles"; +import { Queue } from "src/Queue"; + +export const ConfigSchema = t.type({ + auto_refresh_interval: t.number, + can_manage: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export type RoleChangeMode = "+" | "-"; + +export type PendingMemberRoleChanges = { + timeout: NodeJS.Timeout; + applyFn: () => void; + changes: Array<{ + mode: RoleChangeMode; + roleId: string; + }>; +}; + +const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]); +export type TReactionRolePair = t.TypeOf; +type ReactionRolePair = [string, string, string?]; + +export interface ReactionRolesPluginType extends BasePluginType { + config: TConfigSchema; + state: { + reactionRoles: GuildReactionRoles; + savedMessages: GuildSavedMessages; + + reactionRemoveQueue: Queue; + roleChangeQueue: Queue; + pendingRoleChanges: Map; + pendingRefreshes: Set; + + autoRefreshTimeout: NodeJS.Timeout; + }; +} + +export const reactionRolesCmd = command(); +export const reactionRolesEvent = eventListener(); diff --git a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts new file mode 100644 index 00000000..a6043377 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts @@ -0,0 +1,59 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType, RoleChangeMode, PendingMemberRoleChanges } from "../types"; +import { resolveMember } from "src/utils"; +import { logger } from "src/logger"; + +const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500; + +export async function addMemberPendingRoleChange( + pluginData: PluginData, + memberId: string, + mode: RoleChangeMode, + roleId: string, +) { + if (!pluginData.state.pendingRoleChanges.has(memberId)) { + const newPendingRoleChangeObj: PendingMemberRoleChanges = { + timeout: null, + changes: [], + applyFn: async () => { + pluginData.state.pendingRoleChanges.delete(memberId); + + const lock = await pluginData.locks.acquire(`member-roles-${memberId}`); + + const member = await resolveMember(pluginData.client, pluginData.guild, memberId); + if (member) { + const newRoleIds = new Set(member.roles); + for (const change of newPendingRoleChangeObj.changes) { + if (change.mode === "+") newRoleIds.add(change.roleId); + else newRoleIds.delete(change.roleId); + } + + try { + await member.edit( + { + roles: Array.from(newRoleIds.values()), + }, + "Reaction roles", + ); + } catch (e) { + logger.warn( + `Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${e.message}`, + ); + } + } + lock.unlock(); + }, + }; + + pluginData.state.pendingRoleChanges.set(memberId, newPendingRoleChangeObj); + } + + const pendingRoleChangeObj = pluginData.state.pendingRoleChanges.get(memberId); + pendingRoleChangeObj.changes.push({ mode, roleId }); + + if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout); + pendingRoleChangeObj.timeout = setTimeout( + () => pluginData.state.roleChangeQueue.add(pendingRoleChangeObj.applyFn), + ROLE_CHANGE_BATCH_DEBOUNCE_TIME, + ); +} diff --git a/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts new file mode 100644 index 00000000..396c5adf --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/applyReactionRoleReactionsToMessage.ts @@ -0,0 +1,58 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { ReactionRole } from "src/data/entities/ReactionRole"; +import { TextChannel } from "eris"; +import { isDiscordRESTError, sleep, isSnowflake } from "src/utils"; +import { logger } from "src/logger"; + +const CLEAR_ROLES_EMOJI = "❌"; + +export async function applyReactionRoleReactionsToMessage( + pluginData: PluginData, + channelId: string, + messageId: string, + reactionRoles: ReactionRole[], +) { + const channel = pluginData.guild.channels.get(channelId) as TextChannel; + if (!channel) return; + + let targetMessage; + try { + targetMessage = await channel.getMessage(messageId); + } catch (e) { + if (isDiscordRESTError(e)) { + if (e.code === 10008) { + // Unknown message, remove reaction roles from the message + logger.warn( + `Removed reaction roles from unknown message ${channelId}/${messageId} in guild ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + await pluginData.state.reactionRoles.removeFromMessage(messageId); + } else { + logger.warn( + `Error when applying reaction roles to message ${channelId}/${messageId} in guild ${pluginData.guild.name} (${pluginData.guild.id}), error code ${e.code}`, + ); + } + + return; + } else { + throw e; + } + } + + // Remove old reactions, if any + const removeSleep = sleep(1250); + await targetMessage.removeReactions(); + await removeSleep; + + // Add reaction role reactions + for (const rr of reactionRoles) { + const emoji = isSnowflake(rr.emoji) ? `foo:${rr.emoji}` : rr.emoji; + + const sleepTime = sleep(1250); // Make sure we only add 1 reaction per ~second so as not to hit rate limits + await targetMessage.addReaction(emoji); + await sleepTime; + } + + // Add the "clear reactions" button + await targetMessage.addReaction(CLEAR_ROLES_EMOJI); +} diff --git a/backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts b/backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts new file mode 100644 index 00000000..e29e3dfa --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts @@ -0,0 +1,10 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { runAutoRefresh } from "./runAutoRefresh"; + +export async function autoRefreshLoop(pluginData: PluginData, interval: number) { + pluginData.state.autoRefreshTimeout = setTimeout(async () => { + await runAutoRefresh(pluginData); + autoRefreshLoop(pluginData, interval); + }, interval); +} diff --git a/backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts b/backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts new file mode 100644 index 00000000..4ea195f7 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/refreshReactionRoles.ts @@ -0,0 +1,20 @@ +import { ReactionRolesPluginType } from "../types"; +import { PluginData } from "knub"; +import { applyReactionRoleReactionsToMessage } from "./applyReactionRoleReactionsToMessage"; + +export async function refreshReactionRoles( + pluginData: PluginData, + channelId: string, + messageId: string, +) { + const pendingKey = `${channelId}-${messageId}`; + if (pluginData.state.pendingRefreshes.has(pendingKey)) return; + pluginData.state.pendingRefreshes.add(pendingKey); + + try { + const reactionRoles = await pluginData.state.reactionRoles.getForMessage(messageId); + await applyReactionRoleReactionsToMessage(pluginData, channelId, messageId, reactionRoles); + } finally { + pluginData.state.pendingRefreshes.delete(pendingKey); + } +} diff --git a/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts b/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts new file mode 100644 index 00000000..666df965 --- /dev/null +++ b/backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts @@ -0,0 +1,13 @@ +import { PluginData } from "knub"; +import { ReactionRolesPluginType } from "../types"; +import { refreshReactionRoles } from "./refreshReactionRoles"; + +export async function runAutoRefresh(pluginData: PluginData) { + // Refresh reaction roles on all reaction role messages + const reactionRoles = await pluginData.state.reactionRoles.all(); + const idPairs = new Set(reactionRoles.map(r => `${r.channel_id}-${r.message_id}`)); + for (const pair of idPairs) { + const [channelId, messageId] = pair.split("-"); + await refreshReactionRoles(pluginData, channelId, messageId); + } +} 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/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 69d64347..e5b2ffa6 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -23,6 +23,9 @@ import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; import { StarboardPlugin } from "./Starboard/StarboardPlugin"; import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin"; +import { SpamPlugin } from "./Spam/SpamPlugin"; +import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; +import { AutomodPlugin } from "./Automod/AutomodPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -35,6 +38,7 @@ export const guildPlugins: Array> = [ PersistPlugin, PingableRolesPlugin, PostPlugin, + ReactionRolesPlugin, MessageSaverPlugin, ModActionsPlugin, NameHistoryPlugin, @@ -42,6 +46,7 @@ export const guildPlugins: Array> = [ RolesPlugin, SelfGrantableRolesPlugin, SlowmodePlugin, + SpamPlugin, StarboardPlugin, TagsPlugin, UsernameSaverPlugin, @@ -49,6 +54,7 @@ export const guildPlugins: Array> = [ WelcomeMessagePlugin, CasesPlugin, MutesPlugin, + AutomodPlugin, ]; // prettier-ignore diff --git a/backend/src/utils.ts b/backend/src/utils.ts index a58f751b..831129f4 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,4 +1,5 @@ import { + AnyInvite, Attachment, ChannelInvite, Client, @@ -9,6 +10,7 @@ import { GuildAuditLog, GuildAuditLogEntry, GuildChannel, + GuildInvite, Member, Message, MessageContent, @@ -32,6 +34,7 @@ import { either } from "fp-ts/lib/Either"; import moment from "moment-timezone"; import { SimpleCache } from "./SimpleCache"; import { logger } from "./logger"; +import { Awaitable } from "knub/dist/utils"; const fsp = fs.promises; @@ -1216,3 +1219,11 @@ export function trimPluginDescription(str) { export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message { return (msg as Message).createdAt != null; } + +export function isGuildInvite(invite: AnyInvite): invite is GuildInvite { + return (invite as GuildInvite).guild != null; +} + +export function asyncMap(arr: T[], fn: (item: T) => Promise): Promise { + return Promise.all(arr.map((item, index) => fn(item))); +}