diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 72cb7aa5..923c5806 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -73,19 +73,19 @@ const configPreprocessor: ConfigPreprocessorFn = (options) => continue; } - rule["name"] = name; + 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.enabled == null) { + rule.enabled = true; } - if (rule["allow_further_rules"] == null) { - rule["allow_further_rules"] = false; + if (rule.allow_further_rules == null) { + rule.allow_further_rules = false; } - if (rule["affects_bots"] == null) { - rule["affects_bots"] = false; + if (rule.affects_bots == null) { + rule.affects_bots = false; } if (rule["affects_self"] == null) { @@ -93,8 +93,8 @@ const configPreprocessor: ConfigPreprocessorFn = (options) => } // Loop through the rule's triggers - if (rule["triggers"]) { - for (const triggerObj of rule["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}'`]); @@ -144,33 +144,45 @@ const configPreprocessor: ConfigPreprocessorFn = (options) => } } - if (rule["actions"]) { - for (const actionName in rule["actions"]) { + if (rule.actions) { + if (rule.actions.change_roles && (rule.actions.add_roles || rule.actions.remove_roles)) { + throw new StrictValidationError([ + `Can't use both 'change_roles' and 'add_roles'/'remove_roles' at rule '${rule.name}'`, + ]); + } + + if (rule.actions.add_roles && rule.actions.remove_roles) { + throw new StrictValidationError([ + `Can't use both 'add_roles' and 'remove_roles' at rule '${rule.name}', use 'change_roles' instead`, + ]); + } + + for (const actionName in rule.actions) { if (!availableActions[actionName]) { throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule.name}'`]); } const actionBlueprint = availableActions[actionName]; - const actionConfig = rule["actions"][actionName]; + const actionConfig = rule.actions[actionName]; if (typeof actionConfig !== "object" || Array.isArray(actionConfig) || actionConfig == null) { - rule["actions"][actionName] = actionConfig; + rule.actions[actionName] = actionConfig; } else { - rule["actions"][actionName] = configUtils.mergeConfig(actionBlueprint.defaultConfig, actionConfig); + rule.actions[actionName] = configUtils.mergeConfig(actionBlueprint.defaultConfig, actionConfig); } } } // Enable logging of automod actions by default - if (rule["actions"]) { + 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; + if (rule.actions.log == null) { + rule.actions.log = true; } if (rule["actions"]["clean"] && rule["actions"]["start_thread"]) { throw new StrictValidationError([`Cannot have both clean and start_thread at rule '${rule.name}'`]); diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index ee577ae9..f11fa86b 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -1,6 +1,5 @@ import { Permissions, Snowflake } from "discord.js"; import * as t from "io-ts"; -import { LogType } from "../../../data/LogType"; import { nonNullish, unique } from "../../../utils"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index c37f3a39..60b13906 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -6,6 +6,7 @@ import { AlertAction } from "./alert"; import { ArchiveThreadAction } from "./archiveThread"; import { BanAction } from "./ban"; import { ChangeNicknameAction } from "./changeNickname"; +import { ChangeRolesAction } from "./changeRoles"; import { CleanAction } from "./clean"; import { KickAction } from "./kick"; import { LogAction } from "./log"; @@ -36,6 +37,7 @@ export const availableActions: Record> = { set_slowmode: SetSlowmodeAction, start_thread: StartThreadAction, archive_thread: ArchiveThreadAction, + change_roles: ChangeRolesAction, }; export const AvailableActions = t.type({ @@ -56,4 +58,5 @@ export const AvailableActions = t.type({ set_slowmode: SetSlowmodeAction.configType, start_thread: StartThreadAction.configType, archive_thread: ArchiveThreadAction.configType, + change_roles: ChangeRolesAction.configType, }); diff --git a/backend/src/plugins/Automod/actions/changeRoles.ts b/backend/src/plugins/Automod/actions/changeRoles.ts new file mode 100644 index 00000000..cdd00bef --- /dev/null +++ b/backend/src/plugins/Automod/actions/changeRoles.ts @@ -0,0 +1,92 @@ +import { Permissions, Snowflake } from "discord.js"; +import * as t from "io-ts"; +import isEqual from "lodash.isequal"; +import { nonNullish, unique } from "../../../utils"; +import { canAssignRole } from "../../../utils/canAssignRole"; +import { getMissingPermissions } from "../../../utils/getMissingPermissions"; +import { memberRolesLock } from "../../../utils/lockNameHelpers"; +import { missingPermissionError } from "../../../utils/missingPermissionError"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { ignoreRoleChange } from "../functions/ignoredRoleChanges"; +import { automodAction } from "../helpers"; + +export const ChangeRolesAction = automodAction({ + configType: t.type({ + add: t.array(t.string), + remove: t.array(t.string), + }), + defaultConfig: { + add: [], + remove: [], + }, + + async apply({ pluginData, contexts, actionConfig, ruleName }) { + const members = unique(contexts.map(c => c.member).filter(nonNullish)); + const me = pluginData.guild.me ?? (await pluginData.guild.members.fetch(pluginData.client.user!.id)); + + const missingPermissions = getMissingPermissions(me.permissions, Permissions.FLAGS.MANAGE_ROLES); + if (missingPermissions) { + const logs = pluginData.getPlugin(LogsPlugin); + logs.logBotAlert({ + body: `Cannot edit roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, + }); + return; + } + + const rolesToAssign: Snowflake[] = []; + const rolesWeCannotAssign: Snowflake[] = []; + const rolesToRemove: Snowflake[] = []; + const rolesWeCannotRemove: Snowflake[] = []; + for (const roleId of actionConfig.add) { + if (canAssignRole(pluginData.guild, me, roleId)) { + rolesToAssign.push(roleId); + } else { + rolesWeCannotAssign.push(roleId); + } + } + for (const roleId of actionConfig.remove) { + if (canAssignRole(pluginData.guild, me, roleId)) { + rolesToRemove.push(roleId); + } else { + rolesWeCannotRemove.push(roleId); + } + } + + if (rolesWeCannotAssign.length || rolesWeCannotRemove.length) { + const mapFn = (roleId: Snowflake) => pluginData.guild.roles.cache.get(roleId)?.name || roleId; + const roleNamesWeCannotAssign = rolesWeCannotAssign.map(mapFn); + const roleNamesWeCannotRemove = rolesWeCannotRemove.map(mapFn); + const logs = pluginData.getPlugin(LogsPlugin); + let body = `Unable to change roles in Automod rule **${ruleName}**:`; + if (roleNamesWeCannotAssign.length) body += `\n**Add:** ${roleNamesWeCannotAssign.join("**, **")}}`; + if (roleNamesWeCannotRemove.length) body += `\n**Remove:** ${roleNamesWeCannotRemove.join("**, **")}}`; + logs.logBotAlert({ body }); + } + + await Promise.all( + members.map(async member => { + const memberRoles = new Set(member.roles.cache.keys()); + for (const roleId of rolesToAssign) { + memberRoles.add(roleId); + ignoreRoleChange(pluginData, member.id, roleId); + } + for (const roleId of rolesToRemove) { + memberRoles.delete(roleId); + ignoreRoleChange(pluginData, member.id, roleId); + } + + if (isEqual(Array.from(memberRoles), Array.from(member.roles.cache.keys()))) { + // No role changes + return; + } + + const memberRoleLock = await pluginData.locks.acquire(memberRolesLock(member)); + + const rolesArr = Array.from(memberRoles.values()); + await member.roles.set(rolesArr); + + memberRoleLock.unlock(); + }), + ); + }, +}); diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index 62b71480..90e55bb1 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -1,6 +1,5 @@ import { Permissions, Snowflake } from "discord.js"; import * as t from "io-ts"; -import { LogType } from "../../../data/LogType"; import { nonNullish, unique } from "../../../utils"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; @@ -25,7 +24,7 @@ export const RemoveRolesAction = automodAction({ if (missingPermissions) { const logs = pluginData.getPlugin(LogsPlugin); logs.logBotAlert({ - body: `Cannot add roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, + body: `Cannot remove roles in Automod rule **${ruleName}**. ${missingPermissionError(missingPermissions)}`, }); return; }