From 86023877a27c40dc9ac12ce07da4227b27e90446 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Tue, 28 Jul 2020 21:34:01 +0300
Subject: [PATCH] Automod actions + ModActions public interface

---
 backend/src/plugins/Automod/AutomodPlugin.ts  | 17 ++++-
 .../src/plugins/Automod/actions/addRoles.ts   | 35 +++++++++++
 backend/src/plugins/Automod/actions/alert.ts  | 48 ++++++++++++++
 backend/src/plugins/Automod/actions/ban.ts    | 35 +++++++++++
 .../plugins/Automod/actions/changeNickname.ts | 27 ++++++++
 .../plugins/Automod/actions/exampleAction.ts  | 12 ++++
 backend/src/plugins/Automod/actions/kick.ts   | 34 ++++++++++
 backend/src/plugins/Automod/actions/log.ts    | 10 +++
 backend/src/plugins/Automod/actions/mute.ts   | 36 +++++++++++
 .../plugins/Automod/actions/removeRoles.ts    | 35 +++++++++++
 backend/src/plugins/Automod/actions/reply.ts  | 63 +++++++++++++++++++
 .../Automod/actions/setAntiraidLevel.ts       | 11 ++++
 backend/src/plugins/Automod/actions/warn.ts   | 34 ++++++++++
 backend/src/plugins/Automod/constants.ts      |  1 +
 .../functions/clearOldNicknameChanges.ts      | 12 ++++
 .../functions/resolveActionContactMethods.ts  | 32 ++++++++++
 .../plugins/Automod/functions/runAutomod.ts   | 25 ++++----
 .../Automod/functions/setAntiraidLevel.ts     | 18 ++++++
 backend/src/plugins/Automod/helpers.ts        |  3 +
 backend/src/plugins/Automod/types.ts          |  5 ++
 .../plugins/ModActions/ModActionsPlugin.ts    | 26 +++++++-
 backend/src/utils.ts                          |  5 ++
 22 files changed, 508 insertions(+), 16 deletions(-)
 create mode 100644 backend/src/plugins/Automod/actions/addRoles.ts
 create mode 100644 backend/src/plugins/Automod/actions/alert.ts
 create mode 100644 backend/src/plugins/Automod/actions/ban.ts
 create mode 100644 backend/src/plugins/Automod/actions/changeNickname.ts
 create mode 100644 backend/src/plugins/Automod/actions/exampleAction.ts
 create mode 100644 backend/src/plugins/Automod/actions/kick.ts
 create mode 100644 backend/src/plugins/Automod/actions/log.ts
 create mode 100644 backend/src/plugins/Automod/actions/mute.ts
 create mode 100644 backend/src/plugins/Automod/actions/removeRoles.ts
 create mode 100644 backend/src/plugins/Automod/actions/reply.ts
 create mode 100644 backend/src/plugins/Automod/actions/setAntiraidLevel.ts
 create mode 100644 backend/src/plugins/Automod/actions/warn.ts
 create mode 100644 backend/src/plugins/Automod/functions/clearOldNicknameChanges.ts
 create mode 100644 backend/src/plugins/Automod/functions/resolveActionContactMethods.ts
 create mode 100644 backend/src/plugins/Automod/functions/setAntiraidLevel.ts

diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts
index 9c5ce269..3edffc37 100644
--- a/backend/src/plugins/Automod/AutomodPlugin.ts
+++ b/backend/src/plugins/Automod/AutomodPlugin.ts
@@ -15,6 +15,7 @@ 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: {
@@ -94,9 +95,9 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
           }
         }
 
-        // if (rule["actions"]["log"] == null) {
-        //   rule["actions"]["log"] = true;
-        // }
+        if (rule["actions"]["log"] == null) {
+          rule["actions"]["log"] = true;
+        }
       }
     }
   }
@@ -123,6 +124,14 @@ export const AutomodPlugin = zeppelinPlugin<AutomodPluginType>()("automod", {
     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);
@@ -142,6 +151,8 @@ export const AutomodPlugin = zeppelinPlugin<AutomodPluginType>()("automod", {
 
     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/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/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<string, AutomodContext[]>, 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
index 3ebc278c..a2445c3e 100644
--- a/backend/src/plugins/Automod/constants.ts
+++ b/backend/src/plugins/Automod/constants.ts
@@ -2,6 +2,7 @@ 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,
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<AutomodPluginType>) {
+  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/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<AutomodPluginType>,
+  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
index f3bcde40..428acf6e 100644
--- a/backend/src/plugins/Automod/functions/runAutomod.ts
+++ b/backend/src/plugins/Automod/functions/runAutomod.ts
@@ -25,7 +25,6 @@ export async function runAutomod(pluginData: PluginData<AutomodPluginType>, cont
     if (!rule.affects_bots && user.bot) continue;
 
     let matchResult: AutomodTriggerMatchResult<any>;
-    let matchSummary: string;
     let contexts: AutomodContext[];
 
     triggerLoop: for (const triggerItem of rule.triggers) {
@@ -45,17 +44,7 @@ export async function runAutomod(pluginData: PluginData<AutomodPluginType>, cont
             _context.actioned = true;
           }
 
-          if (matchResult.silentClean) {
-            await CleanAction.apply({
-              ruleName,
-              pluginData,
-              contexts,
-              actionConfig: true,
-            });
-            return;
-          }
-
-          matchSummary = await trigger.renderMatchInformation({
+          matchResult.summary = await trigger.renderMatchInformation({
             ruleName,
             pluginData,
             contexts,
@@ -63,6 +52,17 @@ export async function runAutomod(pluginData: PluginData<AutomodPluginType>, cont
             triggerConfig,
           });
 
+          if (matchResult.silentClean) {
+            await CleanAction.apply({
+              ruleName,
+              pluginData,
+              contexts,
+              actionConfig: true,
+              matchResult,
+            });
+            return;
+          }
+
           break triggerLoop;
         }
       }
@@ -76,6 +76,7 @@ export async function runAutomod(pluginData: PluginData<AutomodPluginType>, cont
           pluginData,
           contexts,
           actionConfig,
+          matchResult,
         });
       }
 
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<AutomodPluginType>,
+  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/helpers.ts b/backend/src/plugins/Automod/helpers.ts
index e654e1ef..0dcce48f 100644
--- a/backend/src/plugins/Automod/helpers.ts
+++ b/backend/src/plugins/Automod/helpers.ts
@@ -8,6 +8,8 @@ export interface AutomodTriggerMatchResult<TExtra extends any = unknown> {
   extra?: TExtra;
 
   silentClean?: boolean; // TODO: Maybe generalize to a "silent" value in general, which mutes alert/log
+
+  summary?: string;
 }
 
 type AutomodTriggerMatchFn<TConfigType, TMatchResultExtra> = (meta: {
@@ -54,6 +56,7 @@ type AutomodActionApplyFn<TConfigType> = (meta: {
   pluginData: PluginData<AutomodPluginType>;
   contexts: AutomodContext[];
   actionConfig: TConfigType;
+  matchResult: AutomodTriggerMatchResult;
 }) => Awaitable<void>;
 
 export interface AutomodActionBlueprint<TConfigType extends t.Any> {
diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts
index 43c95031..f77028e6 100644
--- a/backend/src/plugins/Automod/types.ts
+++ b/backend/src/plugins/Automod/types.ts
@@ -55,6 +55,11 @@ export interface AutomodPluginType extends BasePluginType {
     recentSpam: RecentSpam[];
     clearRecentSpamInterval: Timeout;
 
+    recentNicknameChanges: Map<string, { timestamp: number }>;
+    clearRecentNicknameChangesInterval: Timeout;
+
+    cachedAntiraidLevel: string | null;
+
     savedMessages: GuildSavedMessages;
     logs: GuildLogs;
     antiraidLevels: GuildAntiraidLevels;
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<ModActionsPluginType>()("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/utils.ts b/backend/src/utils.ts
index 2f45fb7f..831129f4 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -34,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;
 
@@ -1222,3 +1223,7 @@ export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message {
 export function isGuildInvite(invite: AnyInvite): invite is GuildInvite {
   return (invite as GuildInvite).guild != null;
 }
+
+export function asyncMap<T, R>(arr: T[], fn: (item: T) => Promise<R>): Promise<R[]> {
+  return Promise.all(arr.map((item, index) => fn(item)));
+}