From 4c7a51f586ed19480e368717ceab3466b7c5e0f6 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 10 Aug 2020 02:22:39 +0300 Subject: [PATCH] automod: add role_added and role_removed triggers --- backend/src/plugins/Automod/AutomodPlugin.ts | 17 ++++++-- .../src/plugins/Automod/actions/addRoles.ts | 2 + .../plugins/Automod/actions/removeRoles.ts | 2 + .../events/RunAutomodOnMemberUpdate.ts | 34 +++++++++++++++ .../Automod/functions/ignoredRoleChanges.ts | 38 +++++++++++++++++ .../Automod/triggers/availableTriggers.ts | 4 ++ .../src/plugins/Automod/triggers/roleAdded.ts | 42 +++++++++++++++++++ .../plugins/Automod/triggers/roleRemoved.ts | 42 +++++++++++++++++++ backend/src/plugins/Automod/types.ts | 10 +++++ 9 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts create mode 100644 backend/src/plugins/Automod/functions/ignoredRoleChanges.ts create mode 100644 backend/src/plugins/Automod/triggers/roleAdded.ts create mode 100644 backend/src/plugins/Automod/triggers/roleRemoved.ts diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index d2684e04..17beba3e 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -27,6 +27,7 @@ import { RegExpRunner } from "../../RegExpRunner"; import { LogType } from "../../data/LogType"; import { logger } from "../../logger"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; +import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; const defaultOptions = { config: { @@ -83,10 +84,15 @@ const configPreprocessor: ConfigPreprocessorFn = options => { } const triggerBlueprint = availableTriggers[triggerName]; - triggerObj[triggerName] = configUtils.mergeConfig( - triggerBlueprint.defaultConfig, - triggerObj[triggerName] || {}, - ); + + if (typeof triggerBlueprint.defaultConfig === "object" && triggerBlueprint.defaultConfig != null) { + triggerObj[triggerName] = configUtils.mergeConfig( + triggerBlueprint.defaultConfig, + triggerObj[triggerName] || {}, + ); + } else { + triggerObj[triggerName] = triggerObj[triggerName] || triggerBlueprint.defaultConfig; + } if (triggerObj[triggerName].match_attachment_type) { const white = triggerObj[triggerName].match_attachment_type.whitelist_enabled; @@ -157,6 +163,7 @@ export const AutomodPlugin = zeppelinPlugin()("automod", { events: [ RunAutomodOnJoinEvt, + RunAutomodOnMemberUpdate, // Messages use message events from SavedMessages, see onLoad below ], @@ -179,6 +186,8 @@ export const AutomodPlugin = zeppelinPlugin()("automod", { 30 * SECONDS, ); + pluginData.state.ignoredRoleChanges = new Set(); + pluginData.state.cooldownManager = new CooldownManager(); pluginData.state.logs = new GuildLogs(pluginData.guild.id); diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index 5f8e8a3b..b3fb6be0 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -8,6 +8,7 @@ import { LogsPlugin } from "../../Logs/LogsPlugin"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; import { canAssignRole } from "../../../utils/canAssignRole"; import { missingPermissionError } from "../../../utils/missingPermissionError"; +import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; const p = Constants.Permissions; @@ -55,6 +56,7 @@ export const AddRolesAction = automodAction({ const memberRoles = new Set(member.roles); for (const roleId of rolesToAssign) { memberRoles.add(roleId); + ignoreRoleChange(pluginData, member.id, roleId); } if (memberRoles.size === member.roles.length) { diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index 614a12bf..12245f1b 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -9,6 +9,7 @@ import { LogsPlugin } from "../../Logs/LogsPlugin"; import { missingPermissionError } from "../../../utils/missingPermissionError"; import { canAssignRole } from "../../../utils/canAssignRole"; import { Constants } from "eris"; +import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; const p = Constants.Permissions; @@ -57,6 +58,7 @@ export const RemoveRolesAction = automodAction({ const memberRoles = new Set(member.roles); for (const roleId of rolesToRemove) { memberRoles.delete(roleId); + ignoreRoleChange(pluginData, member.id, roleId); } if (memberRoles.size === member.roles.length) { diff --git a/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts new file mode 100644 index 00000000..d850ec1d --- /dev/null +++ b/backend/src/plugins/Automod/events/RunAutomodOnMemberUpdate.ts @@ -0,0 +1,34 @@ +import { eventListener } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { RecentActionType } from "../constants"; +import { runAutomod } from "../functions/runAutomod"; +import isEqual from "lodash.isequal"; +import diff from "lodash.difference"; + +export const RunAutomodOnMemberUpdate = eventListener()( + "guildMemberUpdate", + ({ pluginData, args: { member, oldMember } }) => { + if (!oldMember) return; + + if (isEqual(oldMember.roles, member.roles)) return; + + const addedRoles = diff(member.roles, oldMember.roles); + const removedRoles = diff(oldMember.roles, member.roles); + + if (addedRoles.length || removedRoles.length) { + const context: AutomodContext = { + timestamp: Date.now(), + user: member.user, + member, + rolesChanged: { + added: addedRoles, + removed: removedRoles, + }, + }; + + pluginData.state.queue.add(() => { + runAutomod(pluginData, context); + }); + } + }, +); diff --git a/backend/src/plugins/Automod/functions/ignoredRoleChanges.ts b/backend/src/plugins/Automod/functions/ignoredRoleChanges.ts new file mode 100644 index 00000000..d9a296b2 --- /dev/null +++ b/backend/src/plugins/Automod/functions/ignoredRoleChanges.ts @@ -0,0 +1,38 @@ +import { PluginData } from "knub"; +import { AutomodPluginType } from "../types"; +import { MINUTES } from "../../../utils"; + +const IGNORED_ROLE_CHANGE_LIFETIME = 5 * MINUTES; + +function cleanupIgnoredRoleChanges(pluginData: PluginData) { + const cutoff = Date.now() - IGNORED_ROLE_CHANGE_LIFETIME; + for (const ignoredChange of pluginData.state.ignoredRoleChanges.values()) { + if (ignoredChange.timestamp < cutoff) { + pluginData.state.ignoredRoleChanges.delete(ignoredChange); + } + } +} + +export function ignoreRoleChange(pluginData: PluginData, memberId: string, roleId: string) { + pluginData.state.ignoredRoleChanges.add({ + memberId, + roleId, + timestamp: Date.now(), + }); + + cleanupIgnoredRoleChanges(pluginData); +} + +/** + * @return Whether the role change should be ignored + */ +export function consumeIgnoredRoleChange(pluginData: PluginData, memberId: string, roleId: string) { + for (const ignoredChange of pluginData.state.ignoredRoleChanges.values()) { + if (ignoredChange.memberId === memberId && ignoredChange.roleId === roleId) { + pluginData.state.ignoredRoleChanges.delete(ignoredChange); + return true; + } + } + + return false; +} diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index 85e8cb6b..f3dea82d 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -14,6 +14,8 @@ import { MatchLinksTrigger } from "./matchLinks"; import { MatchAttachmentTypeTrigger } from "./matchAttachmentType"; import { MemberJoinSpamTrigger } from "./memberJoinSpam"; import { MemberJoinTrigger } from "./memberJoin"; +import { RoleAddedTrigger } from "./roleAdded"; +import { RoleRemovedTrigger } from "./roleRemoved"; export const availableTriggers: Record> = { match_words: MatchWordsTrigger, @@ -22,6 +24,8 @@ export const availableTriggers: Record match_links: MatchLinksTrigger, match_attachment_type: MatchAttachmentTypeTrigger, member_join: MemberJoinTrigger, + role_added: RoleAddedTrigger, + role_removed: RoleRemovedTrigger, message_spam: MessageSpamTrigger, mention_spam: MentionSpamTrigger, diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts new file mode 100644 index 00000000..7fb89ee5 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/roleAdded.ts @@ -0,0 +1,42 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges"; + +interface RoleAddedMatchResult { + matchedRoleId: string; +} + +export const RoleAddedTrigger = automodTrigger()({ + configType: t.union([t.string, t.array(t.string)]), + + defaultConfig: null, + + async match({ triggerConfig, context, pluginData }) { + if (!context.member || !context.rolesChanged || context.rolesChanged.added.length === 0) { + return; + } + + const triggerRoles = Array.isArray(triggerConfig) ? triggerConfig : [triggerConfig]; + for (const roleId of triggerRoles) { + if (context.rolesChanged.added.includes(roleId)) { + if (consumeIgnoredRoleChange(pluginData, context.member.id, roleId)) { + continue; + } + + return { + extra: { + matchedRoleId: roleId, + }, + }; + } + } + }, + + renderMatchInformation({ matchResult, pluginData, contexts }) { + const role = pluginData.guild.roles.get(matchResult.extra.matchedRoleId); + const roleName = role?.name || "Unknown"; + const member = contexts[0].member!; + const memberName = `**${member.user.username}#${member.user.discriminator}** (\`${member.id}\`)`; + return `Role ${roleName} (\`${matchResult.extra.matchedRoleId}\`) was added to ${memberName}`; + }, +}); diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts new file mode 100644 index 00000000..fa04b3fc --- /dev/null +++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts @@ -0,0 +1,42 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges"; + +interface RoleAddedMatchResult { + matchedRoleId: string; +} + +export const RoleRemovedTrigger = automodTrigger()({ + configType: t.union([t.string, t.array(t.string)]), + + defaultConfig: null, + + async match({ triggerConfig, context, pluginData }) { + if (!context.member || !context.rolesChanged || context.rolesChanged.removed.length === 0) { + return; + } + + const triggerRoles = Array.isArray(triggerConfig) ? triggerConfig : [triggerConfig]; + for (const roleId of triggerRoles) { + if (consumeIgnoredRoleChange(pluginData, context.member.id, roleId)) { + continue; + } + + if (context.rolesChanged.removed.includes(roleId)) { + return { + extra: { + matchedRoleId: roleId, + }, + }; + } + } + }, + + renderMatchInformation({ matchResult, pluginData, contexts }) { + const role = pluginData.guild.roles.get(matchResult.extra.matchedRoleId); + const roleName = role?.name || "Unknown"; + const member = contexts[0].member!; + const memberName = `**${member.user.username}#${member.user.discriminator}** (\`${member.id}\`)`; + return `Role ${roleName} (\`${matchResult.extra.matchedRoleId}\`) was removed from ${memberName}`; + }, +}); diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index d230b3df..2c16269f 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -69,6 +69,12 @@ export interface AutomodPluginType extends BasePluginType { recentNicknameChanges: Map; clearRecentNicknameChangesInterval: Timeout; + ignoredRoleChanges: Set<{ + memberId: string; + roleId: string; + timestamp: number; + }>; + cachedAntiraidLevel: string | null; cooldownManager: CooldownManager; @@ -91,6 +97,10 @@ export interface AutomodContext { message?: SavedMessage; member?: Member; joined?: boolean; + rolesChanged?: { + added?: string[]; + removed?: string[]; + }; } export interface RecentAction {