From 062cb053cc3861273445f596d7e3df74468903ad Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 18 Nov 2023 12:55:01 +0200
Subject: [PATCH 01/51] fix: crash on tag round() with >100 decimals

---
 backend/src/templateFormatter.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/templateFormatter.ts b/backend/src/templateFormatter.ts
index 11c17925..abe42fe0 100644
--- a/backend/src/templateFormatter.ts
+++ b/backend/src/templateFormatter.ts
@@ -419,7 +419,7 @@ const baseValues = {
   },
   round(arg, decimals = 0) {
     if (isNaN(arg)) return 0;
-    return decimals === 0 ? Math.round(arg) : arg.toFixed(decimals);
+    return decimals === 0 ? Math.round(arg) : arg.toFixed(Math.max(0, Math.min(decimals, 100)));
   },
   add(...args) {
     return args.reduce((result, arg) => {

From 9fd2bf4edb71d2c21b1e8af256361a9dc69d825a Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 18 Nov 2023 12:57:54 +0200
Subject: [PATCH 02/51] fix: crash on tag round() with non-numeric argument

---
 backend/src/templateFormatter.ts | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/backend/src/templateFormatter.ts b/backend/src/templateFormatter.ts
index abe42fe0..e51506c4 100644
--- a/backend/src/templateFormatter.ts
+++ b/backend/src/templateFormatter.ts
@@ -418,7 +418,10 @@ const baseValues = {
     return Math.round(randValue * (to - from) + from);
   },
   round(arg, decimals = 0) {
-    if (isNaN(arg)) return 0;
+    if (typeof arg !== "number") {
+      arg = parseFloat(arg);
+    }
+    if (Number.isNaN(arg)) return 0;
     return decimals === 0 ? Math.round(arg) : arg.toFixed(Math.max(0, Math.min(decimals, 100)));
   },
   add(...args) {

From c82e147ea163fcbf4ced78fe385e83560ccc9a5e Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 25 Nov 2023 12:15:32 +0200
Subject: [PATCH 03/51] feat: more robust tag error handling

---
 .../src/plugins/Tags/commands/TagEvalCmd.ts   | 14 ++++++++++----
 .../plugins/Tags/util/renderTagFromString.ts  | 19 ++++++++++++-------
 2 files changed, 22 insertions(+), 11 deletions(-)

diff --git a/backend/src/plugins/Tags/commands/TagEvalCmd.ts b/backend/src/plugins/Tags/commands/TagEvalCmd.ts
index 55659d30..27cbe4f3 100644
--- a/backend/src/plugins/Tags/commands/TagEvalCmd.ts
+++ b/backend/src/plugins/Tags/commands/TagEvalCmd.ts
@@ -5,6 +5,7 @@ import { TemplateParseError } from "../../../templateFormatter";
 import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { tagsCmd } from "../types";
 import { renderTagBody } from "../util/renderTagBody";
+import { logger } from "../../../logger";
 
 export const TagEvalCmd = tagsCmd({
   trigger: "tag eval",
@@ -34,12 +35,17 @@ export const TagEvalCmd = tagsCmd({
 
       msg.channel.send(rendered);
     } catch (e) {
-      if (e instanceof TemplateParseError) {
-        sendErrorMessage(pluginData, msg.channel, `Failed to render tag: ${e.message}`);
-        return;
+      const errorMessage = e instanceof TemplateParseError
+        ? e.message
+        : "Internal error";
+
+      sendErrorMessage(pluginData, msg.channel, `Failed to render tag: ${errorMessage}`);
+
+      if (! (e instanceof TemplateParseError)) {
+        logger.warn(`Internal error evaluating tag in ${pluginData.guild.id}: ${e}`);
       }
 
-      throw e;
+      return;
     }
   },
 });
diff --git a/backend/src/plugins/Tags/util/renderTagFromString.ts b/backend/src/plugins/Tags/util/renderTagFromString.ts
index 55e699fa..f1265858 100644
--- a/backend/src/plugins/Tags/util/renderTagFromString.ts
+++ b/backend/src/plugins/Tags/util/renderTagFromString.ts
@@ -7,6 +7,7 @@ import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../uti
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TTag, TagsPluginType } from "../types";
 import { renderTagBody } from "./renderTagBody";
+import { logger } from "../../../logger";
 
 export async function renderTagFromString(
   pluginData: GuildPluginData<TagsPluginType>,
@@ -34,14 +35,18 @@ export async function renderTagFromString(
 
     return validateAndParseMessageContent(rendered);
   } catch (e) {
-    if (e instanceof TemplateParseError) {
-      const logs = pluginData.getPlugin(LogsPlugin);
-      logs.logBotAlert({
-        body: `Failed to render tag \`${prefix}${tagName}\`: ${e.message}`,
-      });
-      return null;
+    const logs = pluginData.getPlugin(LogsPlugin);
+    const errorMessage = e instanceof TemplateParseError
+      ? e.message
+      : "Internal error";
+    logs.logBotAlert({
+      body: `Failed to render tag \`${prefix}${tagName}\`: ${errorMessage}`,
+    });
+
+    if (! (e instanceof TemplateParseError)) {
+      logger.warn(`Internal error rendering tag ${tagName} in ${pluginData.guild.id}: ${e}`);
     }
 
-    throw e;
+    return null;
   }
 }

From fafaefa1fbafddf9216d2f5ddaad0177cf3fb4bc Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 25 Nov 2023 12:28:28 +0200
Subject: [PATCH 04/51] feat: limit logs timestamp_format length

---
 backend/src/plugins/Logs/LogsPlugin.ts |  5 +++--
 backend/src/plugins/Logs/types.ts      |  5 +++--
 backend/src/utils/iotsUtils.ts         | 17 +++++++++++++++++
 3 files changed, 23 insertions(+), 4 deletions(-)
 create mode 100644 backend/src/utils/iotsUtils.ts

diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts
index bd970160..e56ba756 100644
--- a/backend/src/plugins/Logs/LogsPlugin.ts
+++ b/backend/src/plugins/Logs/LogsPlugin.ts
@@ -110,6 +110,7 @@ import { logVoiceChannelForceMove } from "./logFunctions/logVoiceChannelForceMov
 import { logVoiceChannelJoin } from "./logFunctions/logVoiceChannelJoin";
 import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave";
 import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove";
+import { asBoundedString } from "../../utils/iotsUtils";
 
 // The `any` cast here is to prevent TypeScript from locking up from the circular dependency
 function getCasesPlugin(): Promise<any> {
@@ -120,12 +121,12 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
   config: {
     channels: {},
     format: {
-      timestamp: FORMAT_NO_TIMESTAMP, // Legacy/deprecated, use timestamp_format below instead
+      timestamp: asBoundedString(FORMAT_NO_TIMESTAMP), // Legacy/deprecated, use timestamp_format below instead
       ...DefaultLogMessages,
     },
     ping_user: true, // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
     allow_user_mentions: false,
-    timestamp_format: "[<t:]X[>]",
+    timestamp_format: asBoundedString("[<t:]X[>]"),
     include_embed_timestamp: true,
   },
 
diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts
index 62008aab..759f680d 100644
--- a/backend/src/plugins/Logs/types.ts
+++ b/backend/src/plugins/Logs/types.ts
@@ -23,6 +23,7 @@ import {
   TemplateSafeUser,
 } from "../../utils/templateSafeObjects";
 import { TRegex } from "../../validatorUtils";
+import { tBoundedString } from "../../utils/iotsUtils";
 
 export const tLogFormats = t.record(t.string, t.union([t.string, tMessageContent]));
 export type TLogFormats = t.TypeOf<typeof tLogFormats>;
@@ -53,12 +54,12 @@ export const ConfigSchema = t.type({
   format: t.intersection([
     tLogFormats,
     t.type({
-      timestamp: t.string, // Legacy/deprecated
+      timestamp: tBoundedString(0, 64), // Legacy/deprecated
     }),
   ]),
   ping_user: t.boolean, // Legacy/deprecated, if below is false mentions wont actually ping
   allow_user_mentions: t.boolean,
-  timestamp_format: t.string,
+  timestamp_format: tBoundedString(0, 64),
   include_embed_timestamp: t.boolean,
 });
 export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
diff --git a/backend/src/utils/iotsUtils.ts b/backend/src/utils/iotsUtils.ts
new file mode 100644
index 00000000..a3636c84
--- /dev/null
+++ b/backend/src/utils/iotsUtils.ts
@@ -0,0 +1,17 @@
+import * as t from "io-ts";
+
+interface BoundedStringBrand {
+  readonly BoundedString: unique symbol;
+}
+
+export function asBoundedString(str: string) {
+  return str as t.Branded<string, BoundedStringBrand>;
+}
+
+export function tBoundedString(min: number, max: number) {
+  return t.brand(
+    t.string,
+    (str): str is t.Branded<string, BoundedStringBrand> => (str.length >= min && str.length <= max),
+    "BoundedString",
+  );
+}

From c4a2be5ff5b93d012aa021c10f03a997903e4b32 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 13:11:38 +0000
Subject: [PATCH 05/51] resolveUserId support new usernames

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/utils.ts | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 1bb4d6ce..f29d990c 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1222,11 +1222,25 @@ export function resolveUserId(bot: Client, value: string) {
   }
 
   // A non-mention, full username?
-  const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
+  const oldUsernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
+  if (oldUsernameMatch) {
+    const profiler = getProfiler();
+    const start = performance.now();
+    const user = bot.users.cache.find(
+      (u) => u.username === oldUsernameMatch[1] && u.discriminator === oldUsernameMatch[2],
+    );
+    profiler?.addDataPoint("utils:resolveUserId:usernameMatch", performance.now() - start);
+    if (user) {
+      return user.id;
+    }
+  }
+
+  // new usernames system
+  const usernameMatch = value.match(/^@?([^#]+)$/);
   if (usernameMatch) {
     const profiler = getProfiler();
     const start = performance.now();
-    const user = bot.users.cache.find((u) => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]);
+    const user = bot.users.cache.find((u) => u.username === usernameMatch[1]);
     profiler?.addDataPoint("utils:resolveUserId:usernameMatch", performance.now() - start);
     if (user) {
       return user.id;

From 43076b3db68bafed52e0963914ebf8a027dcdcba Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 13:16:44 +0000
Subject: [PATCH 06/51] update djs

Signed-off-by: GitHub <noreply@github.com>
---
 backend/package-lock.json                     | 289 +++++++-----------
 backend/package.json                          |   2 +-
 .../plugins/Automod/actions/startThread.ts    |  13 +-
 3 files changed, 122 insertions(+), 182 deletions(-)

diff --git a/backend/package-lock.json b/backend/package-lock.json
index b1d6d189..4c6eaa14 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -14,7 +14,7 @@
         "cors": "^2.8.5",
         "cross-env": "^7.0.3",
         "deep-diff": "^1.0.2",
-        "discord.js": "^14.11.0",
+        "discord.js": "^14.14.1",
         "dotenv": "^4.0.0",
         "emoji-regex": "^8.0.0",
         "erlpack": "github:discord/erlpack",
@@ -265,84 +265,109 @@
       }
     },
     "node_modules/@discordjs/builders": {
-      "version": "1.6.3",
-      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.3.tgz",
-      "integrity": "sha512-CTCh8NqED3iecTNuiz49mwSsrc2iQb4d0MjMdmS/8pb69Y4IlzJ/DIy/p5GFlgOrFbNO2WzMHkWKQSiJ3VNXaw==",
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.7.0.tgz",
+      "integrity": "sha512-GDtbKMkg433cOZur8Dv6c25EHxduNIBsxeHrsRoIM8+AwmEZ8r0tEpckx/sHwTLwQPOF3e2JWloZh9ofCaMfAw==",
       "dependencies": {
-        "@discordjs/formatters": "^0.3.1",
-        "@discordjs/util": "^0.3.1",
-        "@sapphire/shapeshift": "^3.8.2",
-        "discord-api-types": "^0.37.41",
+        "@discordjs/formatters": "^0.3.3",
+        "@discordjs/util": "^1.0.2",
+        "@sapphire/shapeshift": "^3.9.3",
+        "discord-api-types": "0.37.61",
         "fast-deep-equal": "^3.1.3",
         "ts-mixer": "^6.0.3",
-        "tslib": "^2.5.0"
+        "tslib": "^2.6.2"
       },
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
       }
     },
     "node_modules/@discordjs/collection": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.1.tgz",
-      "integrity": "sha512-aWEc9DCf3TMDe9iaJoOnO2+JVAjeRNuRxPZQA6GVvBf+Z3gqUuWYBy2NWh4+5CLYq5uoc3MOvUQ5H5m8CJBqOA==",
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
+      "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
       }
     },
     "node_modules/@discordjs/formatters": {
-      "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.1.tgz",
-      "integrity": "sha512-M7X4IGiSeh4znwcRGcs+49B5tBkNDn4k5bmhxJDAUhRxRHTiFAOTVUNQ6yAKySu5jZTnCbSvTYHW3w0rAzV1MA==",
+      "version": "0.3.3",
+      "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.3.tgz",
+      "integrity": "sha512-wTcI1Q5cps1eSGhl6+6AzzZkBBlVrBdc9IUhJbijRgVjCNIIIZPgqnUj3ntFODsHrdbGU8BEG9XmDQmgEEYn3w==",
       "dependencies": {
-        "discord-api-types": "^0.37.41"
+        "discord-api-types": "0.37.61"
       },
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
       }
     },
     "node_modules/@discordjs/rest": {
-      "version": "1.7.1",
-      "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.7.1.tgz",
-      "integrity": "sha512-Ofa9UqT0U45G/eX86cURQnX7gzOJLG2oC28VhIk/G6IliYgQF7jFByBJEykPSHE4MxPhqCleYvmsrtfKh1nYmQ==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.2.0.tgz",
+      "integrity": "sha512-nXm9wT8oqrYFRMEqTXQx9DUTeEtXUDMmnUKIhZn6O2EeDY9VCdwj23XCPq7fkqMPKdF7ldAfeVKyxxFdbZl59A==",
       "dependencies": {
-        "@discordjs/collection": "^1.5.1",
-        "@discordjs/util": "^0.3.0",
+        "@discordjs/collection": "^2.0.0",
+        "@discordjs/util": "^1.0.2",
         "@sapphire/async-queue": "^1.5.0",
-        "@sapphire/snowflake": "^3.4.2",
-        "discord-api-types": "^0.37.41",
-        "file-type": "^18.3.0",
-        "tslib": "^2.5.0",
-        "undici": "^5.22.0"
+        "@sapphire/snowflake": "^3.5.1",
+        "@vladfrangu/async_event_emitter": "^2.2.2",
+        "discord-api-types": "0.37.61",
+        "magic-bytes.js": "^1.5.0",
+        "tslib": "^2.6.2",
+        "undici": "5.27.2"
       },
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
+      }
+    },
+    "node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.0.0.tgz",
+      "integrity": "sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==",
+      "engines": {
+        "node": ">=18"
       }
     },
     "node_modules/@discordjs/util": {
-      "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz",
-      "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.0.2.tgz",
+      "integrity": "sha512-IRNbimrmfb75GMNEjyznqM1tkI7HrZOf14njX7tCAAUetyZM1Pr8hX/EK2lxBCOgWDRmigbp24fD1hdMfQK5lw==",
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
       }
     },
     "node_modules/@discordjs/ws": {
-      "version": "0.8.3",
-      "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-0.8.3.tgz",
-      "integrity": "sha512-hcYtppanjHecbdNyCKQNH2I4RP9UrphDgmRgLYrATEQF1oo4sYSve7ZmGsBEXSzH72MO2tBPdWSThunbxUVk0g==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.0.2.tgz",
+      "integrity": "sha512-+XI82Rm2hKnFwAySXEep4A7Kfoowt6weO6381jgW+wVdTpMS/56qCvoXyFRY0slcv7c/U8My2PwIB2/wEaAh7Q==",
       "dependencies": {
-        "@discordjs/collection": "^1.5.1",
-        "@discordjs/rest": "^1.7.1",
-        "@discordjs/util": "^0.3.1",
+        "@discordjs/collection": "^2.0.0",
+        "@discordjs/rest": "^2.1.0",
+        "@discordjs/util": "^1.0.2",
         "@sapphire/async-queue": "^1.5.0",
-        "@types/ws": "^8.5.4",
-        "@vladfrangu/async_event_emitter": "^2.2.1",
-        "discord-api-types": "^0.37.41",
-        "tslib": "^2.5.0",
-        "ws": "^8.13.0"
+        "@types/ws": "^8.5.9",
+        "@vladfrangu/async_event_emitter": "^2.2.2",
+        "discord-api-types": "0.37.61",
+        "tslib": "^2.6.2",
+        "ws": "^8.14.2"
       },
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
+      }
+    },
+    "node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.0.0.tgz",
+      "integrity": "sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@fastify/busboy": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz",
+      "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==",
+      "engines": {
+        "node": ">=14"
       }
     },
     "node_modules/@jest/types": {
@@ -416,9 +441,9 @@
       }
     },
     "node_modules/@sapphire/shapeshift": {
-      "version": "3.9.2",
-      "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz",
-      "integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==",
+      "version": "3.9.3",
+      "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.3.tgz",
+      "integrity": "sha512-WzKJSwDYloSkHoBbE8rkRW8UNKJiSRJ/P8NqJ5iVq7U2Yr/kriIBx2hW+wj2Z5e5EnXL1hgYomgaFsdK6b+zqQ==",
       "dependencies": {
         "fast-deep-equal": "^3.1.3",
         "lodash": "^4.17.21"
@@ -499,11 +524,6 @@
         "yarn": ">= 1.3.2"
       }
     },
-    "node_modules/@tokenizer/token": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
-      "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
-    },
     "node_modules/@types/body-parser": {
       "version": "1.19.2",
       "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
@@ -769,9 +789,9 @@
       "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
     },
     "node_modules/@types/ws": {
-      "version": "8.5.5",
-      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz",
-      "integrity": "sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==",
+      "version": "8.5.9",
+      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.9.tgz",
+      "integrity": "sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==",
       "dependencies": {
         "@types/node": "*"
       }
@@ -3257,32 +3277,32 @@
       }
     },
     "node_modules/discord-api-types": {
-      "version": "0.37.47",
-      "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.47.tgz",
-      "integrity": "sha512-rNif8IAv6duS2z47BMXq/V9kkrLfkAoiwpFY3sLxxbyKprk065zqf3HLTg4bEoxRSmi+Lhc7yqGDrG8C3j8GFA=="
+      "version": "0.37.61",
+      "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.61.tgz",
+      "integrity": "sha512-o/dXNFfhBpYHpQFdT6FWzeO7pKc838QeeZ9d91CfVAtpr5XLK4B/zYxQbYgPdoMiTDvJfzcsLW5naXgmHGDNXw=="
     },
     "node_modules/discord.js": {
-      "version": "14.11.0",
-      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz",
-      "integrity": "sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ==",
+      "version": "14.14.1",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.14.1.tgz",
+      "integrity": "sha512-/hUVzkIerxKHyRKopJy5xejp4MYKDPTszAnpYxzVVv4qJYf+Tkt+jnT2N29PIPschicaEEpXwF2ARrTYHYwQ5w==",
       "dependencies": {
-        "@discordjs/builders": "^1.6.3",
-        "@discordjs/collection": "^1.5.1",
-        "@discordjs/formatters": "^0.3.1",
-        "@discordjs/rest": "^1.7.1",
-        "@discordjs/util": "^0.3.1",
-        "@discordjs/ws": "^0.8.3",
-        "@sapphire/snowflake": "^3.4.2",
-        "@types/ws": "^8.5.4",
-        "discord-api-types": "^0.37.41",
-        "fast-deep-equal": "^3.1.3",
-        "lodash.snakecase": "^4.1.1",
-        "tslib": "^2.5.0",
-        "undici": "^5.22.0",
-        "ws": "^8.13.0"
+        "@discordjs/builders": "^1.7.0",
+        "@discordjs/collection": "1.5.3",
+        "@discordjs/formatters": "^0.3.3",
+        "@discordjs/rest": "^2.1.0",
+        "@discordjs/util": "^1.0.2",
+        "@discordjs/ws": "^1.0.2",
+        "@sapphire/snowflake": "3.5.1",
+        "@types/ws": "8.5.9",
+        "discord-api-types": "0.37.61",
+        "fast-deep-equal": "3.1.3",
+        "lodash.snakecase": "4.1.1",
+        "tslib": "2.6.2",
+        "undici": "5.27.2",
+        "ws": "8.14.2"
       },
       "engines": {
-        "node": ">=16.9.0"
+        "node": ">=16.11.0"
       }
     },
     "node_modules/distributions": {
@@ -3925,22 +3945,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/file-type": {
-      "version": "18.5.0",
-      "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz",
-      "integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==",
-      "dependencies": {
-        "readable-web-to-node-stream": "^3.0.2",
-        "strtok3": "^7.0.0",
-        "token-types": "^5.0.1"
-      },
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "url": "https://github.com/sindresorhus/file-type?sponsor=1"
-      }
-    },
     "node_modules/file-uri-to-path": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -5769,6 +5773,11 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/magic-bytes.js": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.5.0.tgz",
+      "integrity": "sha512-wJkXvutRbNWcc37tt5j1HyOK1nosspdh3dj6LUYYAvF6JYNqs53IfRvK9oEpcwiDA1NdoIi64yAMfdivPeVAyw=="
+    },
     "node_modules/magic-string": {
       "version": "0.25.1",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.1.tgz",
@@ -7118,18 +7127,6 @@
         "node": ">=0.12"
       }
     },
-    "node_modules/peek-readable": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz",
-      "integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==",
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
     "node_modules/performance-now": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -7706,34 +7703,6 @@
         "safe-buffer": "~5.1.0"
       }
     },
-    "node_modules/readable-web-to-node-stream": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
-      "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
-      "dependencies": {
-        "readable-stream": "^3.6.0"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
-    "node_modules/readable-web-to-node-stream/node_modules/readable-stream": {
-      "version": "3.6.2",
-      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-      "dependencies": {
-        "inherits": "^2.0.3",
-        "string_decoder": "^1.1.1",
-        "util-deprecate": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
     "node_modules/readdirp": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -8873,22 +8842,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/strtok3": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.0.0.tgz",
-      "integrity": "sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==",
-      "dependencies": {
-        "@tokenizer/token": "^0.3.0",
-        "peek-readable": "^5.0.0"
-      },
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
     "node_modules/subarg": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz",
@@ -9151,22 +9104,6 @@
         "node": ">=0.6"
       }
     },
-    "node_modules/token-types": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz",
-      "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==",
-      "dependencies": {
-        "@tokenizer/token": "^0.3.0",
-        "ieee754": "^1.2.1"
-      },
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
     "node_modules/tough-cookie": {
       "version": "4.1.3",
       "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
@@ -9276,9 +9213,9 @@
       }
     },
     "node_modules/tslib": {
-      "version": "2.6.0",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
-      "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
     },
     "node_modules/ttest": {
       "version": "3.0.0",
@@ -9716,11 +9653,11 @@
       "integrity": "sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA=="
     },
     "node_modules/undici": {
-      "version": "5.22.1",
-      "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz",
-      "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==",
+      "version": "5.27.2",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-5.27.2.tgz",
+      "integrity": "sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==",
       "dependencies": {
-        "busboy": "^1.6.0"
+        "@fastify/busboy": "^2.0.0"
       },
       "engines": {
         "node": ">=14.0"
@@ -10091,9 +10028,9 @@
       }
     },
     "node_modules/ws": {
-      "version": "8.13.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
-      "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
+      "version": "8.14.2",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
+      "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
       "engines": {
         "node": ">=10.0.0"
       },
diff --git a/backend/package.json b/backend/package.json
index 2ff53482..44101e51 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -35,7 +35,7 @@
     "cors": "^2.8.5",
     "cross-env": "^7.0.3",
     "deep-diff": "^1.0.2",
-    "discord.js": "^14.11.0",
+    "discord.js": "^14.14.1",
     "dotenv": "^4.0.0",
     "emoji-regex": "^8.0.0",
     "erlpack": "github:discord/erlpack",
diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 25b840e1..66d5ec3a 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -57,7 +57,13 @@ export const StartThreadAction = automodAction({
 
     for (const threadContext of threads) {
       const channel = pluginData.guild.channels.cache.get(threadContext.message!.channel_id);
-      if (!channel || !("threads" in channel) || channel.type === ChannelType.GuildForum) continue;
+      if (
+        !channel ||
+        !("threads" in channel) ||
+        channel.type === ChannelType.GuildForum ||
+        channel.type === ChannelType.GuildMedia
+      )
+        continue;
 
       const renderThreadName = async (str: string) =>
         renderTemplate(
@@ -90,10 +96,7 @@ export const StartThreadAction = automodAction({
           .create({
             ...threadOptions,
             type: actionConfig.private ? ChannelType.PrivateThread : ChannelType.PublicThread,
-            startMessage:
-              !actionConfig.private && guild.features.includes(GuildFeature.PrivateThreads)
-                ? threadContext.message!.id
-                : undefined,
+            startMessage: !actionConfig.private ? threadContext.message!.id : undefined,
           })
           .catch(() => undefined);
       }

From f1791fac4452d63205fda3f0130cec1d46b96bdd Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 13:25:58 +0000
Subject: [PATCH 07/51] redo username check, yeet discrims entirely

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/utils.ts | 20 +++-----------------
 1 file changed, 3 insertions(+), 17 deletions(-)

diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index f29d990c..af3b5ad8 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1221,26 +1221,12 @@ export function resolveUserId(bot: Client, value: string) {
     return mentionMatch[1];
   }
 
-  // A non-mention, full username?
-  const oldUsernameMatch = value.match(/^@?([^#]+)#(\d{4})$/);
-  if (oldUsernameMatch) {
-    const profiler = getProfiler();
-    const start = performance.now();
-    const user = bot.users.cache.find(
-      (u) => u.username === oldUsernameMatch[1] && u.discriminator === oldUsernameMatch[2],
-    );
-    profiler?.addDataPoint("utils:resolveUserId:usernameMatch", performance.now() - start);
-    if (user) {
-      return user.id;
-    }
-  }
-
-  // new usernames system
-  const usernameMatch = value.match(/^@?([^#]+)$/);
+  // a username
+  const usernameMatch = value.match(/^@?(\S{3,})$/);
   if (usernameMatch) {
     const profiler = getProfiler();
     const start = performance.now();
-    const user = bot.users.cache.find((u) => u.username === usernameMatch[1]);
+    const user = bot.users.cache.find((u) => u.tag === usernameMatch[1]);
     profiler?.addDataPoint("utils:resolveUserId:usernameMatch", performance.now() - start);
     if (user) {
       return user.id;

From 4c788bc321795d6d4cfb160945017d9a3475ceed Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 13:38:42 +0000
Subject: [PATCH 08/51] some transforms

Signed-off-by: GitHub <noreply@github.com>
---
 .../Automod/functions/matchMultipleTextTypesOnMessage.ts   | 2 +-
 .../plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts  | 7 +++----
 .../src/plugins/Utility/functions/getInviteInfoEmbed.ts    | 2 +-
 .../src/plugins/Utility/functions/getMessageInfoEmbed.ts   | 2 +-
 backend/src/plugins/Utility/functions/getUserInfoEmbed.ts  | 7 +++----
 backend/src/utils.ts                                       | 7 +++++--
 backend/src/utils/templateSafeObjects.ts                   | 2 ++
 7 files changed, 16 insertions(+), 13 deletions(-)

diff --git a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts
index 9e51c490..f0eee5ec 100644
--- a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts
+++ b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts
@@ -42,7 +42,7 @@ export async function* matchMultipleTextTypesOnMessage(
   }
 
   if (trigger.match_visible_names) {
-    yield ["visiblename", member.nickname || msg.data.author.username];
+    yield ["visiblename", member.nickname || member.user.globalName || msg.data.author.username];
   }
 
   if (trigger.match_usernames) {
diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts
index 13a72a87..f7350b5f 100644
--- a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts
+++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts
@@ -68,10 +68,9 @@ export const ArchiveChannelCmd = channelArchiverCmd({
 
       for (const message of messages.values()) {
         const ts = moment.utc(message.createdTimestamp).format("YYYY-MM-DD HH:mm:ss");
-        let content = `[${ts}] [${message.author.id}] [${renderUsername(
-          message.author.username,
-          message.author.discriminator,
-        )}]: ${message.content || "<no text content>"}`;
+        let content = `[${ts}] [${message.author.id}] [${renderUsername(message.author)}]: ${
+          message.content || "<no text content>"
+        }`;
 
         if (message.attachments.size) {
           if (args["attachment-channel"]) {
diff --git a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts
index 12c97a8a..b844b937 100644
--- a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts
@@ -85,7 +85,7 @@ export async function getInviteInfoEmbed(
       embed.fields.push({
         name: preEmbedPadding + "Invite creator",
         value: trimLines(`
-          Name: **${renderUsername(invite.inviter.username, invite.inviter.discriminator)}**
+          Name: **${renderUsername(invite.inviter)}**
           ID: \`${invite.inviter.id}\`
           Mention: <@!${invite.inviter.id}>
         `),
diff --git a/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts b/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
index 1568a3e3..3a49a80d 100644
--- a/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
@@ -71,7 +71,7 @@ export async function getMessageInfoEmbed(
   embed.fields.push({
     name: preEmbedPadding + "Author information",
     value: trimLines(`
-      Name: **${renderUsername(message.author.username, message.author.discriminator)}**
+      Name: **${renderUsername(message.author)}**
       ID: \`${message.author.id}\`
       Created: **<t:${Math.round(message.author.createdTimestamp / 1000)}:R>**
       ${authorJoinedAtTS ? `Joined: **<t:${Math.round(authorJoinedAtTS / 1000)}:R>**` : ""}
diff --git a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
index ef8d2320..2de8b4db 100644
--- a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
@@ -43,7 +43,7 @@ export async function getUserInfoEmbed(
   const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
 
   embed.author = {
-    name: `${user.bot ? "Bot" : "User"}:  ${renderUsername(user.username, user.discriminator)}`,
+    name: `${user.bot ? "Bot" : "User"}:  ${renderUsername(user)}`,
   };
 
   const avatarURL = user.displayAvatarURL();
@@ -72,9 +72,8 @@ export async function getUserInfoEmbed(
   }
 
   const userInfoLines = [`ID: \`${user.id}\``, `Username: **${user.username}**`];
-  if (user.discriminator !== "0") {
-    userInfoLines.push(`Discriminator: **${user.discriminator}**`);
-  }
+  if (user.discriminator !== "0") userInfoLines.push(`Discriminator: **${user.discriminator}**`);
+  if (user.globalName) userInfoLines.push(`Display Name: **${user.globalName}**`);
   userInfoLines.push(`Created: **<t:${Math.round(user.createdTimestamp / 1000)}:R>**`);
   userInfoLines.push(`Mention: <@!${user.id}>`);
 
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index af3b5ad8..ee6b556c 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1603,8 +1603,11 @@ export function isTruthy<T>(value: T): value is Exclude<T, false | null | undefi
 
 export const DBDateFormat = "YYYY-MM-DD HH:mm:ss";
 
-export function renderUsername(username: string, discriminator: string): string {
-  if (discriminator === "0") {
+export function renderUsername(username: User): string;
+export function renderUsername(username: string, discriminator?: string): string;
+export function renderUsername(username: string | User, discriminator?: string): string {
+  if (username instanceof User) return username.tag;
+  if (discriminator === "0" || discriminator === "0000") {
     return username;
   }
   return `${username}#${discriminator}`;
diff --git a/backend/src/utils/templateSafeObjects.ts b/backend/src/utils/templateSafeObjects.ts
index 67517f19..fbd27754 100644
--- a/backend/src/utils/templateSafeObjects.ts
+++ b/backend/src/utils/templateSafeObjects.ts
@@ -49,6 +49,7 @@ export class TemplateSafeUser extends TemplateSafeValueContainer {
   id: Snowflake | string;
   username: string;
   discriminator: string;
+  globalName?: string;
   mention: string;
   tag: string;
   avatarURL?: string;
@@ -257,6 +258,7 @@ export function userToTemplateSafeUser(user: User | UnknownUser): TemplateSafeUs
     id: user.id,
     username: user.username,
     discriminator: user.discriminator,
+    globalName: user.globalName,
     mention: `<@${user.id}>`,
     tag: user.tag,
     avatarURL: user.displayAvatarURL?.(),

From 1579c3ec25398a5341af0b66a4f68626ddd92f70 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 13:40:05 +0000
Subject: [PATCH 09/51] disable status-search arg

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Utility/commands/SearchCmd.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/plugins/Utility/commands/SearchCmd.ts b/backend/src/plugins/Utility/commands/SearchCmd.ts
index 777bb890..fb4d2ad1 100644
--- a/backend/src/plugins/Utility/commands/SearchCmd.ts
+++ b/backend/src/plugins/Utility/commands/SearchCmd.ts
@@ -15,7 +15,7 @@ export const searchCmdSignature = {
   export: ct.switchOption({ def: false, shortcut: "e" }),
   ids: ct.switchOption(),
   regex: ct.switchOption({ def: false, shortcut: "re" }),
-  "status-search": ct.switchOption({ def: false, shortcut: "ss" }),
+  // "status-search": ct.switchOption({ def: false, shortcut: "ss" }),
 };
 
 export const SearchCmd = utilityCmd({

From c5704131cefd1b17da76ef179c2da81855c3fb25 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 13:44:42 +0000
Subject: [PATCH 10/51] support media channels

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts | 1 +
 backend/src/plugins/Utility/functions/getServerInfoEmbed.ts  | 5 +++++
 2 files changed, 6 insertions(+)

diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
index ee006f5f..d41e3e44 100644
--- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
@@ -56,6 +56,7 @@ export async function getChannelInfoEmbed(
       [ChannelType.AnnouncementThread]: "News Thread channel",
       [ChannelType.GuildDirectory]: "Hub channel",
       [ChannelType.GuildForum]: "Forum channel",
+      [ChannelType.GuildMedia]: "Media channel",
     }[channel.type] ?? "Channel";
 
   embed.author = {
diff --git a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
index d0202db7..afcefb80 100644
--- a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
@@ -149,12 +149,16 @@ export async function getServerInfoEmbed(
     const textChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildText);
     const voiceChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildVoice);
     const forumChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildForum);
+    const mediaChannels = thisServer.channels.cache.filter((channel) => channel.type === ChannelType.GuildMedia);
     const threadChannelsText = thisServer.channels.cache.filter(
       (channel) => channel.isThread() && channel.parent?.type !== ChannelType.GuildForum,
     );
     const threadChannelsForums = thisServer.channels.cache.filter(
       (channel) => channel.isThread() && channel.parent?.type === ChannelType.GuildForum,
     );
+    const threadChannelsMedia = thisServer.channels.cache.filter(
+      (channel) => channel.isThread() && channel.parent?.type === ChannelType.GuildMedia,
+    );
     const announcementChannels = thisServer.channels.cache.filter(
       (channel) => channel.type === ChannelType.GuildAnnouncement,
     );
@@ -169,6 +173,7 @@ export async function getServerInfoEmbed(
           Categories: **${categories.size}**
           Text: **${textChannels.size}** (**${threadChannelsText.size} threads**)
           Forums: **${forumChannels.size}** (**${threadChannelsForums.size} threads**)
+          Media: **${mediaChannels.size}** (**${threadChannelsMedia.size} threads**)
           Announcement: **${announcementChannels.size}**
           Voice: **${voiceChannels.size}**
           Stage: **${stageChannels.size}**

From 1e66f235b2f447ef07af2d17c1cda1108872a1dc Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 14:06:23 +0000
Subject: [PATCH 11/51] media channel icon

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
index d41e3e44..f601dd5c 100644
--- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
@@ -19,6 +19,8 @@ const PRIVATE_THREAD_ICON =
 const FORUM_CHANNEL_ICON =
   "https://cdn.discordapp.com/attachments/740650744830623756/1091681253364875294/forum-channel-icon.png";
 
+const MEDIA_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/876134205229252658/1178335624940490792/media.png";
+
 export async function getChannelInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   channelId: string,
@@ -42,6 +44,7 @@ export async function getChannelInfoEmbed(
       [ChannelType.PrivateThread]: PRIVATE_THREAD_ICON,
       [ChannelType.AnnouncementThread]: PUBLIC_THREAD_ICON,
       [ChannelType.GuildForum]: FORUM_CHANNEL_ICON,
+      [ChannelType.GuildMedia]: MEDIA_CHANNEL_ICON,
     }[channel.type] ?? TEXT_CHANNEL_ICON;
 
   const channelType =

From ba4a2b45b8c73fc1ac201365e7cf9bbf7b9ea8bf Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 14:09:34 +0000
Subject: [PATCH 12/51] remove useless feature check

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Automod/actions/startThread.ts | 13 ++-----------
 1 file changed, 2 insertions(+), 11 deletions(-)

diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 66d5ec3a..970e9ec8 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -1,10 +1,4 @@
-import {
-  ChannelType,
-  GuildFeature,
-  GuildTextThreadCreateOptions,
-  ThreadAutoArchiveDuration,
-  ThreadChannel,
-} from "discord.js";
+import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js";
 import * as t from "io-ts";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { MINUTES, convertDelayStringToMS, noop, tDelayString, tNullable } from "../../../utils";
@@ -77,10 +71,7 @@ export const StartThreadAction = automodAction({
       const threadOptions: GuildTextThreadCreateOptions<unknown> = {
         name: threadName,
         autoArchiveDuration: autoArchive,
-        startMessage:
-          !actionConfig.private && guild.features.includes(GuildFeature.PrivateThreads)
-            ? threadContext.message!.id
-            : undefined,
+        startMessage: !actionConfig.private ? threadContext.message!.id : undefined,
       };
 
       let thread: ThreadChannel | undefined;

From 10bb0b67bc22a3f8dfef1c65809a9035143ba51a Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 14:53:54 +0000
Subject: [PATCH 13/51] some more patches thanks to ruby

Signed-off-by: GitHub <noreply@github.com>
---
 .../plugins/Automod/triggers/threadArchive.ts |  4 ++--
 .../plugins/Automod/triggers/threadCreate.ts  |  3 ++-
 .../plugins/Automod/triggers/threadDelete.ts  |  3 ++-
 .../Automod/triggers/threadUnarchive.ts       |  4 ++--
 .../plugins/BotControl/commands/ServersCmd.ts |  6 +++--
 .../src/plugins/Cases/functions/createCase.ts |  8 +++----
 .../plugins/Cases/functions/createCaseNote.ts |  4 ++--
 .../InternalPoster/functions/sendMessage.ts   |  2 +-
 .../ModActions/commands/CasesModCmd.ts        | 12 +++++-----
 .../ModActions/commands/CasesUserCmd.ts       | 22 ++++++++++++++-----
 .../src/plugins/Post/util/actualPostCmd.ts    |  4 ++--
 .../util/createStarboardEmbedFromMessage.ts   |  6 ++---
 .../src/plugins/Utility/commands/AboutCmd.ts  |  4 ++--
 .../src/plugins/Utility/commands/AvatarCmd.ts |  8 +++----
 .../Utility/functions/getUserInfoEmbed.ts     |  5 +----
 backend/src/plugins/Utility/search.ts         | 10 ++++-----
 backend/src/utils.ts                          |  9 +++++---
 backend/src/utils/templateSafeObjects.ts      |  7 +++---
 18 files changed, 69 insertions(+), 52 deletions(-)

diff --git a/backend/src/plugins/Automod/triggers/threadArchive.ts b/backend/src/plugins/Automod/triggers/threadArchive.ts
index 0e65b10a..8a692f6d 100644
--- a/backend/src/plugins/Automod/triggers/threadArchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadArchive.ts
@@ -1,6 +1,6 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
 import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import { renderUsername, tNullable } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
 interface ThreadArchiveResult {
@@ -48,7 +48,7 @@ export const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     const base = `Thread **#${threadName}** (\`${threadId}\`) has been archived in the **#${parentName}** (\`${parentId}\`) channel`;
     if (threadOwner) {
-      return `${base} by **${escapeBold(threadOwner.tag)}** (\`${threadOwner.id}\`)`;
+      return `${base} by **${escapeBold(renderUsername(threadOwner.tag))}** (\`${threadOwner.id}\`)`;
     }
     return base;
   },
diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts
index 7b8aca71..dc613068 100644
--- a/backend/src/plugins/Automod/triggers/threadCreate.ts
+++ b/backend/src/plugins/Automod/triggers/threadCreate.ts
@@ -1,5 +1,6 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
 import * as t from "io-ts";
+import { renderUsername } from "../../../utils.js";
 import { automodTrigger } from "../helpers";
 
 interface ThreadCreateResult {
@@ -40,7 +41,7 @@ export const ThreadCreateTrigger = automodTrigger<ThreadCreateResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     const base = `Thread **#${threadName}** (\`${threadId}\`) has been created in the **#${parentName}** (\`${parentId}\`) channel`;
     if (threadOwner) {
-      return `${base} by **${escapeBold(threadOwner.tag)}** (\`${threadOwner.id}\`)`;
+      return `${base} by **${escapeBold(renderUsername(threadOwner.tag))}** (\`${threadOwner.id}\`)`;
     }
     return base;
   },
diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts
index 489b5b4c..f27a1d0b 100644
--- a/backend/src/plugins/Automod/triggers/threadDelete.ts
+++ b/backend/src/plugins/Automod/triggers/threadDelete.ts
@@ -1,5 +1,6 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
 import * as t from "io-ts";
+import { renderUsername } from "../../../utils.js";
 import { automodTrigger } from "../helpers";
 
 interface ThreadDeleteResult {
@@ -40,7 +41,7 @@ export const ThreadDeleteTrigger = automodTrigger<ThreadDeleteResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     if (threadOwner) {
       return `Thread **#${threadName ?? "Unknown"}** (\`${threadId}\`) created by **${escapeBold(
-        threadOwner.tag,
+        renderUsername(threadOwner.tag),
       )}** (\`${threadOwner.id}\`) in the **#${parentName}** (\`${parentId}\`) channel has been deleted`;
     }
     return `Thread **#${
diff --git a/backend/src/plugins/Automod/triggers/threadUnarchive.ts b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
index f6047f48..94c69ace 100644
--- a/backend/src/plugins/Automod/triggers/threadUnarchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
@@ -1,6 +1,6 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
 import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import { renderUsername, tNullable } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
 interface ThreadUnarchiveResult {
@@ -48,7 +48,7 @@ export const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     const base = `Thread **#${threadName}** (\`${threadId}\`) has been unarchived in the **#${parentName}** (\`${parentId}\`) channel`;
     if (threadOwner) {
-      return `${base} by **${escapeBold(threadOwner.tag)}** (\`${threadOwner.id}\`)`;
+      return `${base} by **${escapeBold(renderUsername(threadOwner.tag))}** (\`${threadOwner.id}\`)`;
     }
     return base;
   },
diff --git a/backend/src/plugins/BotControl/commands/ServersCmd.ts b/backend/src/plugins/BotControl/commands/ServersCmd.ts
index 23146d21..5cb62433 100644
--- a/backend/src/plugins/BotControl/commands/ServersCmd.ts
+++ b/backend/src/plugins/BotControl/commands/ServersCmd.ts
@@ -1,7 +1,7 @@
 import escapeStringRegexp from "escape-string-regexp";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { isStaffPreFilter } from "../../../pluginUtils";
-import { createChunkedMessage, getUser, sorter } from "../../../utils";
+import { createChunkedMessage, getUser, renderUsername, sorter } from "../../../utils";
 import { botControlCmd } from "../types";
 
 export const ServersCmd = botControlCmd({
@@ -48,7 +48,9 @@ export const ServersCmd = botControlCmd({
         const lines = filteredGuilds.map((g) => {
           const paddedId = g.id.padEnd(longestId, " ");
           const owner = getUser(pluginData.client, g.ownerId);
-          return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${owner.tag}** \`${owner.id}\`)`;
+          return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(owner.tag)}** \`${
+            owner.id
+          }\`)`;
         });
         createChunkedMessage(msg.channel, lines.join("\n"));
       } else {
diff --git a/backend/src/plugins/Cases/functions/createCase.ts b/backend/src/plugins/Cases/functions/createCase.ts
index c16d937b..70717f51 100644
--- a/backend/src/plugins/Cases/functions/createCase.ts
+++ b/backend/src/plugins/Cases/functions/createCase.ts
@@ -1,23 +1,23 @@
 import type { Snowflake } from "discord.js";
 import { GuildPluginData } from "knub";
 import { logger } from "../../../logger";
-import { renderUserUsername, resolveUser } from "../../../utils";
+import { renderUsername, resolveUser } from "../../../utils";
 import { CaseArgs, CasesPluginType } from "../types";
 import { createCaseNote } from "./createCaseNote";
 import { postCaseToCaseLogChannel } from "./postToCaseLogChannel";
 
 export async function createCase(pluginData: GuildPluginData<CasesPluginType>, args: CaseArgs) {
   const user = await resolveUser(pluginData.client, args.userId);
-  const userName = renderUserUsername(user);
+  const userName = renderUsername(user.username, user.discriminator);
 
   const mod = await resolveUser(pluginData.client, args.modId);
-  const modName = mod.tag;
+  const modName = renderUsername(mod.username, mod.discriminator);
 
   let ppName: string | null = null;
   let ppId: Snowflake | null = null;
   if (args.ppId) {
     const pp = await resolveUser(pluginData.client, args.ppId);
-    ppName = pp.tag;
+    ppName = renderUsername(pp.username, pp.discriminator);
     ppId = pp.id;
   }
 
diff --git a/backend/src/plugins/Cases/functions/createCaseNote.ts b/backend/src/plugins/Cases/functions/createCaseNote.ts
index 92520b7d..71b11302 100644
--- a/backend/src/plugins/Cases/functions/createCaseNote.ts
+++ b/backend/src/plugins/Cases/functions/createCaseNote.ts
@@ -1,6 +1,6 @@
 import { GuildPluginData } from "knub";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
-import { UnknownUser, resolveUser } from "../../../utils";
+import { UnknownUser, renderUsername, resolveUser } from "../../../utils";
 import { CaseNoteArgs, CasesPluginType } from "../types";
 import { postCaseToCaseLogChannel } from "./postToCaseLogChannel";
 import { resolveCaseId } from "./resolveCaseId";
@@ -16,7 +16,7 @@ export async function createCaseNote(pluginData: GuildPluginData<CasesPluginType
     throw new RecoverablePluginError(ERRORS.INVALID_USER);
   }
 
-  const modName = mod.tag;
+  const modName = renderUsername(mod);
 
   let body = args.body;
 
diff --git a/backend/src/plugins/InternalPoster/functions/sendMessage.ts b/backend/src/plugins/InternalPoster/functions/sendMessage.ts
index ec811240..3d4d6424 100644
--- a/backend/src/plugins/InternalPoster/functions/sendMessage.ts
+++ b/backend/src/plugins/InternalPoster/functions/sendMessage.ts
@@ -48,7 +48,7 @@ export async function sendMessage(
         ...content,
         ...(pluginData.client.user && {
           username: pluginData.client.user.username,
-          avatarURL: pluginData.client.user.avatarURL() || pluginData.client.user.defaultAvatarURL,
+          avatarURL: pluginData.client.user.displayAvatarURL(),
         }),
       })
       .then((apiMessage) => ({
diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
index 5b0e3273..a911091d 100644
--- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
@@ -1,7 +1,7 @@
-import { APIEmbed, User } from "discord.js";
+import { APIEmbed, GuildMember, User } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage } from "../../../pluginUtils";
-import { emptyEmbedValue, resolveUser, trimLines } from "../../../utils";
+import { emptyEmbedValue, renderUsername, resolveMember, resolveUser, trimLines } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { createPaginatedMessage } from "../../../utils/createPaginatedMessage";
 import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields";
@@ -28,8 +28,10 @@ export const CasesModCmd = modActionsCmd({
 
   async run({ pluginData, message: msg, args }) {
     const modId = args.mod || msg.author.id;
-    const mod = await resolveUser(pluginData.client, modId);
-    const modName = mod instanceof User ? mod.tag : modId;
+    const mod =
+      (await resolveMember(pluginData.client, pluginData.guild, modId)) ||
+      (await resolveUser(pluginData.client, modId));
+    const modName = mod instanceof User ? renderUsername(mod) : modId;
 
     const casesPlugin = pluginData.getPlugin(CasesPlugin);
     const totalCases = await casesPlugin.getTotalCasesByMod(modId);
@@ -57,7 +59,7 @@ export const CasesModCmd = modActionsCmd({
         const embed = {
           author: {
             name: title,
-            icon_url: mod instanceof User ? mod.displayAvatarURL() : undefined,
+            icon_url: mod instanceof User || mod instanceof GuildMember ? mod.displayAvatarURL() : undefined,
           },
           fields: [
             ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")),
diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 069ad31f..05ab65eb 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -1,9 +1,17 @@
-import { APIEmbed, User } from "discord.js";
+import { APIEmbed } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { sendErrorMessage } from "../../../pluginUtils";
 import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
-import { UnknownUser, chunkArray, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils";
+import {
+  UnknownUser,
+  chunkArray,
+  emptyEmbedValue,
+  renderUsername,
+  resolveMember,
+  resolveUser,
+  trimLines,
+} from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields";
 import { getGuildPrefix } from "../../../utils/getGuildPrefix";
@@ -35,8 +43,10 @@ export const CasesUserCmd = modActionsCmd({
   ],
 
   async run({ pluginData, message: msg, args }) {
-    const user = await resolveUser(pluginData.client, args.user);
-    if (!user.id) {
+    const user =
+      (await resolveMember(pluginData.client, pluginData.guild, args.user)) ||
+      (await resolveUser(pluginData.client, args.user));
+    if (!user.id || user instanceof UnknownUser) {
       sendErrorMessage(pluginData, msg.channel, `User not found`);
       return;
     }
@@ -62,7 +72,7 @@ export const CasesUserCmd = modActionsCmd({
     const hiddenCases = cases.filter((c) => c.is_hidden);
 
     const userName =
-      user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user);
+      user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUsername(user);
 
     if (cases.length === 0) {
       msg.channel.send(`No cases found for **${userName}**`);
@@ -123,7 +133,7 @@ export const CasesUserCmd = modActionsCmd({
                 lineChunks.length === 1
                   ? `Cases for ${userName} (${lines.length} total)`
                   : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`,
-              icon_url: user instanceof User ? user.displayAvatarURL() : undefined,
+              icon_url: user.displayAvatarURL(),
             },
             fields: [
               ...getChunkedEmbedFields(emptyEmbedValue, linesInChunk.join("\n")),
diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts
index c73a672b..29c3d5ec 100644
--- a/backend/src/plugins/Post/util/actualPostCmd.ts
+++ b/backend/src/plugins/Post/util/actualPostCmd.ts
@@ -4,7 +4,7 @@ import { GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { DBDateFormat, MINUTES, StrictMessageContent, errorMessage } from "../../../utils";
+import { DBDateFormat, MINUTES, StrictMessageContent, errorMessage, renderUsername } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
 import { PostPluginType } from "../types";
@@ -122,7 +122,7 @@ export async function actualPostCmd(
 
     const post = await pluginData.state.scheduledPosts.create({
       author_id: msg.author.id,
-      author_name: msg.author.tag,
+      author_name: renderUsername(msg.author),
       channel_id: targetChannel.id,
       content,
       attachments: [...msg.attachments.values()],
diff --git a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts
index 5f028c0a..5126f20f 100644
--- a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts
+++ b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts
@@ -1,6 +1,6 @@
 import { GuildChannel, Message } from "discord.js";
 import path from "path";
-import { EMPTY_CHAR, EmbedWith } from "../../../utils";
+import { EMPTY_CHAR, EmbedWith, renderUsername } from "../../../utils";
 
 const imageAttachmentExtensions = ["jpeg", "jpg", "png", "gif", "webp"];
 const audioAttachmentExtensions = ["wav", "mp3", "m4a"];
@@ -18,7 +18,7 @@ export function createStarboardEmbedFromMessage(
       text: `#${(msg.channel as GuildChannel).name}`,
     },
     author: {
-      name: msg.author.tag,
+      name: renderUsername(msg.author),
     },
     fields: [],
     timestamp: msg.createdAt.toISOString(),
@@ -28,7 +28,7 @@ export function createStarboardEmbedFromMessage(
     embed.color = color;
   }
 
-  embed.author.icon_url = msg.author.displayAvatarURL();
+  embed.author.icon_url = (msg.member || msg.author).displayAvatarURL();
 
   // The second condition here checks for messages with only an image link that is then embedded.
   // The message content in that case is hidden by the Discord client, so we hide it here too.
diff --git a/backend/src/plugins/Utility/commands/AboutCmd.ts b/backend/src/plugins/Utility/commands/AboutCmd.ts
index 53408d71..d4188ad7 100644
--- a/backend/src/plugins/Utility/commands/AboutCmd.ts
+++ b/backend/src/plugins/Utility/commands/AboutCmd.ts
@@ -100,8 +100,8 @@ export const AboutCmd = utilityCmd({
     }
 
     // Use the bot avatar as the embed image
-    if (pluginData.client.user!.avatarURL()) {
-      aboutEmbed.thumbnail = { url: pluginData.client.user!.avatarURL()! };
+    if (pluginData.client.user!.displayAvatarURL()) {
+      aboutEmbed.thumbnail = { url: pluginData.client.user!.displayAvatarURL()! };
     }
 
     msg.channel.send({ embeds: [aboutEmbed] });
diff --git a/backend/src/plugins/Utility/commands/AvatarCmd.ts b/backend/src/plugins/Utility/commands/AvatarCmd.ts
index ef44a2cb..b1215df6 100644
--- a/backend/src/plugins/Utility/commands/AvatarCmd.ts
+++ b/backend/src/plugins/Utility/commands/AvatarCmd.ts
@@ -1,7 +1,7 @@
 import { APIEmbed, ImageFormat } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage } from "../../../pluginUtils";
-import { UnknownUser, renderUserUsername } from "../../../utils";
+import { UnknownUser, renderUsername } from "../../../utils";
 import { utilityCmd } from "../types";
 
 export const AvatarCmd = utilityCmd({
@@ -10,17 +10,17 @@ export const AvatarCmd = utilityCmd({
   permission: "can_avatar",
 
   signature: {
-    user: ct.resolvedUserLoose({ required: false }),
+    user: ct.resolvedMember({ required: false }) || ct.resolvedUserLoose({ required: false }),
   },
 
   async run({ message: msg, args, pluginData }) {
-    const user = args.user || msg.author;
+    const user = args.user || msg.member || msg.author;
     if (!(user instanceof UnknownUser)) {
       const embed: APIEmbed = {
         image: {
           url: user.displayAvatarURL({ extension: ImageFormat.PNG, size: 2048 }),
         },
-        title: `Avatar of ${renderUserUsername(user)}:`,
+        title: `Avatar of ${renderUsername(user)}:`,
       };
       msg.channel.send({ embeds: [embed] });
     } else {
diff --git a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
index 2de8b4db..58d34d56 100644
--- a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
@@ -13,7 +13,6 @@ import {
   trimLines,
   UnknownUser,
 } from "../../../utils";
-import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
 import { UtilityPluginType } from "../types";
 
 const MAX_ROLES_TO_DISPLAY = 15;
@@ -40,13 +39,11 @@ export async function getUserInfoEmbed(
     fields: [],
   };
 
-  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
-
   embed.author = {
     name: `${user.bot ? "Bot" : "User"}:  ${renderUsername(user)}`,
   };
 
-  const avatarURL = user.displayAvatarURL();
+  const avatarURL = (member || user).displayAvatarURL();
   embed.author.icon_url = avatarURL;
 
   if (compact) {
diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts
index 19710b58..632a85a7 100644
--- a/backend/src/plugins/Utility/search.ts
+++ b/backend/src/plugins/Utility/search.ts
@@ -14,7 +14,7 @@ import { ArgsFromSignatureOrArray, GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { RegExpRunner, allowTimeout } from "../../RegExpRunner";
 import { getBaseUrl, sendErrorMessage } from "../../pluginUtils";
-import { MINUTES, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils";
+import { MINUTES, multiSorter, renderUsername, sorter, trimLines } from "../../utils";
 import { asyncFilter } from "../../utils/async";
 import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions";
 import { InvalidRegexError, inputPatternToRegExp } from "../../validatorUtils";
@@ -381,7 +381,7 @@ async function performMemberSearch(
         return true;
       }
 
-      const fullUsername = renderUserUsername(member.user);
+      const fullUsername = renderUsername(member.user);
       if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true;
 
       return false;
@@ -448,7 +448,7 @@ async function performBanSearch(
 
     const execRegExp = getOptimizedRegExpRunner(pluginData, isSafeRegex);
     matchingBans = await asyncFilter(matchingBans, async (user) => {
-      const fullUsername = renderUserUsername(user);
+      const fullUsername = renderUsername(user);
       if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true;
       return false;
     });
@@ -492,10 +492,10 @@ function formatSearchResultList(members: Array<GuildMember | User>): string {
     const paddedId = member.id.padEnd(longestId, " ");
     let line;
     if (member instanceof GuildMember) {
-      line = `${paddedId} ${renderUserUsername(member.user)}`;
+      line = `${paddedId} ${renderUsername(member.user)}`;
       if (member.nickname) line += ` (${member.nickname})`;
     } else {
-      line = `${paddedId} ${member.tag}`;
+      line = `${paddedId} ${renderUsername(member)}`;
     }
     return line;
   });
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index ee6b556c..5c795072 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1603,9 +1603,12 @@ export function isTruthy<T>(value: T): value is Exclude<T, false | null | undefi
 
 export const DBDateFormat = "YYYY-MM-DD HH:mm:ss";
 
-export function renderUsername(username: User): string;
-export function renderUsername(username: string, discriminator?: string): string;
-export function renderUsername(username: string | User, discriminator?: string): string {
+// TODO: Fix overloads
+//export function renderUsername(username: GuildMember): string;
+//export function renderUsername(username: User): string;
+//export function renderUsername(username: string, discriminator?: string): string;
+export function renderUsername(username: string | User | GuildMember, discriminator?: string): string {
+  if (username instanceof GuildMember) return username.user.tag;
   if (username instanceof User) return username.tag;
   if (discriminator === "0" || discriminator === "0000") {
     return username;
diff --git a/backend/src/utils/templateSafeObjects.ts b/backend/src/utils/templateSafeObjects.ts
index fbd27754..33e36971 100644
--- a/backend/src/utils/templateSafeObjects.ts
+++ b/backend/src/utils/templateSafeObjects.ts
@@ -52,7 +52,7 @@ export class TemplateSafeUser extends TemplateSafeValueContainer {
   globalName?: string;
   mention: string;
   tag: string;
-  avatarURL?: string;
+  avatarURL: string;
   bot?: boolean;
   createdAt?: number;
   renderedUsername: string;
@@ -92,7 +92,7 @@ export class TemplateSafeMember extends TemplateSafeUser {
   nick: string;
   roles: TemplateSafeRole[];
   joinedAt?: number;
-  // guildAvatarURL: string, Once DJS supports per-server avatars
+  guildAvatarURL: string;
   guildName: string;
 
   constructor(data: InputProps<TemplateSafeMember>) {
@@ -261,7 +261,7 @@ export function userToTemplateSafeUser(user: User | UnknownUser): TemplateSafeUs
     globalName: user.globalName,
     mention: `<@${user.id}>`,
     tag: user.tag,
-    avatarURL: user.displayAvatarURL?.(),
+    avatarURL: user.displayAvatarURL(),
     bot: user.bot,
     createdAt: user.createdTimestamp,
     renderedUsername: renderUserUsername(user),
@@ -287,6 +287,7 @@ export function memberToTemplateSafeMember(member: GuildMember | PartialGuildMem
     nick: member.nickname ?? "*None*",
     roles: [...member.roles.cache.mapValues((r) => roleToTemplateSafeRole(r)).values()],
     joinedAt: member.joinedTimestamp ?? undefined,
+    guildAvatarURL: member.displayAvatarURL(),
     guildName: member.guild.name,
   });
 }

From 4d0161a49f77e28ef89a819d7124a8239fd1a8c9 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 15:50:01 +0000
Subject: [PATCH 14/51] oh god almeida looked at my code

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Automod/triggers/threadArchive.ts   | 2 +-
 backend/src/plugins/Automod/triggers/threadCreate.ts    | 2 +-
 backend/src/plugins/Automod/triggers/threadDelete.ts    | 2 +-
 backend/src/plugins/Automod/triggers/threadUnarchive.ts | 2 +-
 backend/src/plugins/BotControl/commands/ServersCmd.ts   | 7 ++++---
 5 files changed, 8 insertions(+), 7 deletions(-)

diff --git a/backend/src/plugins/Automod/triggers/threadArchive.ts b/backend/src/plugins/Automod/triggers/threadArchive.ts
index 8a692f6d..274732f5 100644
--- a/backend/src/plugins/Automod/triggers/threadArchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadArchive.ts
@@ -48,7 +48,7 @@ export const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     const base = `Thread **#${threadName}** (\`${threadId}\`) has been archived in the **#${parentName}** (\`${parentId}\`) channel`;
     if (threadOwner) {
-      return `${base} by **${escapeBold(renderUsername(threadOwner.tag))}** (\`${threadOwner.id}\`)`;
+      return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\`${threadOwner.id}\`)`;
     }
     return base;
   },
diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts
index dc613068..793708e4 100644
--- a/backend/src/plugins/Automod/triggers/threadCreate.ts
+++ b/backend/src/plugins/Automod/triggers/threadCreate.ts
@@ -41,7 +41,7 @@ export const ThreadCreateTrigger = automodTrigger<ThreadCreateResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     const base = `Thread **#${threadName}** (\`${threadId}\`) has been created in the **#${parentName}** (\`${parentId}\`) channel`;
     if (threadOwner) {
-      return `${base} by **${escapeBold(renderUsername(threadOwner.tag))}** (\`${threadOwner.id}\`)`;
+      return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\`${threadOwner.id}\`)`;
     }
     return base;
   },
diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts
index f27a1d0b..3c108bb0 100644
--- a/backend/src/plugins/Automod/triggers/threadDelete.ts
+++ b/backend/src/plugins/Automod/triggers/threadDelete.ts
@@ -41,7 +41,7 @@ export const ThreadDeleteTrigger = automodTrigger<ThreadDeleteResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     if (threadOwner) {
       return `Thread **#${threadName ?? "Unknown"}** (\`${threadId}\`) created by **${escapeBold(
-        renderUsername(threadOwner.tag),
+        renderUsername(threadOwner),
       )}** (\`${threadOwner.id}\`) in the **#${parentName}** (\`${parentId}\`) channel has been deleted`;
     }
     return `Thread **#${
diff --git a/backend/src/plugins/Automod/triggers/threadUnarchive.ts b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
index 94c69ace..0a081c2b 100644
--- a/backend/src/plugins/Automod/triggers/threadUnarchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
@@ -48,7 +48,7 @@ export const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({
     const parentName = matchResult.extra.matchedThreadParentName;
     const base = `Thread **#${threadName}** (\`${threadId}\`) has been unarchived in the **#${parentName}** (\`${parentId}\`) channel`;
     if (threadOwner) {
-      return `${base} by **${escapeBold(renderUsername(threadOwner.tag))}** (\`${threadOwner.id}\`)`;
+      return `${base} by **${escapeBold(renderUsername(threadOwner))}** (\`${threadOwner.id}\`)`;
     }
     return base;
   },
diff --git a/backend/src/plugins/BotControl/commands/ServersCmd.ts b/backend/src/plugins/BotControl/commands/ServersCmd.ts
index 5cb62433..3658a36c 100644
--- a/backend/src/plugins/BotControl/commands/ServersCmd.ts
+++ b/backend/src/plugins/BotControl/commands/ServersCmd.ts
@@ -48,9 +48,10 @@ export const ServersCmd = botControlCmd({
         const lines = filteredGuilds.map((g) => {
           const paddedId = g.id.padEnd(longestId, " ");
           const owner = getUser(pluginData.client, g.ownerId);
-          return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(owner.tag)}** \`${
-            owner.id
-          }\`)`;
+          return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(
+            owner.username,
+            owner.discriminator,
+          )}** \`${owner.id}\`)`;
         });
         createChunkedMessage(msg.channel, lines.join("\n"));
       } else {

From bfc90093dc83d0c891d7ea542852835f1337e2d0 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 15:57:23 +0000
Subject: [PATCH 15/51] yeet renderUserUsername

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Automod/triggers/roleAdded.ts    |  4 ++--
 backend/src/plugins/Automod/triggers/roleRemoved.ts  |  4 ++--
 .../BotControl/commands/AddDashboardUserCmd.ts       |  4 ++--
 .../BotControl/commands/ListDashboardPermsCmd.ts     |  6 +++---
 .../BotControl/commands/ListDashboardUsersCmd.ts     |  4 ++--
 .../BotControl/commands/RemoveDashboardUserCmd.ts    |  4 ++--
 .../src/plugins/ModActions/commands/AddCaseCmd.ts    |  4 ++--
 backend/src/plugins/ModActions/commands/BanCmd.ts    |  4 ++--
 backend/src/plugins/ModActions/commands/NoteCmd.ts   |  4 ++--
 backend/src/plugins/ModActions/commands/WarnCmd.ts   |  4 ++--
 .../ModActions/events/PostAlertOnMemberJoinEvt.ts    |  4 ++--
 .../ModActions/functions/actualKickMemberCmd.ts      |  4 ++--
 .../ModActions/functions/actualMuteUserCmd.ts        | 10 +++++-----
 .../ModActions/functions/actualUnmuteUserCmd.ts      |  6 +++---
 backend/src/plugins/Mutes/commands/MutesCmd.ts       |  6 +++---
 backend/src/plugins/NameHistory/commands/NamesCmd.ts |  4 ++--
 .../ReactionRoles/util/addMemberPendingRoleChange.ts |  6 ++----
 .../plugins/Slowmode/commands/SlowmodeClearCmd.ts    |  8 ++++----
 backend/src/plugins/UsernameSaver/updateUsername.ts  |  4 ++--
 backend/src/plugins/Utility/commands/LevelCmd.ts     |  4 ++--
 .../src/plugins/Utility/commands/VcdisconnectCmd.ts  |  4 ++--
 backend/src/plugins/Utility/commands/VcmoveCmd.ts    | 12 ++++--------
 backend/src/utils.ts                                 |  8 ++------
 backend/src/utils/templateSafeObjects.ts             |  6 +++---
 24 files changed, 59 insertions(+), 69 deletions(-)

diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts
index dc62f163..754be1b3 100644
--- a/backend/src/plugins/Automod/triggers/roleAdded.ts
+++ b/backend/src/plugins/Automod/triggers/roleAdded.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
 import * as t from "io-ts";
-import { renderUserUsername } from "../../../utils";
+import { renderUsername } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
@@ -38,7 +38,7 @@ export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
     const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake);
     const roleName = role?.name || "Unknown";
     const member = contexts[0].member!;
-    const memberName = `**${renderUserUsername(member.user)}** (\`${member.id}\`)`;
+    const memberName = `**${renderUsername(member.user)}** (\`${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
index 65624827..fc5d5ae3 100644
--- a/backend/src/plugins/Automod/triggers/roleRemoved.ts
+++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
 import * as t from "io-ts";
-import { renderUserUsername } from "../../../utils";
+import { renderUsername } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
@@ -38,7 +38,7 @@ export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
     const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake);
     const roleName = role?.name || "Unknown";
     const member = contexts[0].member!;
-    const memberName = `**${renderUserUsername(member.user)}** (\`${member.id}\`)`;
+    const memberName = `**${renderUsername(member.user)}** (\`${member.id}\`)`;
     return `Role ${roleName} (\`${matchResult.extra.matchedRoleId}\`) was removed from ${memberName}`;
   },
 });
diff --git a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts
index c1259594..0cb87f95 100644
--- a/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts
+++ b/backend/src/plugins/BotControl/commands/AddDashboardUserCmd.ts
@@ -1,7 +1,7 @@
 import { ApiPermissions } from "@shared/apiPermissions";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername } from "../../../utils";
+import { renderUsername } from "../../../utils";
 import { botControlCmd } from "../types";
 
 export const AddDashboardUserCmd = botControlCmd({
@@ -35,7 +35,7 @@ export const AddDashboardUserCmd = botControlCmd({
       await pluginData.state.apiPermissionAssignments.addUser(args.guildId, user.id, [ApiPermissions.EditConfig]);
     }
 
-    const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUserUsername(user)}**, \`${user.id}\`)`);
+    const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`);
     sendSuccessMessage(
       pluginData,
       msg.channel,
diff --git a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts
index 18c7d0d7..7c6d2aa5 100644
--- a/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts
+++ b/backend/src/plugins/BotControl/commands/ListDashboardPermsCmd.ts
@@ -2,7 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { AllowedGuild } from "../../../data/entities/AllowedGuild";
 import { ApiPermissionAssignment } from "../../../data/entities/ApiPermissionAssignment";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername, resolveUser } from "../../../utils";
+import { renderUsername, resolveUser } from "../../../utils";
 import { botControlCmd } from "../types";
 
 export const ListDashboardPermsCmd = botControlCmd({
@@ -42,7 +42,7 @@ export const ListDashboardPermsCmd = botControlCmd({
 
     // If we have user, always display which guilds they have permissions in (or only specified guild permissions)
     if (args.user) {
-      const userInfo = `**${renderUserUsername(args.user)}** (\`${args.user.id}\`)`;
+      const userInfo = `**${renderUsername(args.user)}** (\`${args.user.id}\`)`;
 
       for (const assignment of existingUserAssignment!) {
         if (guild != null && assignment.guild_id !== args.guildId) continue;
@@ -74,7 +74,7 @@ export const ListDashboardPermsCmd = botControlCmd({
       finalMessage += `The server ${guildInfo} has the following assigned permissions:\n`; // Double \n for consistency with AddDashboardUserCmd
       for (const assignment of existingGuildAssignment) {
         const user = await resolveUser(pluginData.client, assignment.target_id);
-        finalMessage += `\n**${renderUserUsername(user)}**, \`${assignment.target_id}\`: ${assignment.permissions.join(
+        finalMessage += `\n**${renderUsername(user)}**, \`${assignment.target_id}\`: ${assignment.permissions.join(
           ", ",
         )}`;
       }
diff --git a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts
index 36f1432f..1d3c1ffe 100644
--- a/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts
+++ b/backend/src/plugins/BotControl/commands/ListDashboardUsersCmd.ts
@@ -1,6 +1,6 @@
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername, resolveUser } from "../../../utils";
+import { renderUsername, resolveUser } from "../../../utils";
 import { botControlCmd } from "../types";
 
 export const ListDashboardUsersCmd = botControlCmd({
@@ -27,7 +27,7 @@ export const ListDashboardUsersCmd = botControlCmd({
     );
     const userNameList = users.map(
       ({ user, permission }) =>
-        `<@!${user.id}> (**${renderUserUsername(user)}**, \`${user.id}\`): ${permission.permissions.join(", ")}`,
+        `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`): ${permission.permissions.join(", ")}`,
     );
 
     sendSuccessMessage(
diff --git a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts
index 3a90683c..c3d1ec99 100644
--- a/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts
+++ b/backend/src/plugins/BotControl/commands/RemoveDashboardUserCmd.ts
@@ -1,6 +1,6 @@
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { isStaffPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername } from "../../../utils";
+import { renderUsername } from "../../../utils";
 import { botControlCmd } from "../types";
 
 export const RemoveDashboardUserCmd = botControlCmd({
@@ -34,7 +34,7 @@ export const RemoveDashboardUserCmd = botControlCmd({
       await pluginData.state.apiPermissionAssignments.removeUser(args.guildId, user.id);
     }
 
-    const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUserUsername(user)}**, \`${user.id}\`)`);
+    const userNameList = args.users.map((user) => `<@!${user.id}> (**${renderUsername(user)}**, \`${user.id}\`)`);
     sendSuccessMessage(
       pluginData,
       msg.channel,
diff --git a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts
index 43575463..3f8b9dfc 100644
--- a/backend/src/plugins/ModActions/commands/AddCaseCmd.ts
+++ b/backend/src/plugins/ModActions/commands/AddCaseCmd.ts
@@ -3,7 +3,7 @@ import { CaseTypes } from "../../../data/CaseTypes";
 import { Case } from "../../../data/entities/Case";
 import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
 import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername, resolveMember, resolveUser } from "../../../utils";
+import { renderUsername, resolveMember, resolveUser } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
 import { modActionsCmd } from "../types";
@@ -75,7 +75,7 @@ export const AddCaseCmd = modActionsCmd({
       sendSuccessMessage(
         pluginData,
         msg.channel,
-        `Case #${theCase.case_number} created for **${renderUserUsername(user)}**`,
+        `Case #${theCase.case_number} created for **${renderUsername(user)}**`,
       );
     } else {
       sendSuccessMessage(pluginData, msg.channel, `Case #${theCase.case_number} created`);
diff --git a/backend/src/plugins/ModActions/commands/BanCmd.ts b/backend/src/plugins/ModActions/commands/BanCmd.ts
index 9d32cd10..3e66e8a2 100644
--- a/backend/src/plugins/ModActions/commands/BanCmd.ts
+++ b/backend/src/plugins/ModActions/commands/BanCmd.ts
@@ -5,7 +5,7 @@ import { CaseTypes } from "../../../data/CaseTypes";
 import { clearExpiringTempban, registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop";
 import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
-import { renderUserUsername, resolveMember, resolveUser } from "../../../utils";
+import { renderUsername, resolveMember, resolveUser } from "../../../utils";
 import { banLock } from "../../../utils/lockNameHelpers";
 import { waitForButtonConfirm } from "../../../utils/waitForInteraction";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
@@ -207,7 +207,7 @@ export const BanCmd = modActionsCmd({
     // Confirm the action to the moderator
     let response = "";
     if (!forceban) {
-      response = `Banned **${renderUserUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`;
+      response = `Banned **${renderUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`;
       if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`;
     } else {
       response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`;
diff --git a/backend/src/plugins/ModActions/commands/NoteCmd.ts b/backend/src/plugins/ModActions/commands/NoteCmd.ts
index b13ed498..edb2202d 100644
--- a/backend/src/plugins/ModActions/commands/NoteCmd.ts
+++ b/backend/src/plugins/ModActions/commands/NoteCmd.ts
@@ -1,7 +1,7 @@
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername, resolveUser } from "../../../utils";
+import { renderUsername, resolveUser } from "../../../utils";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
@@ -29,7 +29,7 @@ export const NoteCmd = modActionsCmd({
       return;
     }
 
-    const userName = renderUserUsername(user);
+    const userName = renderUsername(user);
     const reason = formatReasonWithAttachments(args.note, [...msg.attachments.values()]);
 
     const casesPlugin = pluginData.getPlugin(CasesPlugin);
diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts
index c8192015..f917c55e 100644
--- a/backend/src/plugins/ModActions/commands/WarnCmd.ts
+++ b/backend/src/plugins/ModActions/commands/WarnCmd.ts
@@ -1,7 +1,7 @@
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { errorMessage, renderUserUsername, resolveMember, resolveUser } from "../../../utils";
+import { errorMessage, renderUsername, resolveMember, resolveUser } from "../../../utils";
 import { waitForButtonConfirm } from "../../../utils/waitForInteraction";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
@@ -106,7 +106,7 @@ export const WarnCmd = modActionsCmd({
     sendSuccessMessage(
       pluginData,
       msg.channel,
-      `Warned **${renderUserUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`,
+      `Warned **${renderUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`,
     );
   },
 });
diff --git a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts
index 677fb603..7874c241 100644
--- a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts
+++ b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts
@@ -1,5 +1,5 @@
 import { PermissionsBitField, Snowflake, TextChannel } from "discord.js";
-import { renderUserUsername, resolveMember } from "../../../utils";
+import { renderUsername, resolveMember } from "../../../utils";
 import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { modActionsEvt } from "../types";
@@ -46,7 +46,7 @@ export const PostAlertOnMemberJoinEvt = modActionsEvt({
       }
 
       await alertChannel.send(
-        `<@!${member.id}> (${renderUserUsername(member.user)} \`${member.id}\`) joined with ${
+        `<@!${member.id}> (${renderUsername(member.user)} \`${member.id}\`) joined with ${
           actions.length
         } prior record(s)`,
       );
diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts
index 73a1e2d9..fac3f906 100644
--- a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts
+++ b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts
@@ -3,7 +3,7 @@ import { GuildPluginData } from "knub";
 import { hasPermission } from "knub/helpers";
 import { LogType } from "../../../data/LogType";
 import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { DAYS, SECONDS, errorMessage, renderUserUsername, resolveMember, resolveUser } from "../../../utils";
+import { DAYS, SECONDS, errorMessage, renderUsername, resolveMember, resolveUser } from "../../../utils";
 import { IgnoredEventType, ModActionsPluginType } from "../types";
 import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
 import { ignoreEvent } from "./ignoreEvent";
@@ -103,7 +103,7 @@ export async function actualKickMemberCmd(
   }
 
   // Confirm the action to the moderator
-  let response = `Kicked **${renderUserUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`;
+  let response = `Kicked **${renderUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`;
 
   if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;
   sendSuccessMessage(pluginData, msg.channel, response);
diff --git a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts
index 5c628c4e..2f108410 100644
--- a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts
+++ b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts
@@ -4,7 +4,7 @@ import { GuildPluginData } from "knub";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
 import { logger } from "../../../logger";
 import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { UnknownUser, asSingleLine, isDiscordAPIError, renderUserUsername } from "../../../utils";
+import { UnknownUser, asSingleLine, isDiscordAPIError, renderUsername } from "../../../utils";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
 import { MuteResult } from "../../Mutes/types";
 import { ModActionsPluginType } from "../types";
@@ -86,24 +86,24 @@ export async function actualMuteUserCmd(
   if (args.time) {
     if (muteResult.updatedExistingMute) {
       response = asSingleLine(`
-        Updated **${renderUserUsername(user)}**'s
+        Updated **${renderUsername(user)}**'s
         mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number})
       `);
     } else {
       response = asSingleLine(`
-        Muted **${renderUserUsername(user)}**
+        Muted **${renderUsername(user)}**
         for ${timeUntilUnmute} (Case #${muteResult.case.case_number})
       `);
     }
   } else {
     if (muteResult.updatedExistingMute) {
       response = asSingleLine(`
-        Updated **${renderUserUsername(user)}**'s
+        Updated **${renderUsername(user)}**'s
         mute to indefinite (Case #${muteResult.case.case_number})
       `);
     } else {
       response = asSingleLine(`
-        Muted **${renderUserUsername(user)}**
+        Muted **${renderUsername(user)}**
         indefinitely (Case #${muteResult.case.case_number})
       `);
     }
diff --git a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts
index d70a219c..5b28aee7 100644
--- a/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts
+++ b/backend/src/plugins/ModActions/functions/actualUnmuteUserCmd.ts
@@ -3,7 +3,7 @@ import humanizeDuration from "humanize-duration";
 import { GuildPluginData } from "knub";
 import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin";
-import { UnknownUser, asSingleLine, renderUserUsername } from "../../../utils";
+import { UnknownUser, asSingleLine, renderUsername } from "../../../utils";
 import { ModActionsPluginType } from "../types";
 import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
 
@@ -48,7 +48,7 @@ export async function actualUnmuteCmd(
       pluginData,
       msg.channel,
       asSingleLine(`
-        Unmuting **${renderUserUsername(user)}**
+        Unmuting **${renderUsername(user)}**
         in ${timeUntilUnmute} (Case #${result.case.case_number})
       `),
     );
@@ -57,7 +57,7 @@ export async function actualUnmuteCmd(
       pluginData,
       msg.channel,
       asSingleLine(`
-        Unmuted **${renderUserUsername(user)}**
+        Unmuted **${renderUsername(user)}**
         (Case #${result.case.case_number})
       `),
     );
diff --git a/backend/src/plugins/Mutes/commands/MutesCmd.ts b/backend/src/plugins/Mutes/commands/MutesCmd.ts
index 179d1a65..22cd74c5 100644
--- a/backend/src/plugins/Mutes/commands/MutesCmd.ts
+++ b/backend/src/plugins/Mutes/commands/MutesCmd.ts
@@ -10,7 +10,7 @@ import moment from "moment-timezone";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { humanizeDurationShort } from "../../../humanizeDurationShort";
 import { getBaseUrl } from "../../../pluginUtils";
-import { DBDateFormat, MINUTES, renderUserUsername, resolveMember } from "../../../utils";
+import { DBDateFormat, MINUTES, renderUsername, resolveMember } from "../../../utils";
 import { IMuteWithDetails, mutesCmd } from "../types";
 
 export const MutesCmd = mutesCmd({
@@ -74,7 +74,7 @@ export const MutesCmd = mutesCmd({
       totalMutes = manuallyMutedMembers.length;
 
       lines = manuallyMutedMembers.map((member) => {
-        return `<@!${member.id}> (**${renderUserUsername(member.user)}**, \`${member.id}\`)   🔧 Manual mute`;
+        return `<@!${member.id}> (**${renderUsername(member.user)}**, \`${member.id}\`)   🔧 Manual mute`;
       });
     } else {
       // Show filtered active mutes (but not manual mutes)
@@ -123,7 +123,7 @@ export const MutesCmd = mutesCmd({
 
       lines = filteredMutes.map((mute) => {
         const user = pluginData.client.users.resolve(mute.user_id as Snowflake);
-        const username = user ? renderUserUsername(user) : "Unknown#0000";
+        const username = user ? renderUsername(user) : "Unknown#0000";
         const theCase = muteCasesById.get(mute.case_id);
         const caseName = theCase ? `Case #${theCase.case_number}` : "No case";
 
diff --git a/backend/src/plugins/NameHistory/commands/NamesCmd.ts b/backend/src/plugins/NameHistory/commands/NamesCmd.ts
index 373f1671..da8ba48a 100644
--- a/backend/src/plugins/NameHistory/commands/NamesCmd.ts
+++ b/backend/src/plugins/NameHistory/commands/NamesCmd.ts
@@ -5,7 +5,7 @@ import { MAX_NICKNAME_ENTRIES_PER_USER } from "../../../data/GuildNicknameHistor
 import { MAX_USERNAME_ENTRIES_PER_USER } from "../../../data/UsernameHistory";
 import { NICKNAME_RETENTION_PERIOD } from "../../../data/cleanup/nicknames";
 import { sendErrorMessage } from "../../../pluginUtils";
-import { DAYS, renderUserUsername } from "../../../utils";
+import { DAYS, renderUsername } from "../../../utils";
 import { nameHistoryCmd } from "../types";
 
 export const NamesCmd = nameHistoryCmd({
@@ -31,7 +31,7 @@ export const NamesCmd = nameHistoryCmd({
     const usernameRows = usernames.map((r) => `\`[${r.timestamp}]\` **${disableCodeBlocks(r.username)}**`);
 
     const user = await pluginData.client.users.fetch(args.userId as Snowflake).catch(() => null);
-    const currentUsername = user ? renderUserUsername(user) : args.userId;
+    const currentUsername = user ? renderUsername(user) : args.userId;
 
     const nicknameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS);
     const usernameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS);
diff --git a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts
index a2a82fe9..dda8a6bc 100644
--- a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts
+++ b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts
@@ -1,7 +1,7 @@
 import { Snowflake } from "discord.js";
 import { GuildPluginData } from "knub";
 import { logger } from "../../../logger";
-import { renderUserUsername, resolveMember } from "../../../utils";
+import { renderUsername, resolveMember } from "../../../utils";
 import { memberRolesLock } from "../../../utils/lockNameHelpers";
 import { PendingMemberRoleChanges, ReactionRolesPluginType, RoleChangeMode } from "../types";
 
@@ -33,9 +33,7 @@ export async function addMemberPendingRoleChange(
           try {
             await member.roles.set(Array.from(newRoleIds.values()), "Reaction roles");
           } catch (e) {
-            logger.warn(
-              `Failed to apply role changes to ${renderUserUsername(member.user)} (${member.id}): ${e.message}`,
-            );
+            logger.warn(`Failed to apply role changes to ${renderUsername(member.user)} (${member.id}): ${e.message}`);
           }
         }
         lock.unlock();
diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts
index 246a048e..fd133c3a 100644
--- a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts
+++ b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts
@@ -1,7 +1,7 @@
 import { ChannelType, escapeInlineCode } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { asSingleLine, renderUserUsername } from "../../../utils";
+import { asSingleLine, renderUsername } from "../../../utils";
 import { getMissingChannelPermissions } from "../../../utils/getMissingChannelPermissions";
 import { missingPermissionError } from "../../../utils/missingPermissionError";
 import { BOT_SLOWMODE_CLEAR_PERMISSIONS } from "../requiredPermissions";
@@ -45,7 +45,7 @@ export const SlowmodeClearCmd = slowmodeCmd({
           pluginData,
           msg.channel,
           asSingleLine(`
-            Failed to clear slowmode from **${renderUserUsername(args.user)}** in <#${args.channel.id}>:
+            Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>:
             Threads cannot have Bot Slowmode
           `),
         );
@@ -56,7 +56,7 @@ export const SlowmodeClearCmd = slowmodeCmd({
         pluginData,
         msg.channel,
         asSingleLine(`
-          Failed to clear slowmode from **${renderUserUsername(args.user)}** in <#${args.channel.id}>:
+          Failed to clear slowmode from **${renderUsername(args.user)}** in <#${args.channel.id}>:
           \`${escapeInlineCode(e.message)}\`
         `),
       );
@@ -66,7 +66,7 @@ export const SlowmodeClearCmd = slowmodeCmd({
     sendSuccessMessage(
       pluginData,
       msg.channel,
-      `Slowmode cleared from **${renderUserUsername(args.user)}** in <#${args.channel.id}>`,
+      `Slowmode cleared from **${renderUsername(args.user)}** in <#${args.channel.id}>`,
     );
   },
 });
diff --git a/backend/src/plugins/UsernameSaver/updateUsername.ts b/backend/src/plugins/UsernameSaver/updateUsername.ts
index 163a4aae..ed0fb73f 100644
--- a/backend/src/plugins/UsernameSaver/updateUsername.ts
+++ b/backend/src/plugins/UsernameSaver/updateUsername.ts
@@ -1,11 +1,11 @@
 import { User } from "discord.js";
 import { GuildPluginData } from "knub";
-import { renderUserUsername } from "../../utils";
+import { renderUsername } from "../../utils";
 import { UsernameSaverPluginType } from "./types";
 
 export async function updateUsername(pluginData: GuildPluginData<UsernameSaverPluginType>, user: User) {
   if (!user) return;
-  const newUsername = renderUserUsername(user);
+  const newUsername = renderUsername(user);
   const latestEntry = await pluginData.state.usernameHistory.getLastEntry(user.id);
   if (!latestEntry || newUsername !== latestEntry.username) {
     await pluginData.state.usernameHistory.addEntry(user.id, newUsername);
diff --git a/backend/src/plugins/Utility/commands/LevelCmd.ts b/backend/src/plugins/Utility/commands/LevelCmd.ts
index 306a1d20..df4d9325 100644
--- a/backend/src/plugins/Utility/commands/LevelCmd.ts
+++ b/backend/src/plugins/Utility/commands/LevelCmd.ts
@@ -1,6 +1,6 @@
 import { helpers } from "knub";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
-import { renderUserUsername } from "../../../utils";
+import { renderUsername } from "../../../utils";
 import { utilityCmd } from "../types";
 
 const { getMemberLevel } = helpers;
@@ -18,6 +18,6 @@ export const LevelCmd = utilityCmd({
   run({ message, args, pluginData }) {
     const member = args.member || message.member;
     const level = getMemberLevel(pluginData, member);
-    message.channel.send(`The permission level of ${renderUserUsername(member.user)} is **${level}**`);
+    message.channel.send(`The permission level of ${renderUsername(member.user)} is **${level}**`);
   },
 });
diff --git a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts
index 0f82458a..240c763f 100644
--- a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts
+++ b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts
@@ -1,7 +1,7 @@
 import { VoiceChannel } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { renderUserUsername } from "../../../utils";
+import { renderUsername } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { utilityCmd } from "../types";
 
@@ -43,7 +43,7 @@ export const VcdisconnectCmd = utilityCmd({
     sendSuccessMessage(
       pluginData,
       msg.channel,
-      `**${renderUserUsername(args.member.user)}** disconnected from **${channel.name}**`,
+      `**${renderUsername(args.member.user)}** disconnected from **${channel.name}**`,
     );
   },
 });
diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts
index db00161e..6ffe8588 100644
--- a/backend/src/plugins/Utility/commands/VcmoveCmd.ts
+++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts
@@ -1,7 +1,7 @@
 import { ChannelType, Snowflake, VoiceChannel } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { channelMentionRegex, isSnowflake, renderUserUsername, simpleClosestStringMatch } from "../../../utils";
+import { channelMentionRegex, isSnowflake, renderUsername, simpleClosestStringMatch } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { utilityCmd } from "../types";
 
@@ -80,11 +80,7 @@ export const VcmoveCmd = utilityCmd({
       newChannel: channel,
     });
 
-    sendSuccessMessage(
-      pluginData,
-      msg.channel,
-      `**${renderUserUsername(args.member.user)}** moved to **${channel.name}**`,
-    );
+    sendSuccessMessage(pluginData, msg.channel, `**${renderUsername(args.member.user)}** moved to **${channel.name}**`);
   },
 });
 
@@ -157,7 +153,7 @@ export const VcmoveAllCmd = utilityCmd({
         sendErrorMessage(
           pluginData,
           msg.channel,
-          `Failed to move ${renderUserUsername(currMember.user)} (${currMember.id}): You cannot act on this member`,
+          `Failed to move ${renderUsername(currMember.user)} (${currMember.id}): You cannot act on this member`,
         );
         errAmt++;
         continue;
@@ -175,7 +171,7 @@ export const VcmoveAllCmd = utilityCmd({
         sendErrorMessage(
           pluginData,
           msg.channel,
-          `Failed to move ${renderUserUsername(currMember.user)} (${currMember.id})`,
+          `Failed to move ${renderUsername(currMember.user)} (${currMember.id})`,
         );
         errAmt++;
         continue;
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 5c795072..990c1f7e 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1607,15 +1607,11 @@ export const DBDateFormat = "YYYY-MM-DD HH:mm:ss";
 //export function renderUsername(username: GuildMember): string;
 //export function renderUsername(username: User): string;
 //export function renderUsername(username: string, discriminator?: string): string;
-export function renderUsername(username: string | User | GuildMember, discriminator?: string): string {
+export function renderUsername(username: string | User | GuildMember | UnknownUser, discriminator?: string): string {
   if (username instanceof GuildMember) return username.user.tag;
-  if (username instanceof User) return username.tag;
+  if (username instanceof User || username instanceof UnknownUser) return username.tag;
   if (discriminator === "0" || discriminator === "0000") {
     return username;
   }
   return `${username}#${discriminator}`;
 }
-
-export function renderUserUsername(user: User | UnknownUser): string {
-  return renderUsername(user.username, user.discriminator);
-}
diff --git a/backend/src/utils/templateSafeObjects.ts b/backend/src/utils/templateSafeObjects.ts
index 33e36971..d6a0dfd6 100644
--- a/backend/src/utils/templateSafeObjects.ts
+++ b/backend/src/utils/templateSafeObjects.ts
@@ -13,7 +13,7 @@ import {
   User,
 } from "discord.js";
 import { GuildPluginData } from "knub";
-import { UnknownUser, renderUserUsername } from "src/utils";
+import { UnknownUser, renderUsername } from "src/utils";
 import { Case } from "../data/entities/Case";
 import {
   ISavedMessageAttachmentData,
@@ -250,7 +250,7 @@ export function userToTemplateSafeUser(user: User | UnknownUser): TemplateSafeUs
       discriminator: "0000",
       mention: `<@${user.id}>`,
       tag: "Unknown#0000",
-      renderedUsername: renderUserUsername(user),
+      renderedUsername: renderUsername(user),
     });
   }
 
@@ -264,7 +264,7 @@ export function userToTemplateSafeUser(user: User | UnknownUser): TemplateSafeUs
     avatarURL: user.displayAvatarURL(),
     bot: user.bot,
     createdAt: user.createdTimestamp,
-    renderedUsername: renderUserUsername(user),
+    renderedUsername: renderUsername(user),
   });
 }
 

From 2e0598194f36ed9d2d12ca063cb37837b1f61556 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 16:02:15 +0000
Subject: [PATCH 16/51] fix renderUsername overloads

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/utils.ts | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 990c1f7e..72f203f2 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1603,14 +1603,12 @@ export function isTruthy<T>(value: T): value is Exclude<T, false | null | undefi
 
 export const DBDateFormat = "YYYY-MM-DD HH:mm:ss";
 
-// TODO: Fix overloads
-//export function renderUsername(username: GuildMember): string;
-//export function renderUsername(username: User): string;
-//export function renderUsername(username: string, discriminator?: string): string;
+export function renderUsername(memberOrUser: GuildMember | UnknownUser | User): string;
+export function renderUsername(username: string, discriminator: string): string;
 export function renderUsername(username: string | User | GuildMember | UnknownUser, discriminator?: string): string {
   if (username instanceof GuildMember) return username.user.tag;
   if (username instanceof User || username instanceof UnknownUser) return username.tag;
-  if (discriminator === "0" || discriminator === "0000") {
+  if (discriminator === "0") {
     return username;
   }
   return `${username}#${discriminator}`;

From aa1f11e8015b192af39d77bb73f7a5e914d1696f Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 16:10:10 +0000
Subject: [PATCH 17/51] lint

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/index.ts                                         | 2 +-
 backend/src/plugins/Automod/actions/startThread.ts           | 1 -
 backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts | 2 +-
 backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts | 5 +----
 backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts    | 2 +-
 backend/src/plugins/Utility/functions/getServerInfoEmbed.ts  | 2 +-
 .../src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts   | 2 +-
 backend/src/plugins/Utility/functions/getUserInfoEmbed.ts    | 2 +-
 8 files changed, 7 insertions(+), 11 deletions(-)

diff --git a/backend/src/index.ts b/backend/src/index.ts
index 2fe16c11..6dad14b1 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -203,7 +203,7 @@ if (env.DEBUG) {
 }
 
 logger.info("Connecting to database");
-connect().then(async (connection) => {
+connect().then(async () => {
   const client = new Client({
     partials: [Partials.User, Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction],
 
diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 970e9ec8..b5864389 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -41,7 +41,6 @@ export const StartThreadAction = automodAction({
       return true;
     });
 
-    const guild = pluginData.guild;
     const archiveSet = actionConfig.auto_archive
       ? Math.ceil(Math.max(convertDelayStringToMS(actionConfig.auto_archive) ?? 0, 0) / MINUTES)
       : ThreadAutoArchiveDuration.OneDay;
diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
index f601dd5c..c05bd2c1 100644
--- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
@@ -24,7 +24,7 @@ const MEDIA_CHANNEL_ICON = "https://cdn.discordapp.com/attachments/8761342052292
 export async function getChannelInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   channelId: string,
-  requestMemberId?: string,
+  //  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);
   if (!channel) {
diff --git a/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts b/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
index 3a49a80d..e272b904 100644
--- a/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
@@ -9,7 +9,6 @@ import {
   trimEmptyLines,
   trimLines,
 } from "../../../utils";
-import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
 import { UtilityPluginType } from "../types";
 
 const MESSAGE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/740685652152025088/message.png";
@@ -18,7 +17,7 @@ export async function getMessageInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   channelId: string,
   messageId: string,
-  requestMemberId?: string,
+  //  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const message = await (pluginData.guild.channels.resolve(channelId as Snowflake) as TextChannel).messages
     .fetch(messageId as Snowflake)
@@ -27,8 +26,6 @@ export async function getMessageInfoEmbed(
     return null;
   }
 
-  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
-
   const embed: EmbedWith<"fields" | "author"> = {
     fields: [],
     author: {
diff --git a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
index fa3188ec..c4d685d2 100644
--- a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
@@ -9,7 +9,7 @@ const MENTION_ICON = "https://cdn.discordapp.com/attachments/705009450855039042/
 export async function getRoleInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   role: Role,
-  requestMemberId?: string,
+  // requestMemberId?: string,
 ): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author" | "color"> = {
     fields: [],
diff --git a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
index afcefb80..8a2ebfc7 100644
--- a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
@@ -25,7 +25,7 @@ const prettifyFeature = (feature: string): string =>
 export async function getServerInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   serverId: string,
-  requestMemberId?: string,
+  //  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const thisServer = serverId === pluginData.guild.id ? pluginData.guild : null;
   const [restGuild, guildPreview] = await Promise.all([
diff --git a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
index ef676fd1..ba7c3374 100644
--- a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
@@ -10,7 +10,7 @@ export async function getSnowflakeInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   snowflake: string,
   showUnknownWarning = false,
-  requestMemberId?: string,
+  //  requestMemberId?: string,
 ): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author"> = {
     fields: [],
diff --git a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
index 58d34d56..d8ccc833 100644
--- a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
@@ -26,7 +26,7 @@ export async function getUserInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   userId: string,
   compact = false,
-  requestMemberId?: string,
+  //  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const user = await resolveUser(pluginData.client, userId);
   if (!user || user instanceof UnknownUser) {

From 89d6e8aeecabea2ecc6c788f7ded6c447a870028 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 16:11:12 +0000
Subject: [PATCH 18/51] missed one

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts
index 823bc726..c107791d 100644
--- a/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts
+++ b/backend/src/plugins/ModActions/commands/DeleteCaseCmd.ts
@@ -2,7 +2,7 @@ import { helpers } from "knub";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { Case } from "../../../data/entities/Case";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
-import { SECONDS, trimLines } from "../../../utils";
+import { SECONDS, renderUsername, trimLines } from "../../../utils";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
@@ -60,7 +60,7 @@ export const DeleteCaseCmd = modActionsCmd({
         }
       }
 
-      const deletedByName = message.author.tag;
+      const deletedByName = renderUsername(message.author);
 
       const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
       const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime"));

From e0637a206f0e9c7bae14ec61c3508b92a83136c1 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 16:12:57 +0000
Subject: [PATCH 19/51] better startThread if checks (almeida is happy)

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/Automod/actions/startThread.ts | 8 +-------
 1 file changed, 1 insertion(+), 7 deletions(-)

diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index b5864389..4930ebe5 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -50,13 +50,7 @@ export const StartThreadAction = automodAction({
 
     for (const threadContext of threads) {
       const channel = pluginData.guild.channels.cache.get(threadContext.message!.channel_id);
-      if (
-        !channel ||
-        !("threads" in channel) ||
-        channel.type === ChannelType.GuildForum ||
-        channel.type === ChannelType.GuildMedia
-      )
-        continue;
+      if (!channel || !("threads" in channel) || channel.isThreadOnly()) continue;
 
       const renderThreadName = async (str: string) =>
         renderTemplate(

From 2b5a5e636a230212383ff84af58bb20b97e9777f Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 17:02:54 +0000
Subject: [PATCH 20/51] almeida review.mp4

Signed-off-by: GitHub <noreply@github.com>
---
 .../functions/matchMultipleTextTypesOnMessage.ts       |  2 +-
 backend/src/plugins/Automod/triggers/roleAdded.ts      |  2 +-
 backend/src/plugins/Automod/triggers/roleRemoved.ts    |  2 +-
 backend/src/plugins/BotControl/commands/ServersCmd.ts  |  7 +++----
 backend/src/plugins/Cases/functions/createCase.ts      |  8 ++++----
 backend/src/plugins/ModActions/commands/CasesModCmd.ts |  8 ++++----
 .../src/plugins/ModActions/commands/CasesUserCmd.ts    |  2 +-
 backend/src/plugins/ModActions/commands/WarnCmd.ts     |  2 +-
 .../ModActions/events/PostAlertOnMemberJoinEvt.ts      |  4 +---
 .../ModActions/functions/actualKickMemberCmd.ts        |  2 +-
 backend/src/plugins/Mutes/commands/MutesCmd.ts         |  2 +-
 .../Starboard/util/createStarboardEmbedFromMessage.ts  |  2 +-
 backend/src/plugins/Utility/commands/AvatarCmd.ts      |  2 +-
 backend/src/plugins/Utility/commands/LevelCmd.ts       |  2 +-
 .../src/plugins/Utility/commands/VcdisconnectCmd.ts    |  2 +-
 backend/src/plugins/Utility/commands/VcmoveCmd.ts      | 10 +++-------
 .../src/plugins/Utility/functions/getUserInfoEmbed.ts  |  2 +-
 backend/src/plugins/Utility/search.ts                  |  4 ++--
 18 files changed, 29 insertions(+), 36 deletions(-)

diff --git a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts
index f0eee5ec..a82c526e 100644
--- a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts
+++ b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts
@@ -42,7 +42,7 @@ export async function* matchMultipleTextTypesOnMessage(
   }
 
   if (trigger.match_visible_names) {
-    yield ["visiblename", member.nickname || member.user.globalName || msg.data.author.username];
+    yield ["visiblename", member.displayName || msg.data.author.username];
   }
 
   if (trigger.match_usernames) {
diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts
index 754be1b3..5d18de99 100644
--- a/backend/src/plugins/Automod/triggers/roleAdded.ts
+++ b/backend/src/plugins/Automod/triggers/roleAdded.ts
@@ -38,7 +38,7 @@ export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
     const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake);
     const roleName = role?.name || "Unknown";
     const member = contexts[0].member!;
-    const memberName = `**${renderUsername(member.user)}** (\`${member.id}\`)`;
+    const memberName = `**${renderUsername(member)}** (\`${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
index fc5d5ae3..9452bdda 100644
--- a/backend/src/plugins/Automod/triggers/roleRemoved.ts
+++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts
@@ -38,7 +38,7 @@ export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
     const role = pluginData.guild.roles.cache.get(matchResult.extra.matchedRoleId as Snowflake);
     const roleName = role?.name || "Unknown";
     const member = contexts[0].member!;
-    const memberName = `**${renderUsername(member.user)}** (\`${member.id}\`)`;
+    const memberName = `**${renderUsername(member)}** (\`${member.id}\`)`;
     return `Role ${roleName} (\`${matchResult.extra.matchedRoleId}\`) was removed from ${memberName}`;
   },
 });
diff --git a/backend/src/plugins/BotControl/commands/ServersCmd.ts b/backend/src/plugins/BotControl/commands/ServersCmd.ts
index 3658a36c..5b2cf9c7 100644
--- a/backend/src/plugins/BotControl/commands/ServersCmd.ts
+++ b/backend/src/plugins/BotControl/commands/ServersCmd.ts
@@ -48,10 +48,9 @@ export const ServersCmd = botControlCmd({
         const lines = filteredGuilds.map((g) => {
           const paddedId = g.id.padEnd(longestId, " ");
           const owner = getUser(pluginData.client, g.ownerId);
-          return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(
-            owner.username,
-            owner.discriminator,
-          )}** \`${owner.id}\`)`;
+          return `\`${paddedId}\` **${g.name}** (${g.memberCount} members) (owner **${renderUsername(owner)}** \`${
+            owner.id
+          }\`)`;
         });
         createChunkedMessage(msg.channel, lines.join("\n"));
       } else {
diff --git a/backend/src/plugins/Cases/functions/createCase.ts b/backend/src/plugins/Cases/functions/createCase.ts
index 70717f51..c68b899f 100644
--- a/backend/src/plugins/Cases/functions/createCase.ts
+++ b/backend/src/plugins/Cases/functions/createCase.ts
@@ -8,16 +8,16 @@ import { postCaseToCaseLogChannel } from "./postToCaseLogChannel";
 
 export async function createCase(pluginData: GuildPluginData<CasesPluginType>, args: CaseArgs) {
   const user = await resolveUser(pluginData.client, args.userId);
-  const userName = renderUsername(user.username, user.discriminator);
+  const name = renderUsername(user);
 
   const mod = await resolveUser(pluginData.client, args.modId);
-  const modName = renderUsername(mod.username, mod.discriminator);
+  const modName = renderUsername(mod);
 
   let ppName: string | null = null;
   let ppId: Snowflake | null = null;
   if (args.ppId) {
     const pp = await resolveUser(pluginData.client, args.ppId);
-    ppName = renderUsername(pp.username, pp.discriminator);
+    ppName = renderUsername(pp);
     ppId = pp.id;
   }
 
@@ -32,7 +32,7 @@ export async function createCase(pluginData: GuildPluginData<CasesPluginType>, a
   const createdCase = await pluginData.state.cases.create({
     type: args.type,
     user_id: user.id,
-    user_name: userName,
+    user_name: name,
     mod_id: mod.id,
     mod_name: modName,
     audit_log_id: args.auditLogId,
diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
index a911091d..12b6c05c 100644
--- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
@@ -1,7 +1,7 @@
-import { APIEmbed, GuildMember, User } from "discord.js";
+import { APIEmbed } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage } from "../../../pluginUtils";
-import { emptyEmbedValue, renderUsername, resolveMember, resolveUser, trimLines } from "../../../utils";
+import { UnknownUser, emptyEmbedValue, renderUsername, resolveMember, resolveUser, trimLines } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { createPaginatedMessage } from "../../../utils/createPaginatedMessage";
 import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields";
@@ -31,7 +31,7 @@ export const CasesModCmd = modActionsCmd({
     const mod =
       (await resolveMember(pluginData.client, pluginData.guild, modId)) ||
       (await resolveUser(pluginData.client, modId));
-    const modName = mod instanceof User ? renderUsername(mod) : modId;
+    const modName = mod instanceof UnknownUser ? modId : renderUsername(mod);
 
     const casesPlugin = pluginData.getPlugin(CasesPlugin);
     const totalCases = await casesPlugin.getTotalCasesByMod(modId);
@@ -59,7 +59,7 @@ export const CasesModCmd = modActionsCmd({
         const embed = {
           author: {
             name: title,
-            icon_url: mod instanceof User || mod instanceof GuildMember ? mod.displayAvatarURL() : undefined,
+            icon_url: mod instanceof UnknownUser ? undefined : mod.displayAvatarURL(),
           },
           fields: [
             ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")),
diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 05ab65eb..7b3714b6 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -46,7 +46,7 @@ export const CasesUserCmd = modActionsCmd({
     const user =
       (await resolveMember(pluginData.client, pluginData.guild, args.user)) ||
       (await resolveUser(pluginData.client, args.user));
-    if (!user.id || user instanceof UnknownUser) {
+    if (user instanceof UnknownUser) {
       sendErrorMessage(pluginData, msg.channel, `User not found`);
       return;
     }
diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts
index f917c55e..218eeaf3 100644
--- a/backend/src/plugins/ModActions/commands/WarnCmd.ts
+++ b/backend/src/plugins/ModActions/commands/WarnCmd.ts
@@ -106,7 +106,7 @@ export const WarnCmd = modActionsCmd({
     sendSuccessMessage(
       pluginData,
       msg.channel,
-      `Warned **${renderUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`,
+      `Warned **${renderUsername(memberToWarn)}** (Case #${warnResult.case.case_number})${messageResultText}`,
     );
   },
 });
diff --git a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts
index 7874c241..82e39547 100644
--- a/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts
+++ b/backend/src/plugins/ModActions/events/PostAlertOnMemberJoinEvt.ts
@@ -46,9 +46,7 @@ export const PostAlertOnMemberJoinEvt = modActionsEvt({
       }
 
       await alertChannel.send(
-        `<@!${member.id}> (${renderUsername(member.user)} \`${member.id}\`) joined with ${
-          actions.length
-        } prior record(s)`,
+        `<@!${member.id}> (${renderUsername(member)} \`${member.id}\`) joined with ${actions.length} prior record(s)`,
       );
     }
   },
diff --git a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts
index fac3f906..a755ee08 100644
--- a/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts
+++ b/backend/src/plugins/ModActions/functions/actualKickMemberCmd.ts
@@ -103,7 +103,7 @@ export async function actualKickMemberCmd(
   }
 
   // Confirm the action to the moderator
-  let response = `Kicked **${renderUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`;
+  let response = `Kicked **${renderUsername(memberToKick)}** (Case #${kickResult.case.case_number})`;
 
   if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;
   sendSuccessMessage(pluginData, msg.channel, response);
diff --git a/backend/src/plugins/Mutes/commands/MutesCmd.ts b/backend/src/plugins/Mutes/commands/MutesCmd.ts
index 22cd74c5..5000218c 100644
--- a/backend/src/plugins/Mutes/commands/MutesCmd.ts
+++ b/backend/src/plugins/Mutes/commands/MutesCmd.ts
@@ -74,7 +74,7 @@ export const MutesCmd = mutesCmd({
       totalMutes = manuallyMutedMembers.length;
 
       lines = manuallyMutedMembers.map((member) => {
-        return `<@!${member.id}> (**${renderUsername(member.user)}**, \`${member.id}\`)   🔧 Manual mute`;
+        return `<@!${member.id}> (**${renderUsername(member)}**, \`${member.id}\`)   🔧 Manual mute`;
       });
     } else {
       // Show filtered active mutes (but not manual mutes)
diff --git a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts
index 5126f20f..93ba4dfd 100644
--- a/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts
+++ b/backend/src/plugins/Starboard/util/createStarboardEmbedFromMessage.ts
@@ -28,7 +28,7 @@ export function createStarboardEmbedFromMessage(
     embed.color = color;
   }
 
-  embed.author.icon_url = (msg.member || msg.author).displayAvatarURL();
+  embed.author.icon_url = (msg.member ?? msg.author).displayAvatarURL();
 
   // The second condition here checks for messages with only an image link that is then embedded.
   // The message content in that case is hidden by the Discord client, so we hide it here too.
diff --git a/backend/src/plugins/Utility/commands/AvatarCmd.ts b/backend/src/plugins/Utility/commands/AvatarCmd.ts
index b1215df6..e4ba07d5 100644
--- a/backend/src/plugins/Utility/commands/AvatarCmd.ts
+++ b/backend/src/plugins/Utility/commands/AvatarCmd.ts
@@ -14,7 +14,7 @@ export const AvatarCmd = utilityCmd({
   },
 
   async run({ message: msg, args, pluginData }) {
-    const user = args.user || msg.member || msg.author;
+    const user = args.user ?? msg.member ?? msg.author;
     if (!(user instanceof UnknownUser)) {
       const embed: APIEmbed = {
         image: {
diff --git a/backend/src/plugins/Utility/commands/LevelCmd.ts b/backend/src/plugins/Utility/commands/LevelCmd.ts
index df4d9325..5830f66a 100644
--- a/backend/src/plugins/Utility/commands/LevelCmd.ts
+++ b/backend/src/plugins/Utility/commands/LevelCmd.ts
@@ -18,6 +18,6 @@ export const LevelCmd = utilityCmd({
   run({ message, args, pluginData }) {
     const member = args.member || message.member;
     const level = getMemberLevel(pluginData, member);
-    message.channel.send(`The permission level of ${renderUsername(member.user)} is **${level}**`);
+    message.channel.send(`The permission level of ${renderUsername(member)} is **${level}**`);
   },
 });
diff --git a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts
index 240c763f..b6863599 100644
--- a/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts
+++ b/backend/src/plugins/Utility/commands/VcdisconnectCmd.ts
@@ -43,7 +43,7 @@ export const VcdisconnectCmd = utilityCmd({
     sendSuccessMessage(
       pluginData,
       msg.channel,
-      `**${renderUsername(args.member.user)}** disconnected from **${channel.name}**`,
+      `**${renderUsername(args.member)}** disconnected from **${channel.name}**`,
     );
   },
 });
diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts
index 6ffe8588..259c60b5 100644
--- a/backend/src/plugins/Utility/commands/VcmoveCmd.ts
+++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts
@@ -80,7 +80,7 @@ export const VcmoveCmd = utilityCmd({
       newChannel: channel,
     });
 
-    sendSuccessMessage(pluginData, msg.channel, `**${renderUsername(args.member.user)}** moved to **${channel.name}**`);
+    sendSuccessMessage(pluginData, msg.channel, `**${renderUsername(args.member)}** moved to **${channel.name}**`);
   },
 });
 
@@ -153,7 +153,7 @@ export const VcmoveAllCmd = utilityCmd({
         sendErrorMessage(
           pluginData,
           msg.channel,
-          `Failed to move ${renderUsername(currMember.user)} (${currMember.id}): You cannot act on this member`,
+          `Failed to move ${renderUsername(currMember)} (${currMember.id}): You cannot act on this member`,
         );
         errAmt++;
         continue;
@@ -168,11 +168,7 @@ export const VcmoveAllCmd = utilityCmd({
           sendErrorMessage(pluginData, msg.channel, "Unknown error when trying to move members");
           return;
         }
-        sendErrorMessage(
-          pluginData,
-          msg.channel,
-          `Failed to move ${renderUsername(currMember.user)} (${currMember.id})`,
-        );
+        sendErrorMessage(pluginData, msg.channel, `Failed to move ${renderUsername(currMember)} (${currMember.id})`);
         errAmt++;
         continue;
       }
diff --git a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
index d8ccc833..88e4aba0 100644
--- a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
@@ -43,7 +43,7 @@ export async function getUserInfoEmbed(
     name: `${user.bot ? "Bot" : "User"}:  ${renderUsername(user)}`,
   };
 
-  const avatarURL = (member || user).displayAvatarURL();
+  const avatarURL = (member ?? user).displayAvatarURL();
   embed.author.icon_url = avatarURL;
 
   if (compact) {
diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts
index 632a85a7..76c5af9c 100644
--- a/backend/src/plugins/Utility/search.ts
+++ b/backend/src/plugins/Utility/search.ts
@@ -381,7 +381,7 @@ async function performMemberSearch(
         return true;
       }
 
-      const fullUsername = renderUsername(member.user);
+      const fullUsername = renderUsername(member);
       if (await execRegExp(queryRegex, fullUsername).catch(allowTimeout)) return true;
 
       return false;
@@ -492,7 +492,7 @@ function formatSearchResultList(members: Array<GuildMember | User>): string {
     const paddedId = member.id.padEnd(longestId, " ");
     let line;
     if (member instanceof GuildMember) {
-      line = `${paddedId} ${renderUsername(member.user)}`;
+      line = `${paddedId} ${renderUsername(member)}`;
       if (member.nickname) line += ` (${member.nickname})`;
     } else {
       line = `${paddedId} ${renderUsername(member)}`;

From 2ac7ae85ce5db8c1551aa5cb9d18e2797a55ef4b Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 17:05:04 +0000
Subject: [PATCH 21/51] missed one

Signed-off-by: GitHub <noreply@github.com>
---
 .../plugins/ReactionRoles/util/addMemberPendingRoleChange.ts    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts
index dda8a6bc..8f25a0ae 100644
--- a/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts
+++ b/backend/src/plugins/ReactionRoles/util/addMemberPendingRoleChange.ts
@@ -33,7 +33,7 @@ export async function addMemberPendingRoleChange(
           try {
             await member.roles.set(Array.from(newRoleIds.values()), "Reaction roles");
           } catch (e) {
-            logger.warn(`Failed to apply role changes to ${renderUsername(member.user)} (${member.id}): ${e.message}`);
+            logger.warn(`Failed to apply role changes to ${renderUsername(member)} (${member.id}): ${e.message}`);
           }
         }
         lock.unlock();

From ae651c8a705fa82379d3b687e7fe46c2de474d56 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 10:55:47 +0000
Subject: [PATCH 22/51] paginated user cases

Signed-off-by: GitHub <noreply@github.com>
---
 .../ModActions/commands/CasesUserCmd.ts       | 83 +++++++++++--------
 1 file changed, 47 insertions(+), 36 deletions(-)

diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 069ad31f..6e5aaf88 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -5,6 +5,7 @@ import { sendErrorMessage } from "../../../pluginUtils";
 import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
 import { UnknownUser, chunkArray, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
+import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js";
 import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields";
 import { getGuildPrefix } from "../../../utils/getGuildPrefix";
 import { modActionsCmd } from "../types";
@@ -21,6 +22,8 @@ const opts = {
   unbans: ct.switchOption({ def: false, shortcut: "ub" }),
 };
 
+const casesPerPage = 10;
+
 export const CasesUserCmd = modActionsCmd({
   trigger: ["cases", "modlogs"],
   permission: "can_view",
@@ -90,49 +93,57 @@ export const CasesUserCmd = modActionsCmd({
       } else {
         // Compact view (= regular message with a preview of each case)
         const casesPlugin = pluginData.getPlugin(CasesPlugin);
-        const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id));
 
+        const totalPages = Math.max(Math.ceil(cases.length / casesPerPage), 1);
         const prefix = getGuildPrefix(pluginData);
-        const linesPerChunk = 10;
-        const lineChunks = chunkArray(lines, linesPerChunk);
 
-        const footerField = {
-          name: emptyEmbedValue,
-          value: trimLines(`
-            Use \`${prefix}case <num>\` to see more information about an individual case
-          `),
-        };
+        createPaginatedMessage(
+          pluginData.client,
+          msg.channel,
+          totalPages,
+          async (page) => {
+            const chunkedCases = chunkArray(cases, casesPerPage)[page - 1];
+            const lines = await asyncMap(chunkedCases, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id));
 
-        for (const [i, linesInChunk] of lineChunks.entries()) {
-          const isLastChunk = i === lineChunks.length - 1;
+            const isLastPage = page === totalPages;
+            const firstCaseNum = (page - 1) * casesPerPage + 1;
+            const lastCaseNum = page * casesPerPage;
+            const title =
+              totalPages === 1
+                ? `Cases for ${userName} (${lines.length} total)`
+                : `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${cases.length} for ${userName}`;
 
-          if (isLastChunk && !args.hidden && hiddenCases.length) {
-            if (hiddenCases.length === 1) {
-              linesInChunk.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`);
-            } else {
-              linesInChunk.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`);
-            }
-          }
+            const embed = {
+              author: {
+                name: title,
+                icon_url: user instanceof User ? user.displayAvatarURL() : undefined,
+              },
+              fields: [
+                ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")),
+                {
+                  name: emptyEmbedValue,
+                  value: trimLines(`
+                    Use \`${prefix}case <num>\` to see more information about an individual case
+                  `),
+                },
+              ],
+            } satisfies APIEmbed;
 
-          const chunkStart = i * linesPerChunk + 1;
-          const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length);
+            if (isLastPage && !args.hidden && hiddenCases.length)
+              embed.fields.push({
+                name: emptyEmbedValue,
+                value:
+                  hiddenCases.length === 1
+                    ? `*+${hiddenCases.length} hidden case, use "-hidden" to show it*`
+                    : `*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`,
+              });
 
-          const embed = {
-            author: {
-              name:
-                lineChunks.length === 1
-                  ? `Cases for ${userName} (${lines.length} total)`
-                  : `Cases ${chunkStart}–${chunkEnd} of ${lines.length} for ${userName}`,
-              icon_url: user instanceof User ? user.displayAvatarURL() : undefined,
-            },
-            fields: [
-              ...getChunkedEmbedFields(emptyEmbedValue, linesInChunk.join("\n")),
-              ...(isLastChunk ? [footerField] : []),
-            ],
-          } satisfies APIEmbed;
-
-          msg.channel.send({ embeds: [embed] });
-        }
+            return { embeds: [embed] };
+          },
+          {
+            limitToUserId: msg.author.id,
+          },
+        );
       }
     }
   },

From bb4fdf653095002a916b93e4daf8cceaea511022 Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Sun, 26 Nov 2023 11:49:33 +0000
Subject: [PATCH 23/51] move embed fields to embed description

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/ModActions/commands/CasesModCmd.ts  | 8 ++++----
 backend/src/plugins/ModActions/commands/CasesUserCmd.ts | 9 +++------
 2 files changed, 7 insertions(+), 10 deletions(-)

diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
index 5b0e3273..d520eede 100644
--- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
@@ -4,7 +4,6 @@ import { sendErrorMessage } from "../../../pluginUtils";
 import { emptyEmbedValue, resolveUser, trimLines } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { createPaginatedMessage } from "../../../utils/createPaginatedMessage";
-import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields";
 import { getGuildPrefix } from "../../../utils/getGuildPrefix";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { modActionsCmd } from "../types";
@@ -13,7 +12,7 @@ const opts = {
   mod: ct.userId({ option: true }),
 };
 
-const casesPerPage = 5;
+const casesPerPage = 10;
 
 export const CasesModCmd = modActionsCmd({
   trigger: ["cases", "modlogs", "infractions"],
@@ -50,8 +49,9 @@ export const CasesModCmd = modActionsCmd({
         const cases = await casesPlugin.getRecentCasesByMod(modId, casesPerPage, (page - 1) * casesPerPage);
         const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, msg.author.id));
 
+        const isLastPage = page === totalPages;
         const firstCaseNum = (page - 1) * casesPerPage + 1;
-        const lastCaseNum = page * casesPerPage;
+        const lastCaseNum = isLastPage ? totalCases : page * casesPerPage;
         const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`;
 
         const embed = {
@@ -59,8 +59,8 @@ export const CasesModCmd = modActionsCmd({
             name: title,
             icon_url: mod instanceof User ? mod.displayAvatarURL() : undefined,
           },
+          description: lines.join("\n"),
           fields: [
-            ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")),
             {
               name: emptyEmbedValue,
               value: trimLines(`
diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 6e5aaf88..15f42cf6 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -6,7 +6,6 @@ import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
 import { UnknownUser, chunkArray, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js";
-import { getChunkedEmbedFields } from "../../../utils/getChunkedEmbedFields";
 import { getGuildPrefix } from "../../../utils/getGuildPrefix";
 import { modActionsCmd } from "../types";
 
@@ -107,7 +106,7 @@ export const CasesUserCmd = modActionsCmd({
 
             const isLastPage = page === totalPages;
             const firstCaseNum = (page - 1) * casesPerPage + 1;
-            const lastCaseNum = page * casesPerPage;
+            const lastCaseNum = isLastPage ? cases.length : page * casesPerPage;
             const title =
               totalPages === 1
                 ? `Cases for ${userName} (${lines.length} total)`
@@ -118,13 +117,11 @@ export const CasesUserCmd = modActionsCmd({
                 name: title,
                 icon_url: user instanceof User ? user.displayAvatarURL() : undefined,
               },
+              description: lines.join("\n"),
               fields: [
-                ...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")),
                 {
                   name: emptyEmbedValue,
-                  value: trimLines(`
-                    Use \`${prefix}case <num>\` to see more information about an individual case
-                  `),
+                  value: trimLines(`Use \`${prefix}case <num>\` to see more information about an individual case`),
                 },
               ],
             } satisfies APIEmbed;

From c53bfaa2332e4fc948ba338b1188625103f5528e Mon Sep 17 00:00:00 2001
From: Tiago R <metal@i0.tf>
Date: Fri, 29 Dec 2023 17:03:45 +0000
Subject: [PATCH 24/51] remove trimLines

Signed-off-by: GitHub <noreply@github.com>
---
 backend/src/plugins/ModActions/commands/CasesUserCmd.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 15f42cf6..e8ce50ad 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -3,7 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { sendErrorMessage } from "../../../pluginUtils";
 import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
-import { UnknownUser, chunkArray, emptyEmbedValue, renderUserUsername, resolveUser, trimLines } from "../../../utils";
+import { UnknownUser, chunkArray, emptyEmbedValue, renderUserUsername, resolveUser } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js";
 import { getGuildPrefix } from "../../../utils/getGuildPrefix";
@@ -121,7 +121,7 @@ export const CasesUserCmd = modActionsCmd({
               fields: [
                 {
                   name: emptyEmbedValue,
-                  value: trimLines(`Use \`${prefix}case <num>\` to see more information about an individual case`),
+                  value: `Use \`${prefix}case <num>\` to see more information about an individual case`,
                 },
               ],
             } satisfies APIEmbed;

From 28692962bcd437659e05489f55a86fa5269e0681 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sun, 14 Jan 2024 14:25:42 +0000
Subject: [PATCH 25/51] refactor: replace io-ts with zod

---
 backend/package-lock.json                     |   9 -
 backend/package.json                          |   1 -
 backend/src/commandTypes.ts                   |   2 +-
 backend/src/configValidator.ts                |  16 +-
 backend/src/pluginUtils.ts                    |  48 +--
 .../plugins/AutoDelete/AutoDeletePlugin.ts    |   7 +-
 backend/src/plugins/AutoDelete/types.ts       |  13 +-
 .../AutoReactions/AutoReactionsPlugin.ts      |   7 +-
 backend/src/plugins/AutoReactions/types.ts    |   9 +-
 backend/src/plugins/Automod/AutomodPlugin.ts  | 132 +-------
 .../src/plugins/Automod/actions/addRoles.ts   |   9 +-
 .../plugins/Automod/actions/addToCounter.ts   |  15 +-
 backend/src/plugins/Automod/actions/alert.ts  |  22 +-
 .../plugins/Automod/actions/archiveThread.ts  |   7 +-
 .../Automod/actions/availableActions.ts       |  26 +-
 backend/src/plugins/Automod/actions/ban.ts    |  30 +-
 .../plugins/Automod/actions/changeNickname.ts |  14 +-
 .../plugins/Automod/actions/changePerms.ts    |  22 +-
 backend/src/plugins/Automod/actions/clean.ts  |   5 +-
 .../plugins/Automod/actions/exampleAction.ts  |   9 +-
 backend/src/plugins/Automod/actions/kick.ts   |  22 +-
 backend/src/plugins/Automod/actions/log.ts    |   5 +-
 backend/src/plugins/Automod/actions/mute.ts   |  28 +-
 .../plugins/Automod/actions/removeRoles.ts    |   8 +-
 backend/src/plugins/Automod/actions/reply.ts  |  22 +-
 .../Automod/actions/setAntiraidLevel.ts       |   6 +-
 .../src/plugins/Automod/actions/setCounter.ts |  11 +-
 .../plugins/Automod/actions/setSlowmode.ts    |  14 +-
 .../plugins/Automod/actions/startThread.ts    |  20 +-
 backend/src/plugins/Automod/actions/warn.ts   |  22 +-
 .../functions/createMessageSpamTrigger.ts     |  19 +-
 backend/src/plugins/Automod/helpers.ts        |  38 +--
 backend/src/plugins/Automod/info.ts           |   4 +-
 .../plugins/Automod/triggers/antiraidLevel.ts |  13 +-
 .../plugins/Automod/triggers/anyMessage.ts    |   8 +-
 .../Automod/triggers/availableTriggers.ts     |  45 +--
 backend/src/plugins/Automod/triggers/ban.ts   |  17 +-
 .../Automod/triggers/counterTrigger.ts        |  17 +-
 .../Automod/triggers/exampleTrigger.ts        |  14 +-
 backend/src/plugins/Automod/triggers/kick.ts  |  17 +-
 .../Automod/triggers/matchAttachmentType.ts   |  39 ++-
 .../plugins/Automod/triggers/matchInvites.ts  |  42 +--
 .../plugins/Automod/triggers/matchLinks.ts    |  60 ++--
 .../plugins/Automod/triggers/matchMimeType.ts |  39 ++-
 .../plugins/Automod/triggers/matchRegex.ts    |  42 +--
 .../plugins/Automod/triggers/matchWords.ts    |  49 ++-
 .../plugins/Automod/triggers/memberJoin.ts    |  19 +-
 .../Automod/triggers/memberJoinSpam.ts        |  16 +-
 .../plugins/Automod/triggers/memberLeave.ts   |   8 +-
 backend/src/plugins/Automod/triggers/mute.ts  |  17 +-
 backend/src/plugins/Automod/triggers/note.ts  |   7 +-
 .../src/plugins/Automod/triggers/roleAdded.ts |  13 +-
 .../plugins/Automod/triggers/roleRemoved.ts   |  13 +-
 .../plugins/Automod/triggers/threadArchive.ts |  13 +-
 .../plugins/Automod/triggers/threadCreate.ts  |   7 +-
 .../Automod/triggers/threadCreateSpam.ts      |  16 +-
 .../plugins/Automod/triggers/threadDelete.ts  |   7 +-
 .../Automod/triggers/threadUnarchive.ts       |  13 +-
 backend/src/plugins/Automod/triggers/unban.ts |   7 +-
 .../src/plugins/Automod/triggers/unmute.ts    |   7 +-
 backend/src/plugins/Automod/triggers/warn.ts  |  17 +-
 backend/src/plugins/Automod/types.ts          |  88 +++--
 .../plugins/BotControl/BotControlPlugin.ts    |   6 +-
 backend/src/plugins/BotControl/types.ts       |  21 +-
 backend/src/plugins/Cases/CasesPlugin.ts      |   8 +-
 backend/src/plugins/Cases/types.ts            |  26 +-
 backend/src/plugins/Censor/CensorPlugin.ts    |   7 +-
 backend/src/plugins/Censor/types.ts           |  36 +--
 .../ChannelArchiver/ChannelArchiverPlugin.ts  |   7 +-
 .../CompanionChannelsPlugin.ts                |   7 +-
 .../src/plugins/CompanionChannels/types.ts    |  29 +-
 .../plugins/ContextMenus/ContextMenuPlugin.ts |   5 +-
 .../src/plugins/ContextMenus/actions/mute.ts  |   4 +-
 backend/src/plugins/ContextMenus/types.ts     |  24 +-
 .../src/plugins/Counters/CountersPlugin.ts    |  68 +---
 .../getPrettyNameForCounterTrigger.ts         |   4 +-
 backend/src/plugins/Counters/types.ts         | 119 +++++--
 .../CustomEvents/CustomEventsPlugin.ts        |   5 +-
 .../CustomEvents/actions/addRoleAction.ts     |  14 +-
 .../CustomEvents/actions/createCaseAction.ts  |  19 +-
 .../actions/makeRoleMentionableAction.ts      |  14 +-
 .../actions/makeRoleUnmentionableAction.ts    |  11 +-
 .../CustomEvents/actions/messageAction.ts     |  13 +-
 .../actions/moveToVoiceChannelAction.ts       |  14 +-
 .../actions/setChannelPermissionOverrides.ts  |  25 +-
 backend/src/plugins/CustomEvents/types.ts     |  67 ++--
 .../GuildAccessMonitorPlugin.ts               |   5 +-
 .../GuildConfigReloaderPlugin.ts              |   5 +-
 .../GuildInfoSaver/GuildInfoSaverPlugin.ts    |   5 +-
 .../GuildMemberCachePlugin.ts                 |   6 +-
 .../InternalPoster/InternalPosterPlugin.ts    |   7 +-
 backend/src/plugins/InternalPoster/types.ts   |   6 -
 .../plugins/LocateUser/LocateUserPlugin.ts    |   7 +-
 backend/src/plugins/LocateUser/types.ts       |  11 +-
 backend/src/plugins/Logs/LogsPlugin.ts        |  18 +-
 .../Logs/logFunctions/logMessageDelete.ts     |   2 +-
 backend/src/plugins/Logs/types.ts             |  82 ++---
 .../MessageSaver/MessageSaverPlugin.ts        |   5 +-
 backend/src/plugins/MessageSaver/types.ts     |   9 +-
 .../plugins/ModActions/ModActionsPlugin.ts    |   8 +-
 backend/src/plugins/ModActions/types.ts       |  73 +++--
 backend/src/plugins/Mutes/MutesPlugin.ts      |   8 +-
 backend/src/plugins/Mutes/types.ts            |  39 ++-
 .../plugins/NameHistory/NameHistoryPlugin.ts  |   5 +-
 backend/src/plugins/NameHistory/types.ts      |   9 +-
 backend/src/plugins/Persist/PersistPlugin.ts  |   7 +-
 backend/src/plugins/Persist/types.ts          |  14 +-
 .../plugins/Phisherman/PhishermanPlugin.ts    |   6 +-
 backend/src/plugins/Phisherman/info.ts        |   4 +-
 backend/src/plugins/Phisherman/types.ts       |  10 +-
 .../PingableRoles/PingableRolesPlugin.ts      |   7 +-
 backend/src/plugins/PingableRoles/types.ts    |   9 +-
 backend/src/plugins/Post/PostPlugin.ts        |   7 +-
 backend/src/plugins/Post/types.ts             |   9 +-
 .../ReactionRoles/ReactionRolesPlugin.ts      |   7 +-
 backend/src/plugins/ReactionRoles/types.ts    |  24 +-
 .../src/plugins/Reminders/RemindersPlugin.ts  |   7 +-
 backend/src/plugins/Reminders/types.ts        |   9 +-
 .../plugins/RoleButtons/RoleButtonsPlugin.ts  |  41 +--
 backend/src/plugins/RoleButtons/info.ts       |   4 +-
 backend/src/plugins/RoleButtons/types.ts      | 126 +++++---
 .../plugins/RoleManager/RoleManagerPlugin.ts  |   6 +-
 backend/src/plugins/RoleManager/types.ts      |   7 +-
 backend/src/plugins/Roles/RolesPlugin.ts      |   9 +-
 backend/src/plugins/Roles/types.ts            |  13 +-
 .../SelfGrantableRolesPlugin.ts               |  22 +-
 .../src/plugins/SelfGrantableRoles/types.ts   |  38 ++-
 .../src/plugins/Slowmode/SlowmodePlugin.ts    |   7 +-
 backend/src/plugins/Slowmode/types.ts         |  15 +-
 backend/src/plugins/Spam/SpamPlugin.ts        |   7 +-
 backend/src/plugins/Spam/types.ts             |  53 +--
 .../src/plugins/Starboard/StarboardPlugin.ts  |  16 +-
 backend/src/plugins/Starboard/types.ts        |  46 ++-
 .../Starboard/util/preprocessStaticConfig.ts  |  12 -
 backend/src/plugins/Tags/TagsPlugin.ts        |  28 +-
 backend/src/plugins/Tags/types.ts             |  77 +++--
 .../src/plugins/Tags/util/findTagByName.ts    |   5 +-
 .../src/plugins/Tags/util/onMessageCreate.ts  |   9 +-
 .../plugins/TimeAndDate/TimeAndDatePlugin.ts  |   8 +-
 backend/src/plugins/TimeAndDate/types.ts      |  20 +-
 .../UsernameSaver/UsernameSaverPlugin.ts      |   6 +-
 backend/src/plugins/UsernameSaver/types.ts    |   4 +
 backend/src/plugins/Utility/UtilityPlugin.ts  |   8 +-
 backend/src/plugins/Utility/search.ts         |   3 +-
 backend/src/plugins/Utility/types.ts          |  61 ++--
 .../WelcomeMessage/WelcomeMessagePlugin.ts    |   7 +-
 backend/src/plugins/WelcomeMessage/types.ts   |  14 +-
 .../src/plugins/ZeppelinPluginBlueprint.ts    |   4 +-
 backend/src/types.ts                          |  31 +-
 backend/src/utils.test.ts                     |   6 +-
 backend/src/utils.ts                          | 302 ++++++------------
 backend/src/utils/iotsUtils.ts                |  17 -
 backend/src/utils/tColor.ts                   |  16 -
 backend/src/utils/tValidTimezone.ts           |  13 -
 backend/src/utils/typeUtils.ts                |   8 +
 backend/src/utils/zColor.ts                   |  15 +
 backend/src/utils/zValidTimezone.ts           |   8 +
 backend/src/validation.test.ts                |  40 ---
 backend/src/validatorUtils.ts                 | 140 --------
 package-lock.json                             |   7 +
 package.json                                  |   1 +
 161 files changed, 1450 insertions(+), 2105 deletions(-)
 delete mode 100644 backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
 delete mode 100644 backend/src/utils/iotsUtils.ts
 delete mode 100644 backend/src/utils/tColor.ts
 delete mode 100644 backend/src/utils/tValidTimezone.ts
 create mode 100644 backend/src/utils/zColor.ts
 create mode 100644 backend/src/utils/zValidTimezone.ts
 delete mode 100644 backend/src/validation.test.ts
 delete mode 100644 backend/src/validatorUtils.ts

diff --git a/backend/package-lock.json b/backend/package-lock.json
index b1d6d189..1f2f5992 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -22,7 +22,6 @@
         "express": "^4.17.0",
         "fp-ts": "^2.0.1",
         "humanize-duration": "^3.15.0",
-        "io-ts": "^2.0.0",
         "js-yaml": "^3.13.1",
         "knub": "^32.0.0-next.16",
         "knub-command-manager": "^9.1.0",
@@ -4975,14 +4974,6 @@
       "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
       "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
     },
-    "node_modules/io-ts": {
-      "version": "2.2.20",
-      "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.20.tgz",
-      "integrity": "sha512-Rq2BsYmtwS5vVttie4rqrOCIfHCS9TgpRLFpKQCM1wZBBRY9nWVGmEvm2FnDbSE2un1UE39DvFpTR5UL47YDcA==",
-      "peerDependencies": {
-        "fp-ts": "^2.5.0"
-      }
-    },
     "node_modules/iota-array": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
diff --git a/backend/package.json b/backend/package.json
index 2ff53482..fbbe2ddc 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -43,7 +43,6 @@
     "express": "^4.17.0",
     "fp-ts": "^2.0.1",
     "humanize-duration": "^3.15.0",
-    "io-ts": "^2.0.0",
     "js-yaml": "^3.13.1",
     "knub": "^32.0.0-next.16",
     "knub-command-manager": "^9.1.0",
diff --git a/backend/src/commandTypes.ts b/backend/src/commandTypes.ts
index 47ad0405..9ace5312 100644
--- a/backend/src/commandTypes.ts
+++ b/backend/src/commandTypes.ts
@@ -17,6 +17,7 @@ import { createTypeHelper } from "knub-command-manager";
 import {
   channelMentionRegex,
   convertDelayStringToMS,
+  inputPatternToRegExp,
   isValidSnowflake,
   resolveMember,
   resolveUser,
@@ -26,7 +27,6 @@ import {
 } from "./utils";
 import { isValidTimezone } from "./utils/isValidTimezone";
 import { MessageTarget, resolveMessageTarget } from "./utils/resolveMessageTarget";
-import { inputPatternToRegExp } from "./validatorUtils";
 
 export const commandTypes = {
   ...messageCommandBaseTypeConverters,
diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts
index 3bb602a4..96e08ba3 100644
--- a/backend/src/configValidator.ts
+++ b/backend/src/configValidator.ts
@@ -1,9 +1,9 @@
-import { ConfigValidationError, PluginConfigManager } from "knub";
+import { PluginConfigManager } from "knub";
 import moment from "moment-timezone";
+import { ZodError } from "zod";
 import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
 import { guildPlugins } from "./plugins/availablePlugins";
-import { PartialZeppelinGuildConfigSchema, ZeppelinGuildConfig } from "./types";
-import { StrictValidationError, decodeAndValidateStrict } from "./validatorUtils";
+import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types";
 
 const pluginNameToPlugin = new Map<string, ZeppelinPlugin>();
 for (const plugin of guildPlugins) {
@@ -11,8 +11,10 @@ for (const plugin of guildPlugins) {
 }
 
 export async function validateGuildConfig(config: any): Promise<string | null> {
-  const validationResult = decodeAndValidateStrict(PartialZeppelinGuildConfigSchema, config);
-  if (validationResult instanceof StrictValidationError) return validationResult.getErrors();
+  const validationResult = zZeppelinGuildConfig.safeParse(config);
+  if (!validationResult.success) {
+    return validationResult.error.issues.join("\n");
+  }
 
   const guildConfig = config as ZeppelinGuildConfig;
 
@@ -41,8 +43,8 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
       try {
         await configManager.init();
       } catch (err) {
-        if (err instanceof ConfigValidationError || err instanceof StrictValidationError) {
-          return `${pluginName}: ${err.message}`;
+        if (err instanceof ZodError) {
+          return `${pluginName}: ${err.issues.join("\n")}`;
         }
 
         throw err;
diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts
index 8e6c6da2..a2f744d9 100644
--- a/backend/src/pluginUtils.ts
+++ b/backend/src/pluginUtils.ts
@@ -10,22 +10,18 @@ import {
   PermissionsBitField,
   TextBasedChannel,
 } from "discord.js";
-import * as t from "io-ts";
 import {
   AnyPluginData,
   CommandContext,
-  ConfigValidationError,
   ExtendedMatchParams,
   GuildPluginData,
-  PluginOverrideCriteria,
-  helpers,
+  helpers
 } from "knub";
 import { logger } from "./logger";
 import { isStaff } from "./staff";
 import { TZeppelinKnub } from "./types";
-import { errorMessage, successMessage, tNullable } from "./utils";
+import { errorMessage, successMessage } from "./utils";
 import { Tail } from "./utils/typeUtils";
-import { StrictValidationError, parseIoTsSchema } from "./validatorUtils";
 
 const { getMemberLevel } = helpers;
 
@@ -59,46 +55,6 @@ export async function hasPermission(
   return helpers.hasPermission(config, permission);
 }
 
-const PluginOverrideCriteriaType: t.Type<PluginOverrideCriteria<unknown>> = t.recursion(
-  "PluginOverrideCriteriaType",
-  () =>
-    t.partial({
-      channel: tNullable(t.union([t.string, t.array(t.string)])),
-      category: tNullable(t.union([t.string, t.array(t.string)])),
-      level: tNullable(t.union([t.string, t.array(t.string)])),
-      user: tNullable(t.union([t.string, t.array(t.string)])),
-      role: tNullable(t.union([t.string, t.array(t.string)])),
-
-      all: tNullable(t.array(PluginOverrideCriteriaType)),
-      any: tNullable(t.array(PluginOverrideCriteriaType)),
-      not: tNullable(PluginOverrideCriteriaType),
-
-      extra: t.unknown,
-    }),
-);
-
-export function strictValidationErrorToConfigValidationError(err: StrictValidationError) {
-  return new ConfigValidationError(
-    err
-      .getErrors()
-      .map((e) => e.toString())
-      .join("\n"),
-  );
-}
-
-export function makeIoTsConfigParser<Schema extends t.Type<any>>(schema: Schema): (input: unknown) => t.TypeOf<Schema> {
-  return (input: unknown) => {
-    try {
-      return parseIoTsSchema(schema, input);
-    } catch (err) {
-      if (err instanceof StrictValidationError) {
-        throw strictValidationErrorToConfigValidationError(err);
-      }
-      throw err;
-    }
-  };
-}
-
 export async function sendSuccessMessage(
   pluginData: AnyPluginData<any>,
   channel: TextBasedChannel,
diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts
index 0a8e0438..d1273577 100644
--- a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts
+++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts
@@ -1,11 +1,10 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
-import { AutoDeletePluginType, ConfigSchema } from "./types";
+import { AutoDeletePluginType, zAutoDeleteConfig } from "./types";
 import { onMessageCreate } from "./util/onMessageCreate";
 import { onMessageDelete } from "./util/onMessageDelete";
 import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk";
@@ -24,11 +23,11 @@ export const AutoDeletePlugin = zeppelinGuildPlugin<AutoDeletePluginType>()({
     prettyName: "Auto-delete",
     description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
     configurationGuide: "Maximum deletion delay is currently 5 minutes",
-    configSchema: ConfigSchema,
+    configSchema: zAutoDeleteConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zAutoDeleteConfig.parse(input),
   defaultOptions,
 
   beforeLoad(pluginData) {
diff --git a/backend/src/plugins/AutoDelete/types.ts b/backend/src/plugins/AutoDelete/types.ts
index 24389860..69100438 100644
--- a/backend/src/plugins/AutoDelete/types.ts
+++ b/backend/src/plugins/AutoDelete/types.ts
@@ -1,10 +1,10 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { SavedMessage } from "../../data/entities/SavedMessage";
-import { MINUTES, tDelayString } from "../../utils";
+import { MINUTES, zDelayString } from "../../utils";
 import Timeout = NodeJS.Timeout;
+import z from "zod";
 
 export const MAX_DELAY = 5 * MINUTES;
 
@@ -13,14 +13,13 @@ export interface IDeletionQueueItem {
   message: SavedMessage;
 }
 
-export const ConfigSchema = t.type({
-  enabled: t.boolean,
-  delay: tDelayString,
+export const zAutoDeleteConfig = z.strictObject({
+  enabled: z.boolean(),
+  delay: zDelayString,
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface AutoDeletePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zAutoDeleteConfig>;
   state: {
     guildSavedMessages: GuildSavedMessages;
     guildLogs: GuildLogs;
diff --git a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts
index 8ec1ecd7..9b01d538 100644
--- a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts
+++ b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts
@@ -1,14 +1,13 @@
 import { PluginOptions } from "knub";
 import { GuildAutoReactions } from "../../data/GuildAutoReactions";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd";
 import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd";
 import { AddReactionsEvt } from "./events/AddReactionsEvt";
-import { AutoReactionsPluginType, ConfigSchema } from "./types";
+import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types";
 
 const defaultOptions: PluginOptions<AutoReactionsPluginType> = {
   config: {
@@ -32,7 +31,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
     description: trimPluginDescription(`
       Allows setting up automatic reactions to all new messages on a channel
     `),
-    configSchema: ConfigSchema,
+    configSchema: zAutoReactionsConfig,
   },
 
   // prettier-ignore
@@ -40,7 +39,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin<AutoReactionsPluginType>(
     LogsPlugin,
   ],
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zAutoReactionsConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/AutoReactions/types.ts b/backend/src/plugins/AutoReactions/types.ts
index a02c3c26..996fba8d 100644
--- a/backend/src/plugins/AutoReactions/types.ts
+++ b/backend/src/plugins/AutoReactions/types.ts
@@ -1,17 +1,16 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildAutoReactions } from "../../data/GuildAutoReactions";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { AutoReaction } from "../../data/entities/AutoReaction";
 
-export const ConfigSchema = t.type({
-  can_manage: t.boolean,
+export const zAutoReactionsConfig = z.strictObject({
+  can_manage: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface AutoReactionsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zAutoReactionsConfig>;
   state: {
     logs: GuildLogs;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts
index ff4f01fd..9634c25d 100644
--- a/backend/src/plugins/Automod/AutomodPlugin.ts
+++ b/backend/src/plugins/Automod/AutomodPlugin.ts
@@ -1,4 +1,4 @@
-import { configUtils, CooldownManager } from "knub";
+import { CooldownManager } from "knub";
 import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
@@ -8,7 +8,6 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { MINUTES, SECONDS } from "../../utils";
 import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap";
 import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap";
-import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils";
 import { CountersPlugin } from "../Counters/CountersPlugin";
 import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
@@ -17,7 +16,6 @@ import { MutesPlugin } from "../Mutes/MutesPlugin";
 import { PhishermanPlugin } from "../Phisherman/PhishermanPlugin";
 import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
-import { availableActions } from "./actions/availableActions";
 import { AntiraidClearCmd } from "./commands/AntiraidClearCmd";
 import { SetAntiraidCmd } from "./commands/SetAntiraidCmd";
 import { ViewAntiraidCmd } from "./commands/ViewAntiraidCmd";
@@ -35,8 +33,7 @@ import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChang
 import { clearOldRecentActions } from "./functions/clearOldRecentActions";
 import { clearOldRecentSpam } from "./functions/clearOldRecentSpam";
 import { pluginInfo } from "./info";
-import { availableTriggers } from "./triggers/availableTriggers";
-import { AutomodPluginType, ConfigSchema } from "./types";
+import { AutomodPluginType, zAutomodConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -61,129 +58,6 @@ const defaultOptions = {
   ],
 };
 
-/**
- * Config preprocessor to set default values for triggers and perform extra validation
- * TODO: Separate input and output types
- */
-const configParser = (input: unknown) => {
-  const rules = (input as any).rules;
-  if (rules) {
-    // Loop through each rule
-    for (const [name, rule] of Object.entries(rules)) {
-      if (rule == null) {
-        delete rules[name];
-        continue;
-      }
-
-      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["allow_further_rules"] == null) {
-        rule["allow_further_rules"] = false;
-      }
-
-      if (rule["affects_bots"] == null) {
-        rule["affects_bots"] = false;
-      }
-
-      if (rule["affects_self"] == null) {
-        rule["affects_self"] = 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];
-
-            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;
-              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>`,
-                ]);
-              }
-            }
-
-            if (triggerObj[triggerName].match_mime_type) {
-              const white = triggerObj[triggerName].match_mime_type.whitelist_enabled;
-              const black = triggerObj[triggerName].match_mime_type.blacklist_enabled;
-
-              if (white && black) {
-                throw new StrictValidationError([
-                  `Cannot have both blacklist and whitelist enabled at rule <${rule["name"]}/match_mime_type>`,
-                ]);
-              } else if (!white && !black) {
-                throw new StrictValidationError([
-                  `Must have either blacklist or whitelist enabled at rule <${rule["name"]}/match_mime_type>`,
-                ]);
-              }
-            }
-          }
-        }
-      }
-
-      if (rule["actions"]) {
-        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];
-
-          if (typeof actionConfig !== "object" || Array.isArray(actionConfig) || actionConfig == null) {
-            rule["actions"][actionName] = actionConfig;
-          } else {
-            rule["actions"][actionName] = configUtils.mergeConfig(actionBlueprint.defaultConfig, actionConfig);
-          }
-        }
-      }
-
-      // 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;
-        }
-        if (rule["actions"]["clean"] && rule["actions"]["start_thread"]) {
-          throw new StrictValidationError([`Cannot have both clean and start_thread at rule '${rule["name"]}'`]);
-        }
-      }
-    }
-  }
-
-  return parseIoTsSchema(ConfigSchema, input);
-};
-
 export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
   name: "automod",
   showInDocs: true,
@@ -201,7 +75,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()({
   ],
 
   defaultOptions,
-  configParser,
+  configParser: (input) => zAutomodConfig.parse(input),
 
   customOverrideCriteriaFunctions: {
     antiraid_level: (pluginData, matchParams, value) => {
diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts
index 53b00005..89294728 100644
--- a/backend/src/plugins/Automod/actions/addRoles.ts
+++ b/backend/src/plugins/Automod/actions/addRoles.ts
@@ -1,6 +1,6 @@
 import { PermissionFlagsBits, Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { nonNullish, unique } from "../../../utils";
+import z from "zod";
+import { nonNullish, unique, zSnowflake } from "../../../utils";
 import { canAssignRole } from "../../../utils/canAssignRole";
 import { getMissingPermissions } from "../../../utils/getMissingPermissions";
 import { missingPermissionError } from "../../../utils/missingPermissionError";
@@ -11,9 +11,10 @@ import { automodAction } from "../helpers";
 
 const p = PermissionFlagsBits;
 
+const configSchema = z.array(zSnowflake);
+
 export const AddRolesAction = automodAction({
-  configType: t.array(t.string),
-  defaultConfig: [],
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const members = unique(contexts.map((c) => c.member).filter(nonNullish));
diff --git a/backend/src/plugins/Automod/actions/addToCounter.ts b/backend/src/plugins/Automod/actions/addToCounter.ts
index c3a72dae..30ffe8fa 100644
--- a/backend/src/plugins/Automod/actions/addToCounter.ts
+++ b/backend/src/plugins/Automod/actions/addToCounter.ts
@@ -1,15 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
+import { zBoundedCharacters } from "../../../utils";
 import { CountersPlugin } from "../../Counters/CountersPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
-export const AddToCounterAction = automodAction({
-  configType: t.type({
-    counter: t.string,
-    amount: t.number,
-  }),
+const configSchema = z.object({
+  counter: zBoundedCharacters(0, 100),
+  amount: z.number(),
+});
 
-  defaultConfig: {},
+export const AddToCounterAction = automodAction({
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const countersPlugin = pluginData.getPlugin(CountersPlugin);
diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts
index eb6f3348..1a767a0e 100644
--- a/backend/src/plugins/Automod/actions/alert.ts
+++ b/backend/src/plugins/Automod/actions/alert.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
+import z from "zod";
 import { LogType } from "../../../data/LogType";
 import {
   createTypedTemplateSafeValueContainer,
@@ -12,10 +12,12 @@ import {
   chunkMessageLines,
   isTruthy,
   messageLink,
-  tAllowedMentions,
-  tNormalizedNullOptional,
   validateAndParseMessageContent,
   verboseChannelMention,
+  zAllowedMentions,
+  zBoundedCharacters,
+  zNullishToUndefined,
+  zSnowflake
 } from "../../../utils";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
 import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
@@ -23,14 +25,14 @@ import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin"
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
-export const AlertAction = automodAction({
-  configType: t.type({
-    channel: t.string,
-    text: t.string,
-    allowed_mentions: tNormalizedNullOptional(tAllowedMentions),
-  }),
+const configSchema = z.object({
+  channel: zSnowflake,
+  text: zBoundedCharacters(1, 4000),
+  allowed_mentions: zNullishToUndefined(zAllowedMentions.nullable().default(null)),
+});
 
-  defaultConfig: {},
+export const AlertAction = automodAction({
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) {
     const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake);
diff --git a/backend/src/plugins/Automod/actions/archiveThread.ts b/backend/src/plugins/Automod/actions/archiveThread.ts
index d94afdf7..e73cb3dd 100644
--- a/backend/src/plugins/Automod/actions/archiveThread.ts
+++ b/backend/src/plugins/Automod/actions/archiveThread.ts
@@ -1,11 +1,12 @@
 import { AnyThreadChannel } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { noop } from "../../../utils";
 import { automodAction } from "../helpers";
 
+const configSchema = z.strictObject({});
+
 export const ArchiveThreadAction = automodAction({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async apply({ pluginData, contexts }) {
     const threads = contexts
diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts
index 3f253bc0..dd148ca7 100644
--- a/backend/src/plugins/Automod/actions/availableActions.ts
+++ b/backend/src/plugins/Automod/actions/availableActions.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import { AutomodActionBlueprint } from "../helpers";
 import { AddRolesAction } from "./addRoles";
 import { AddToCounterAction } from "./addToCounter";
@@ -19,7 +18,7 @@ import { SetSlowmodeAction } from "./setSlowmode";
 import { StartThreadAction } from "./startThread";
 import { WarnAction } from "./warn";
 
-export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
+export const availableActions = {
   clean: CleanAction,
   warn: WarnAction,
   mute: MuteAction,
@@ -38,25 +37,4 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
   start_thread: StartThreadAction,
   archive_thread: ArchiveThreadAction,
   change_perms: ChangePermsAction,
-};
-
-export const AvailableActions = t.type({
-  clean: CleanAction.configType,
-  warn: WarnAction.configType,
-  mute: MuteAction.configType,
-  kick: KickAction.configType,
-  ban: BanAction.configType,
-  alert: AlertAction.configType,
-  change_nickname: ChangeNicknameAction.configType,
-  log: LogAction.configType,
-  add_roles: AddRolesAction.configType,
-  remove_roles: RemoveRolesAction.configType,
-  set_antiraid_level: SetAntiraidLevelAction.configType,
-  reply: ReplyAction.configType,
-  add_to_counter: AddToCounterAction.configType,
-  set_counter: SetCounterAction.configType,
-  set_slowmode: SetSlowmodeAction.configType,
-  start_thread: StartThreadAction.configType,
-  archive_thread: ArchiveThreadAction.configType,
-  change_perms: ChangePermsAction.configType,
-});
+} satisfies Record<string, AutomodActionBlueprint<any>>;
diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts
index 9cd0fd86..dcc9563f 100644
--- a/backend/src/plugins/Automod/actions/ban.ts
+++ b/backend/src/plugins/Automod/actions/ban.ts
@@ -1,25 +1,23 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, nonNullish, tDelayString, tNullable, unique } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
+
+const configSchema = z.strictObject({
+  reason: zBoundedCharacters(0, 4000).nullable().default(null),
+  duration: zDelayString.nullable().default(null),
+  notify: zNotify.nullable().default(null),
+  notifyChannel: zSnowflake.nullable().default(null),
+  deleteMessageDays: z.number().nullable().default(null),
+  postInCaseLog: z.boolean().nullable().default(null),
+  hide_case: z.boolean().nullable().default(false),
+});
 
 export const BanAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    duration: tNullable(tDelayString),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    deleteMessageDays: tNullable(t.number),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
-  }),
-
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
+  configSchema,
 
   async apply({ pluginData, contexts, actionConfig, matchResult }) {
     const reason = actionConfig.reason || "Kicked automatically";
diff --git a/backend/src/plugins/Automod/actions/changeNickname.ts b/backend/src/plugins/Automod/actions/changeNickname.ts
index d63a3b60..ce7c610e 100644
--- a/backend/src/plugins/Automod/actions/changeNickname.ts
+++ b/backend/src/plugins/Automod/actions/changeNickname.ts
@@ -1,18 +1,16 @@
-import * as t from "io-ts";
-import { nonNullish, unique } from "../../../utils";
+import z from "zod";
+import { nonNullish, unique, zBoundedCharacters } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const ChangeNicknameAction = automodAction({
-  configType: t.union([
-    t.string,
-    t.type({
-      name: t.string,
+  configSchema: z.union([
+    zBoundedCharacters(0, 32),
+    z.strictObject({
+      name: zBoundedCharacters(0, 32),
     }),
   ]),
 
-  defaultConfig: {},
-
   async apply({ pluginData, contexts, actionConfig }) {
     const members = unique(contexts.map((c) => c.member).filter(nonNullish));
 
diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts
index 1eaa6dd5..b5bc9686 100644
--- a/backend/src/plugins/Automod/actions/changePerms.ts
+++ b/backend/src/plugins/Automod/actions/changePerms.ts
@@ -1,7 +1,8 @@
 import { PermissionsBitField, PermissionsString } from "discord.js";
-import * as t from "io-ts";
+import { U } from "ts-toolbelt";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { isValidSnowflake, noop, tNullable, tPartialDictionary } from "../../../utils";
+import { isValidSnowflake, keys, noop, zSnowflake } from "../../../utils";
 import {
   guildToTemplateSafeGuild,
   savedMessageToTemplateSafeSavedMessage,
@@ -59,16 +60,19 @@ const realToLegacyMap = Object.entries(legacyPermMap).reduce((map, pair) => {
   return map;
 }, {}) as Record<keyof typeof PermissionsBitField.Flags, keyof typeof legacyPermMap>;
 
+const permissionNames = keys(PermissionsBitField.Flags) as U.ListOf<keyof typeof PermissionsBitField.Flags>;
+const legacyPermissionNames = keys(legacyPermMap) as U.ListOf<keyof typeof legacyPermMap>;
+const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const;
+
 export const ChangePermsAction = automodAction({
-  configType: t.type({
-    target: t.string,
-    channel: tNullable(t.string),
-    perms: tPartialDictionary(
-      t.union([t.keyof(PermissionsBitField.Flags), t.keyof(legacyPermMap)]),
-      tNullable(t.boolean),
+  configSchema: z.strictObject({
+    target: zSnowflake,
+    channel: zSnowflake.nullable().default(null),
+    perms: z.record(
+      z.enum(allPermissionNames),
+      z.boolean().nullable(),
     ),
   }),
-  defaultConfig: {},
 
   async apply({ pluginData, contexts, actionConfig }) {
     const user = contexts.find((c) => c.user)?.user;
diff --git a/backend/src/plugins/Automod/actions/clean.ts b/backend/src/plugins/Automod/actions/clean.ts
index 91bf37ac..5c66d370 100644
--- a/backend/src/plugins/Automod/actions/clean.ts
+++ b/backend/src/plugins/Automod/actions/clean.ts
@@ -1,12 +1,11 @@
 import { GuildTextBasedChannel, Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { LogType } from "../../../data/LogType";
 import { noop } from "../../../utils";
 import { automodAction } from "../helpers";
 
 export const CleanAction = automodAction({
-  configType: t.boolean,
-  defaultConfig: false,
+  configSchema: z.boolean().default(false),
 
   async apply({ pluginData, contexts, ruleName }) {
     const messageIdsToDeleteByChannelId: Map<string, string[]> = new Map();
diff --git a/backend/src/plugins/Automod/actions/exampleAction.ts b/backend/src/plugins/Automod/actions/exampleAction.ts
index 05bea0de..a43fc676 100644
--- a/backend/src/plugins/Automod/actions/exampleAction.ts
+++ b/backend/src/plugins/Automod/actions/exampleAction.ts
@@ -1,13 +1,12 @@
-import * as t from "io-ts";
+import z from "zod";
+import { zBoundedCharacters } from "../../../utils";
 import { automodAction } from "../helpers";
 
 export const ExampleAction = automodAction({
-  configType: t.type({
-    someValue: t.string,
+  configSchema: z.strictObject({
+    someValue: zBoundedCharacters(0, 1000),
   }),
 
-  defaultConfig: {},
-
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   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
index 9e6a792d..5afba467 100644
--- a/backend/src/plugins/Automod/actions/kick.ts
+++ b/backend/src/plugins/Automod/actions/kick.ts
@@ -1,24 +1,20 @@
-import * as t from "io-ts";
-import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
+import z from "zod";
+import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
 
 export const KickAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
+  configSchema: z.strictObject({
+    reason: zBoundedCharacters(0, 4000).nullable().default(null),
+    notify: zNotify.nullable().default(null),
+    notifyChannel: zSnowflake.nullable().default(null),
+    postInCaseLog: z.boolean().nullable().default(null),
+    hide_case: z.boolean().nullable().default(false),
   }),
 
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
-
   async apply({ pluginData, contexts, actionConfig, matchResult }) {
     const reason = actionConfig.reason || "Kicked automatically";
     const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
diff --git a/backend/src/plugins/Automod/actions/log.ts b/backend/src/plugins/Automod/actions/log.ts
index 82075a2e..dace25f1 100644
--- a/backend/src/plugins/Automod/actions/log.ts
+++ b/backend/src/plugins/Automod/actions/log.ts
@@ -1,11 +1,10 @@
-import * as t from "io-ts";
+import z from "zod";
 import { isTruthy, unique } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const LogAction = automodAction({
-  configType: t.boolean,
-  defaultConfig: true,
+  configSchema: z.boolean().default(true),
 
   async apply({ pluginData, contexts, ruleName, matchResult }) {
     const users = unique(contexts.map((c) => c.user)).filter(isTruthy);
diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts
index 3219c712..4a2a42ad 100644
--- a/backend/src/plugins/Automod/actions/mute.ts
+++ b/backend/src/plugins/Automod/actions/mute.ts
@@ -1,29 +1,25 @@
-import * as t from "io-ts";
+import z from "zod";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
-import { convertDelayStringToMS, nonNullish, tDelayString, tNullable, unique } from "../../../utils";
+import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
 
 export const MuteAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    duration: tNullable(tDelayString),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-    restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
+  configSchema: z.strictObject({
+    reason: zBoundedCharacters(0, 4000).nullable().default(null),
+    duration: zDelayString.nullable().default(null),
+    notify: zNotify.nullable().default(null),
+    notifyChannel: zSnowflake.nullable().default(null),
+    remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null),
+    restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null),
+    postInCaseLog: z.boolean().nullable().default(null),
+    hide_case: z.boolean().nullable().default(false),
   }),
 
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
-
   async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) {
     const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;
     const reason = actionConfig.reason || "Muted automatically";
diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts
index a46d8262..8b065702 100644
--- a/backend/src/plugins/Automod/actions/removeRoles.ts
+++ b/backend/src/plugins/Automod/actions/removeRoles.ts
@@ -1,6 +1,6 @@
 import { PermissionFlagsBits, Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { nonNullish, unique } from "../../../utils";
+import z from "zod";
+import { nonNullish, unique, zSnowflake } from "../../../utils";
 import { canAssignRole } from "../../../utils/canAssignRole";
 import { getMissingPermissions } from "../../../utils/getMissingPermissions";
 import { memberRolesLock } from "../../../utils/lockNameHelpers";
@@ -12,9 +12,7 @@ import { automodAction } from "../helpers";
 const p = PermissionFlagsBits;
 
 export const RemoveRolesAction = automodAction({
-  configType: t.array(t.string),
-
-  defaultConfig: [],
+  configSchema: z.array(zSnowflake).default([]),
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const members = unique(contexts.map((c) => c.member).filter(nonNullish));
diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts
index 6239cee8..02ff9545 100644
--- a/backend/src/plugins/Automod/actions/reply.ts
+++ b/backend/src/plugins/Automod/actions/reply.ts
@@ -1,16 +1,16 @@
 import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import {
   convertDelayStringToMS,
   noop,
   renderRecursively,
-  tDelayString,
-  tMessageContent,
-  tNullable,
   unique,
   validateAndParseMessageContent,
   verboseChannelMention,
+  zBoundedCharacters,
+  zDelayString,
+  zMessageContent
 } from "../../../utils";
 import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
@@ -20,17 +20,15 @@ import { automodAction } from "../helpers";
 import { AutomodContext } from "../types";
 
 export const ReplyAction = automodAction({
-  configType: t.union([
-    t.string,
-    t.type({
-      text: tMessageContent,
-      auto_delete: tNullable(t.union([tDelayString, t.number])),
-      inline: tNullable(t.boolean),
+  configSchema: z.union([
+    zBoundedCharacters(0, 4000),
+    z.strictObject({
+      text: zMessageContent,
+      auto_delete: z.union([zDelayString, z.number()]).nullable().default(null),
+      inline: z.boolean().default(false),
     }),
   ]),
 
-  defaultConfig: {},
-
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const contextsWithTextChannels = contexts
       .filter((c) => c.message?.channel_id)
diff --git a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts
index ecddabd9..db2a6bef 100644
--- a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts
+++ b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts
@@ -1,11 +1,9 @@
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import { zBoundedCharacters } from "../../../utils";
 import { setAntiraidLevel } from "../functions/setAntiraidLevel";
 import { automodAction } from "../helpers";
 
 export const SetAntiraidLevelAction = automodAction({
-  configType: tNullable(t.string),
-  defaultConfig: "",
+  configSchema: zBoundedCharacters(0, 100).nullable(),
 
   async apply({ pluginData, actionConfig }) {
     setAntiraidLevel(pluginData, actionConfig ?? null);
diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts
index 3286fb4b..dea4bdd6 100644
--- a/backend/src/plugins/Automod/actions/setCounter.ts
+++ b/backend/src/plugins/Automod/actions/setCounter.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
+import z from "zod";
+import { zBoundedCharacters } from "../../../utils";
 import { CountersPlugin } from "../../Counters/CountersPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const SetCounterAction = automodAction({
-  configType: t.type({
-    counter: t.string,
-    value: t.number,
+  configSchema: z.strictObject({
+    counter: zBoundedCharacters(0, 100),
+    value: z.number(),
   }),
 
-  defaultConfig: {},
-
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const countersPlugin = pluginData.getPlugin(CountersPlugin);
     if (!countersPlugin.counterExists(actionConfig.counter)) {
diff --git a/backend/src/plugins/Automod/actions/setSlowmode.ts b/backend/src/plugins/Automod/actions/setSlowmode.ts
index 7b527f10..ecac0f21 100644
--- a/backend/src/plugins/Automod/actions/setSlowmode.ts
+++ b/backend/src/plugins/Automod/actions/setSlowmode.ts
@@ -1,19 +1,15 @@
 import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from "../../../utils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 export const SetSlowmodeAction = automodAction({
-  configType: t.type({
-    channels: t.array(t.string),
-    duration: tNullable(tDelayString),
+  configSchema: z.strictObject({
+    channels: z.array(zSnowflake),
+    duration: zDelayString.nullable().default("10s"),
   }),
 
-  defaultConfig: {
-    duration: "10s",
-  },
-
   async apply({ pluginData, actionConfig }) {
     const slowmodeMs = Math.max(actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : 0, 0);
 
diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 25b840e1..e0bc11a4 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -5,9 +5,9 @@ import {
   ThreadAutoArchiveDuration,
   ThreadChannel,
 } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { MINUTES, convertDelayStringToMS, noop, tDelayString, tNullable } from "../../../utils";
+import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils";
 import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { automodAction } from "../helpers";
 
@@ -19,18 +19,14 @@ const validThreadAutoArchiveDurations: ThreadAutoArchiveDuration[] = [
 ];
 
 export const StartThreadAction = automodAction({
-  configType: t.type({
-    name: tNullable(t.string),
-    auto_archive: tDelayString,
-    private: tNullable(t.boolean),
-    slowmode: tNullable(tDelayString),
-    limit_per_channel: tNullable(t.number),
+  configSchema: z.strictObject({
+    name: zBoundedCharacters(1, 100).nullable(),
+    auto_archive: zDelayString,
+    private: z.boolean().default(false),
+    slowmode: zDelayString.nullable().default(null),
+    limit_per_channel: z.number().nullable().default(5),
   }),
 
-  defaultConfig: {
-    limit_per_channel: 5,
-  },
-
   async apply({ pluginData, contexts, actionConfig }) {
     // check if the message still exists, we don't want to create threads for deleted messages
     const threads = contexts.filter((c) => {
diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts
index 59135cb2..e8c63340 100644
--- a/backend/src/plugins/Automod/actions/warn.ts
+++ b/backend/src/plugins/Automod/actions/warn.ts
@@ -1,24 +1,20 @@
-import * as t from "io-ts";
-import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils";
+import z from "zod";
+import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
+import { zNotify } from "../types";
 
 export const WarnAction = automodAction({
-  configType: t.type({
-    reason: tNullable(t.string),
-    notify: tNullable(t.string),
-    notifyChannel: tNullable(t.string),
-    postInCaseLog: tNullable(t.boolean),
-    hide_case: tNullable(t.boolean),
+  configSchema: z.strictObject({
+    reason: zBoundedCharacters(0, 4000).nullable().default(null),
+    notify: zNotify.nullable().default(null),
+    notifyChannel: zSnowflake.nullable().default(null),
+    postInCaseLog: z.boolean().nullable().default(null),
+    hide_case: z.boolean().nullable().default(false),
   }),
 
-  defaultConfig: {
-    notify: null, // Use defaults from ModActions
-    hide_case: false,
-  },
-
   async apply({ pluginData, contexts, actionConfig, matchResult }) {
     const reason = actionConfig.reason || "Warned automatically";
     const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
index a1858d37..19364eee 100644
--- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
+++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
@@ -1,28 +1,27 @@
-import * as t from "io-ts";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
 import { humanizeDurationShort } from "../../../humanizeDurationShort";
 import { getBaseUrl } from "../../../pluginUtils";
-import { convertDelayStringToMS, sorter, tDelayString, tNullable } from "../../../utils";
+import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils";
 import { RecentActionType } from "../constants";
 import { automodTrigger } from "../helpers";
 import { findRecentSpam } from "./findRecentSpam";
 import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions";
 import { getMessageSpamIdentifier } from "./getSpamIdentifier";
-
-const MessageSpamTriggerConfig = t.type({
-  amount: t.number,
-  within: tDelayString,
-  per_channel: tNullable(t.boolean),
-});
+import z from "zod";
 
 interface TMessageSpamMatchResultType {
   archiveId: string;
 }
 
+const configSchema = z.strictObject({
+  amount: z.number().int(),
+  within: zDelayString,
+  per_channel: z.boolean().optional(),
+});
+
 export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
   return automodTrigger<TMessageSpamMatchResultType>()({
-    configType: MessageSpamTriggerConfig,
-    defaultConfig: {},
+    configSchema,
 
     async match({ pluginData, context, triggerConfig }) {
       if (!context.message) {
diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts
index 5c5f55c7..d1c04d61 100644
--- a/backend/src/plugins/Automod/helpers.ts
+++ b/backend/src/plugins/Automod/helpers.ts
@@ -1,7 +1,7 @@
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
 import { Awaitable } from "../../utils/typeUtils";
 import { AutomodContext, AutomodPluginType } from "./types";
+import z, { ZodTypeAny } from "zod";
 
 interface BaseAutomodTriggerMatchResult {
   extraContexts?: AutomodContext[];
@@ -31,21 +31,19 @@ type AutomodTriggerRenderMatchInformationFn<TConfigType, TMatchResultExtra> = (m
   matchResult: AutomodTriggerMatchResult<TMatchResultExtra>;
 }) => Awaitable<string>;
 
-export interface AutomodTriggerBlueprint<TConfigType extends t.Any, TMatchResultExtra> {
-  configType: TConfigType;
-  defaultConfig: Partial<t.TypeOf<TConfigType>>;
-
-  match: AutomodTriggerMatchFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
-  renderMatchInformation: AutomodTriggerRenderMatchInformationFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
+export interface AutomodTriggerBlueprint<TConfigSchema extends ZodTypeAny, TMatchResultExtra> {
+  configSchema: TConfigSchema;
+  match: AutomodTriggerMatchFn<z.output<TConfigSchema>, TMatchResultExtra>;
+  renderMatchInformation: AutomodTriggerRenderMatchInformationFn<z.output<TConfigSchema>, TMatchResultExtra>;
 }
 
-export function automodTrigger<TMatchResultExtra>(): <TConfigType extends t.Any>(
-  blueprint: AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>,
-) => AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>;
+export function automodTrigger<TMatchResultExtra>(): <TConfigSchema extends ZodTypeAny>(
+  blueprint: AutomodTriggerBlueprint<TConfigSchema, TMatchResultExtra>,
+) => AutomodTriggerBlueprint<TConfigSchema, TMatchResultExtra>;
 
-export function automodTrigger<TConfigType extends t.Any>(
-  blueprint: AutomodTriggerBlueprint<TConfigType, unknown>,
-): AutomodTriggerBlueprint<TConfigType, unknown>;
+export function automodTrigger<TConfigSchema extends ZodTypeAny>(
+  blueprint: AutomodTriggerBlueprint<TConfigSchema, unknown>,
+): AutomodTriggerBlueprint<TConfigSchema, unknown>;
 
 export function automodTrigger(...args) {
   if (args.length) {
@@ -63,15 +61,13 @@ type AutomodActionApplyFn<TConfigType> = (meta: {
   matchResult: AutomodTriggerMatchResult;
 }) => Awaitable<void>;
 
-export interface AutomodActionBlueprint<TConfigType extends t.Any> {
-  configType: TConfigType;
-  defaultConfig: Partial<t.TypeOf<TConfigType>>;
-
-  apply: AutomodActionApplyFn<t.TypeOf<TConfigType>>;
+export interface AutomodActionBlueprint<TConfigSchema extends ZodTypeAny> {
+  configSchema: TConfigSchema;
+  apply: AutomodActionApplyFn<z.output<TConfigSchema>>;
 }
 
-export function automodAction<TConfigType extends t.Any>(
-  blueprint: AutomodActionBlueprint<TConfigType>,
-): AutomodActionBlueprint<TConfigType> {
+export function automodAction<TConfigSchema extends ZodTypeAny>(
+  blueprint: AutomodActionBlueprint<TConfigSchema>,
+): AutomodActionBlueprint<TConfigSchema> {
   return blueprint;
 }
diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts
index e0102459..a2071e21 100644
--- a/backend/src/plugins/Automod/info.ts
+++ b/backend/src/plugins/Automod/info.ts
@@ -1,6 +1,6 @@
 import { trimPluginDescription } from "../../utils";
 import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
-import { ConfigSchema } from "./types";
+import { zAutomodConfig } from "./types";
 
 export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
   prettyName: "Automod",
@@ -100,5 +100,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
                     {matchSummary}
       ~~~
     `),
-  configSchema: ConfigSchema,
+  configSchema: zAutomodConfig,
 };
diff --git a/backend/src/plugins/Automod/triggers/antiraidLevel.ts b/backend/src/plugins/Automod/triggers/antiraidLevel.ts
index 7c6d17af..c879455f 100644
--- a/backend/src/plugins/Automod/triggers/antiraidLevel.ts
+++ b/backend/src/plugins/Automod/triggers/antiraidLevel.ts
@@ -1,15 +1,14 @@
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
 import { automodTrigger } from "../helpers";
+import z from "zod";
 
 interface AntiraidLevelTriggerResult {}
 
-export const AntiraidLevelTrigger = automodTrigger<AntiraidLevelTriggerResult>()({
-  configType: t.type({
-    level: tNullable(t.string),
-  }),
+const configSchema = z.strictObject({
+  level: z.nullable(z.string().max(100)),
+});
 
-  defaultConfig: {},
+export const AntiraidLevelTrigger = automodTrigger<AntiraidLevelTriggerResult>()({
+  configSchema,
 
   async match({ triggerConfig, context }) {
     if (!context.antiraid) {
diff --git a/backend/src/plugins/Automod/triggers/anyMessage.ts b/backend/src/plugins/Automod/triggers/anyMessage.ts
index 5b611334..93ce0abc 100644
--- a/backend/src/plugins/Automod/triggers/anyMessage.ts
+++ b/backend/src/plugins/Automod/triggers/anyMessage.ts
@@ -1,14 +1,14 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
+import z from "zod";
 
 interface AnyMessageResultType {}
 
-export const AnyMessageTrigger = automodTrigger<AnyMessageResultType>()({
-  configType: t.type({}),
+const configSchema = z.strictObject({});
 
-  defaultConfig: {},
+export const AnyMessageTrigger = automodTrigger<AnyMessageResultType>()({
+  configSchema,
 
   async match({ context }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts
index 822e44ff..a2ac60fb 100644
--- a/backend/src/plugins/Automod/triggers/availableTriggers.ts
+++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import { AutomodTriggerBlueprint } from "../helpers";
 import { AntiraidLevelTrigger } from "./antiraidLevel";
 import { AnyMessageTrigger } from "./anyMessage";
@@ -45,6 +44,7 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
   match_attachment_type: MatchAttachmentTypeTrigger,
   match_mime_type: MatchMimeTypeTrigger,
   member_join: MemberJoinTrigger,
+  member_leave: MemberLeaveTrigger,
   role_added: RoleAddedTrigger,
   role_removed: RoleRemovedTrigger,
 
@@ -76,46 +76,3 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
   thread_archive: ThreadArchiveTrigger,
   thread_unarchive: ThreadUnarchiveTrigger,
 };
-
-export const AvailableTriggers = t.type({
-  any_message: AnyMessageTrigger.configType,
-
-  match_words: MatchWordsTrigger.configType,
-  match_regex: MatchRegexTrigger.configType,
-  match_invites: MatchInvitesTrigger.configType,
-  match_links: MatchLinksTrigger.configType,
-  match_attachment_type: MatchAttachmentTypeTrigger.configType,
-  match_mime_type: MatchMimeTypeTrigger.configType,
-  member_join: MemberJoinTrigger.configType,
-  member_leave: MemberLeaveTrigger.configType,
-  role_added: RoleAddedTrigger.configType,
-  role_removed: RoleRemovedTrigger.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,
-  sticker_spam: StickerSpamTrigger.configType,
-  thread_create_spam: ThreadCreateSpamTrigger.configType,
-
-  counter_trigger: CounterTrigger.configType,
-
-  note: NoteTrigger.configType,
-  warn: WarnTrigger.configType,
-  mute: MuteTrigger.configType,
-  unmute: UnmuteTrigger.configType,
-  kick: KickTrigger.configType,
-  ban: BanTrigger.configType,
-  unban: UnbanTrigger.configType,
-
-  antiraid_level: AntiraidLevelTrigger.configType,
-
-  thread_create: ThreadCreateTrigger.configType,
-  thread_delete: ThreadDeleteTrigger.configType,
-  thread_archive: ThreadArchiveTrigger.configType,
-  thread_unarchive: ThreadUnarchiveTrigger.configType,
-});
diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts
index f8a4730b..f6df0fd6 100644
--- a/backend/src/plugins/Automod/triggers/ban.ts
+++ b/backend/src/plugins/Automod/triggers/ban.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface BanTriggerResultType {}
 
-export const BanTrigger = automodTrigger<BanTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const BanTrigger = automodTrigger<BanTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "ban") {
diff --git a/backend/src/plugins/Automod/triggers/counterTrigger.ts b/backend/src/plugins/Automod/triggers/counterTrigger.ts
index 58f21402..22de3df1 100644
--- a/backend/src/plugins/Automod/triggers/counterTrigger.ts
+++ b/backend/src/plugins/Automod/triggers/counterTrigger.ts
@@ -1,18 +1,17 @@
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line
 interface CounterTriggerResult {}
 
-export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
-  configType: t.type({
-    counter: t.string,
-    trigger: t.string,
-    reverse: tNullable(t.boolean),
-  }),
+const configSchema = z.strictObject({
+  counter: z.string().max(100),
+  trigger: z.string().max(100),
+  reverse: z.boolean().optional(),
+});
 
-  defaultConfig: {},
+export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
+  configSchema,
 
   async match({ triggerConfig, context }) {
     if (!context.counterTrigger) {
diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts
index bf0880a9..e6005e07 100644
--- a/backend/src/plugins/Automod/triggers/exampleTrigger.ts
+++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts
@@ -1,18 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ExampleMatchResultType {
   isBanana: boolean;
 }
 
-export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
-  configType: t.type({
-    allowedFruits: t.array(t.string),
-  }),
+const configSchema = z.strictObject({
+  allowedFruits: z.array(z.string().max(100)).max(50).default(["peach", "banana"]),
+});
 
-  defaultConfig: {
-    allowedFruits: ["peach", "banana"],
-  },
+export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
+  configSchema,
 
   async match({ triggerConfig, context }) {
     const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit);
diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts
index 163d6bf3..add6803e 100644
--- a/backend/src/plugins/Automod/triggers/kick.ts
+++ b/backend/src/plugins/Automod/triggers/kick.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface KickTriggerResultType {}
 
-export const KickTrigger = automodTrigger<KickTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const KickTrigger = automodTrigger<KickTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "kick") {
diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
index 659be65b..45f7ede7 100644
--- a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
+++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
@@ -1,5 +1,5 @@
 import { escapeInlineCode, Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
@@ -8,20 +8,31 @@ interface MatchResultType {
   mode: "blacklist" | "whitelist";
 }
 
-export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    filetype_blacklist: t.array(t.string),
-    blacklist_enabled: t.boolean,
-    filetype_whitelist: t.array(t.string),
-    whitelist_enabled: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  filetype_blacklist: z.array(z.string().max(32)).max(255).default([]),
+  blacklist_enabled: z.boolean().default(false),
+  filetype_whitelist: z.array(z.string().max(32)).max(255).default([]),
+  whitelist_enabled: z.boolean().default(false),
+}).transform((parsed, ctx) => {
+  if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Cannot have both blacklist and whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Must have either blacklist or whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  return parsed;
+});
 
-  defaultConfig: {
-    filetype_blacklist: [],
-    blacklist_enabled: false,
-    filetype_whitelist: [],
-    whitelist_enabled: false,
-  },
+export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchInvites.ts b/backend/src/plugins/Automod/triggers/matchInvites.ts
index 9e0b0c4e..4ea7c3cd 100644
--- a/backend/src/plugins/Automod/triggers/matchInvites.ts
+++ b/backend/src/plugins/Automod/triggers/matchInvites.ts
@@ -1,5 +1,5 @@
-import * as t from "io-ts";
-import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, tNullable } from "../../../utils";
+import z from "zod";
+import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
 import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
 import { automodTrigger } from "../helpers";
@@ -10,30 +10,22 @@ interface MatchResultType {
   invite?: GuildInvite;
 }
 
-export const MatchInvitesTrigger = automodTrigger<MatchResultType>()({
-  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,
-  }),
+const configSchema = z.strictObject({
+  include_guilds: z.array(zSnowflake).max(255).optional(),
+  exclude_guilds: z.array(zSnowflake).max(255).optional(),
+  include_invite_codes: z.array(z.string().max(32)).max(255).optional(),
+  exclude_invite_codes: z.array(z.string().max(32)).max(255).optional(),
+  allow_group_dm_invites: z.boolean().default(false),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(false),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
 
-  defaultConfig: {
-    allow_group_dm_invites: false,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-  },
+export const MatchInvitesTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts
index 5a8de7de..128b3cd1 100644
--- a/backend/src/plugins/Automod/triggers/matchLinks.ts
+++ b/backend/src/plugins/Automod/triggers/matchLinks.ts
@@ -1,11 +1,10 @@
 import { escapeInlineCode } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { allowTimeout } from "../../../RegExpRunner";
 import { phishermanDomainIsSafe } from "../../../data/Phisherman";
-import { getUrlsInString, tNullable } from "../../../utils";
+import { getUrlsInString, zRegex } from "../../../utils";
 import { mergeRegexes } from "../../../utils/mergeRegexes";
 import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex";
-import { TRegex } from "../../../validatorUtils";
 import { PhishermanPlugin } from "../../Phisherman/PhishermanPlugin";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
 import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
@@ -21,40 +20,29 @@ const regexCache = new WeakMap<any, RegExp[]>();
 
 const quickLinkCheck = /^https?:\/\//i;
 
-export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
-  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(TRegex)),
-    exclude_regex: tNullable(t.array(TRegex)),
-    phisherman: tNullable(
-      t.type({
-        include_suspected: tNullable(t.boolean),
-        include_verified: tNullable(t.boolean),
-      }),
-    ),
-    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,
-  }),
+const configSchema = z.strictObject({
+  include_domains: z.array(z.string().max(255)).max(255).optional(),
+  exclude_domains: z.array(z.string().max(255)).max(255).optional(),
+  include_subdomains: z.boolean().default(true),
+  include_words: z.array(z.string().max(2000)).max(512).optional(),
+  exclude_words: z.array(z.string().max(2000)).max(512).optional(),
+  include_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
+  exclude_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
+  phisherman: z.strictObject({
+    include_suspected: z.boolean().optional(),
+    include_verified: z.boolean().optional(),
+  }).optional(),
+  only_real_links: z.boolean(),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(true),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
 
-  defaultConfig: {
-    include_subdomains: true,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-    only_real_links: true,
-  },
+export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchMimeType.ts b/backend/src/plugins/Automod/triggers/matchMimeType.ts
index 1af12471..8da9b2e3 100644
--- a/backend/src/plugins/Automod/triggers/matchMimeType.ts
+++ b/backend/src/plugins/Automod/triggers/matchMimeType.ts
@@ -1,5 +1,5 @@
 import { escapeInlineCode } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
@@ -8,20 +8,31 @@ interface MatchResultType {
   mode: "blacklist" | "whitelist";
 }
 
-export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    mime_type_blacklist: t.array(t.string),
-    blacklist_enabled: t.boolean,
-    mime_type_whitelist: t.array(t.string),
-    whitelist_enabled: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]),
+  blacklist_enabled: z.boolean().default(false),
+  mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]),
+  whitelist_enabled: z.boolean().default(false),
+}).transform((parsed, ctx) => {
+  if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Cannot have both blacklist and whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Must have either blacklist or whitelist enabled",
+    });
+    return z.NEVER;
+  }
+  return parsed;
+});
 
-  defaultConfig: {
-    mime_type_blacklist: [],
-    blacklist_enabled: false,
-    mime_type_whitelist: [],
-    whitelist_enabled: false,
-  },
+export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig: trigger }) {
     if (!context.message) return;
diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts
index 7d011891..41df0396 100644
--- a/backend/src/plugins/Automod/triggers/matchRegex.ts
+++ b/backend/src/plugins/Automod/triggers/matchRegex.ts
@@ -1,9 +1,9 @@
-import * as t from "io-ts";
+import z from "zod";
 import { allowTimeout } from "../../../RegExpRunner";
+import { zRegex } from "../../../utils";
 import { mergeRegexes } from "../../../utils/mergeRegexes";
 import { normalizeText } from "../../../utils/normalizeText";
 import { stripMarkdown } from "../../../utils/stripMarkdown";
-import { TRegex } from "../../../validatorUtils";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
 import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
 import { automodTrigger } from "../helpers";
@@ -13,33 +13,23 @@ interface MatchResultType {
   type: MatchableTextType;
 }
 
+const configSchema = z.strictObject({
+  patterns: z.array(zRegex(z.string().max(2000))).max(512),
+  case_sensitive: z.boolean().default(false),
+  normalize: z.boolean().default(false),
+  strip_markdown: z.boolean().default(false),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(false),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
+
 const regexCache = new WeakMap<any, RegExp[]>();
 
 export const MatchRegexTrigger = automodTrigger<MatchResultType>()({
-  configType: t.type({
-    patterns: t.array(TRegex),
-    case_sensitive: t.boolean,
-    normalize: t.boolean,
-    strip_markdown: 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,
-    strip_markdown: false,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-  },
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts
index c5ca79fc..e8c275e1 100644
--- a/backend/src/plugins/Automod/triggers/matchWords.ts
+++ b/backend/src/plugins/Automod/triggers/matchWords.ts
@@ -1,5 +1,5 @@
 import escapeStringRegexp from "escape-string-regexp";
-import * as t from "io-ts";
+import z from "zod";
 import { normalizeText } from "../../../utils/normalizeText";
 import { stripMarkdown } from "../../../utils/stripMarkdown";
 import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary";
@@ -13,37 +13,24 @@ interface MatchResultType {
 
 const regexCache = new WeakMap<any, RegExp[]>();
 
-export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
-  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,
-    strip_markdown: 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,
-  }),
+const configSchema = z.strictObject({
+  words: z.array(z.string().max(2000)).max(512),
+  case_sensitive: z.boolean().default(false),
+  only_full_words: z.boolean().default(true),
+  normalize: z.boolean().default(false),
+  loose_matching: z.boolean().default(false),
+  loose_matching_threshold: z.number().int().default(4),
+  strip_markdown: z.boolean().default(false),
+  match_messages: z.boolean().default(true),
+  match_embeds: z.boolean().default(false),
+  match_visible_names: z.boolean().default(false),
+  match_usernames: z.boolean().default(false),
+  match_nicknames: z.boolean().default(false),
+  match_custom_status: z.boolean().default(false),
+});
 
-  defaultConfig: {
-    case_sensitive: false,
-    only_full_words: true,
-    normalize: false,
-    loose_matching: false,
-    loose_matching_threshold: 4,
-    strip_markdown: false,
-    match_messages: true,
-    match_embeds: false,
-    match_visible_names: false,
-    match_usernames: false,
-    match_nicknames: false,
-    match_custom_status: false,
-  },
+export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig: trigger }) {
     if (!context.message) {
diff --git a/backend/src/plugins/Automod/triggers/memberJoin.ts b/backend/src/plugins/Automod/triggers/memberJoin.ts
index f62d87aa..af9a1fae 100644
--- a/backend/src/plugins/Automod/triggers/memberJoin.ts
+++ b/backend/src/plugins/Automod/triggers/memberJoin.ts
@@ -1,17 +1,14 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, tDelayString } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, zDelayString } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
-export const MemberJoinTrigger = automodTrigger<unknown>()({
-  configType: t.type({
-    only_new: t.boolean,
-    new_threshold: tDelayString,
-  }),
+const configSchema = z.strictObject({
+  only_new: z.boolean().default(false),
+  new_threshold: zDelayString.default("1h"),
+});
 
-  defaultConfig: {
-    only_new: false,
-    new_threshold: "1h",
-  },
+export const MemberJoinTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (!context.joined || !context.member) {
diff --git a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts
index c55c8895..e346a8d3 100644
--- a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts
+++ b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts
@@ -1,18 +1,18 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, tDelayString } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, zDelayString } from "../../../utils";
 import { RecentActionType } from "../constants";
 import { findRecentSpam } from "../functions/findRecentSpam";
 import { getMatchingRecentActions } from "../functions/getMatchingRecentActions";
 import { sumRecentActionCounts } from "../functions/sumRecentActionCounts";
 import { automodTrigger } from "../helpers";
 
-export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
-  configType: t.type({
-    amount: t.number,
-    within: tDelayString,
-  }),
+const configSchema = z.strictObject({
+  amount: z.number().int(),
+  within: zDelayString,
+});
 
-  defaultConfig: {},
+export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig }) {
     if (!context.joined || !context.member) {
diff --git a/backend/src/plugins/Automod/triggers/memberLeave.ts b/backend/src/plugins/Automod/triggers/memberLeave.ts
index c5a25033..fa9f37a8 100644
--- a/backend/src/plugins/Automod/triggers/memberLeave.ts
+++ b/backend/src/plugins/Automod/triggers/memberLeave.ts
@@ -1,10 +1,10 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
-export const MemberLeaveTrigger = automodTrigger<unknown>()({
-  configType: t.type({}),
+const configSchema = z.strictObject({});
 
-  defaultConfig: {},
+export const MemberLeaveTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ context }) {
     if (!context.joined || !context.member) {
diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts
index 144c30f2..dcaf96c5 100644
--- a/backend/src/plugins/Automod/triggers/mute.ts
+++ b/backend/src/plugins/Automod/triggers/mute.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface MuteTriggerResultType {}
 
-export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "mute") {
diff --git a/backend/src/plugins/Automod/triggers/note.ts b/backend/src/plugins/Automod/triggers/note.ts
index c6d11be7..eeaa2c27 100644
--- a/backend/src/plugins/Automod/triggers/note.ts
+++ b/backend/src/plugins/Automod/triggers/note.ts
@@ -1,12 +1,13 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface NoteTriggerResultType {}
 
+const configSchema = z.strictObject({});
+
 export const NoteTrigger = automodTrigger<NoteTriggerResultType>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (context.modAction?.type !== "note") {
diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts
index dc62f163..47027b6b 100644
--- a/backend/src/plugins/Automod/triggers/roleAdded.ts
+++ b/backend/src/plugins/Automod/triggers/roleAdded.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { renderUserUsername } from "../../../utils";
+import z from "zod";
+import { renderUserUsername, zSnowflake } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
@@ -8,10 +8,13 @@ interface RoleAddedMatchResult {
   matchedRoleId: string;
 }
 
-export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
-  configType: t.union([t.string, t.array(t.string)]),
+const configSchema = z.union([
+  zSnowflake,
+  z.array(zSnowflake).max(255),
+]).default([]);
 
-  defaultConfig: "",
+export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
+  configSchema,
 
   async match({ triggerConfig, context, pluginData }) {
     if (!context.member || !context.rolesChanged || context.rolesChanged.added!.length === 0) {
diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts
index 65624827..26caf227 100644
--- a/backend/src/plugins/Automod/triggers/roleRemoved.ts
+++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { renderUserUsername } from "../../../utils";
+import z from "zod";
+import { renderUserUsername, zSnowflake } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
@@ -8,10 +8,13 @@ interface RoleAddedMatchResult {
   matchedRoleId: string;
 }
 
-export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
-  configType: t.union([t.string, t.array(t.string)]),
+const configSchema = z.union([
+  zSnowflake,
+  z.array(zSnowflake).max(255),
+]).default([]);
 
-  defaultConfig: "",
+export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
+  configSchema,
 
   async match({ triggerConfig, context, pluginData }) {
     if (!context.member || !context.rolesChanged || context.rolesChanged.removed!.length === 0) {
diff --git a/backend/src/plugins/Automod/triggers/threadArchive.ts b/backend/src/plugins/Automod/triggers/threadArchive.ts
index 0e65b10a..87cc1b2a 100644
--- a/backend/src/plugins/Automod/triggers/threadArchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadArchive.ts
@@ -1,6 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadArchiveResult {
@@ -11,12 +10,12 @@ interface ThreadArchiveResult {
   matchedThreadOwner: User | undefined;
 }
 
-export const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({
-  configType: t.type({
-    locked: tNullable(t.boolean),
-  }),
+const configSchema = z.strictObject({
+  locked: z.boolean().optional(),
+});
 
-  defaultConfig: {},
+export const ThreadArchiveTrigger = automodTrigger<ThreadArchiveResult>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (!context.threadChange?.archived) {
diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts
index 7b8aca71..ba5553de 100644
--- a/backend/src/plugins/Automod/triggers/threadCreate.ts
+++ b/backend/src/plugins/Automod/triggers/threadCreate.ts
@@ -1,5 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadCreateResult {
@@ -10,9 +10,10 @@ interface ThreadCreateResult {
   matchedThreadOwner: User | undefined;
 }
 
+const configSchema = z.strictObject({});
+
 export const ThreadCreateTrigger = automodTrigger<ThreadCreateResult>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (!context.threadChange?.created) {
diff --git a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts
index b1d02f47..a4352c11 100644
--- a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts
+++ b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts
@@ -1,18 +1,18 @@
-import * as t from "io-ts";
-import { convertDelayStringToMS, tDelayString } from "../../../utils";
+import z from "zod";
+import { convertDelayStringToMS, zDelayString } from "../../../utils";
 import { RecentActionType } from "../constants";
 import { findRecentSpam } from "../functions/findRecentSpam";
 import { getMatchingRecentActions } from "../functions/getMatchingRecentActions";
 import { sumRecentActionCounts } from "../functions/sumRecentActionCounts";
 import { automodTrigger } from "../helpers";
 
-export const ThreadCreateSpamTrigger = automodTrigger<unknown>()({
-  configType: t.type({
-    amount: t.number,
-    within: tDelayString,
-  }),
+const configSchema = z.strictObject({
+  amount: z.number().int(),
+  within: zDelayString,
+});
 
-  defaultConfig: {},
+export const ThreadCreateSpamTrigger = automodTrigger<unknown>()({
+  configSchema,
 
   async match({ pluginData, context, triggerConfig }) {
     if (!context.threadChange?.created) {
diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts
index 489b5b4c..fc538cf8 100644
--- a/backend/src/plugins/Automod/triggers/threadDelete.ts
+++ b/backend/src/plugins/Automod/triggers/threadDelete.ts
@@ -1,5 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadDeleteResult {
@@ -10,9 +10,10 @@ interface ThreadDeleteResult {
   matchedThreadOwner: User | undefined;
 }
 
+const configSchema = z.strictObject({});
+
 export const ThreadDeleteTrigger = automodTrigger<ThreadDeleteResult>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (!context.threadChange?.deleted) {
diff --git a/backend/src/plugins/Automod/triggers/threadUnarchive.ts b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
index f6047f48..3d4a3cdf 100644
--- a/backend/src/plugins/Automod/triggers/threadUnarchive.ts
+++ b/backend/src/plugins/Automod/triggers/threadUnarchive.ts
@@ -1,6 +1,5 @@
 import { User, escapeBold, type Snowflake } from "discord.js";
-import * as t from "io-ts";
-import { tNullable } from "../../../utils";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 interface ThreadUnarchiveResult {
@@ -11,12 +10,12 @@ interface ThreadUnarchiveResult {
   matchedThreadOwner: User | undefined;
 }
 
-export const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({
-  configType: t.type({
-    locked: tNullable(t.boolean),
-  }),
+const configSchema = z.strictObject({
+  locked: z.boolean().optional(),
+});
 
-  defaultConfig: {},
+export const ThreadUnarchiveTrigger = automodTrigger<ThreadUnarchiveResult>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (!context.threadChange?.unarchived) {
diff --git a/backend/src/plugins/Automod/triggers/unban.ts b/backend/src/plugins/Automod/triggers/unban.ts
index c653f615..25f24ec9 100644
--- a/backend/src/plugins/Automod/triggers/unban.ts
+++ b/backend/src/plugins/Automod/triggers/unban.ts
@@ -1,12 +1,13 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface UnbanTriggerResultType {}
 
+const configSchema = z.strictObject({});
+
 export const UnbanTrigger = automodTrigger<UnbanTriggerResultType>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (context.modAction?.type !== "unban") {
diff --git a/backend/src/plugins/Automod/triggers/unmute.ts b/backend/src/plugins/Automod/triggers/unmute.ts
index fbd946c6..f9695ef0 100644
--- a/backend/src/plugins/Automod/triggers/unmute.ts
+++ b/backend/src/plugins/Automod/triggers/unmute.ts
@@ -1,12 +1,13 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface UnmuteTriggerResultType {}
 
+const configSchema = z.strictObject({});
+
 export const UnmuteTrigger = automodTrigger<UnmuteTriggerResultType>()({
-  configType: t.type({}),
-  defaultConfig: {},
+  configSchema,
 
   async match({ context }) {
     if (context.modAction?.type !== "unmute") {
diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts
index 5c350dad..3586e82d 100644
--- a/backend/src/plugins/Automod/triggers/warn.ts
+++ b/backend/src/plugins/Automod/triggers/warn.ts
@@ -1,19 +1,16 @@
-import * as t from "io-ts";
+import z from "zod";
 import { automodTrigger } from "../helpers";
 
 // tslint:disable-next-line:no-empty-interface
 interface WarnTriggerResultType {}
 
-export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
-  configType: t.type({
-    manual: t.boolean,
-    automatic: t.boolean,
-  }),
+const configSchema = z.strictObject({
+  manual: z.boolean().default(true),
+  automatic: z.boolean().default(true),
+});
 
-  defaultConfig: {
-    manual: true,
-    automatic: true,
-  },
+export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
+  configSchema,
 
   async match({ context, triggerConfig }) {
     if (context.modAction?.type !== "warn") {
diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts
index bd3f05ee..59a09a33 100644
--- a/backend/src/plugins/Automod/types.ts
+++ b/backend/src/plugins/Automod/types.ts
@@ -1,6 +1,6 @@
 import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
@@ -8,39 +8,81 @@ import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { SavedMessage } from "../../data/entities/SavedMessage";
-import { tNullable } from "../../utils";
+import { entries, zBoundedRecord, zDelayString } from "../../utils";
 import { CounterEvents } from "../Counters/types";
 import { ModActionType, ModActionsEvents } from "../ModActions/types";
 import { MutesEvents } from "../Mutes/types";
-import { AvailableActions } from "./actions/availableActions";
+import { availableActions } from "./actions/availableActions";
 import { RecentActionType } from "./constants";
-import { AvailableTriggers } from "./triggers/availableTriggers";
+import { availableTriggers } from "./triggers/availableTriggers";
 
 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,
-  affects_self: t.boolean,
-  triggers: t.array(t.partial(AvailableTriggers.props)),
-  actions: t.partial(AvailableActions.props),
-  cooldown: tNullable(t.string),
-  allow_further_rules: t.boolean,
-});
-export type TRule = t.TypeOf<typeof Rule>;
+export type ZTriggersMapHelper = {
+  [TriggerName in keyof typeof availableTriggers]: typeof availableTriggers[TriggerName]["configSchema"];
+};
+const zTriggersMap = z.strictObject(entries(availableTriggers).reduce((map, [triggerName, trigger]) => {
+  map[triggerName] = trigger.configSchema;
+  return map;
+}, {} as ZTriggersMapHelper)).partial();
 
-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,
+type ZActionsMapHelper = {
+  [ActionName in keyof typeof availableActions]: typeof availableActions[ActionName]["configSchema"];
+};
+const zActionsMap = z.strictObject(entries(availableActions).reduce((map, [actionName, action]) => {
+  // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper
+  map[actionName] = action.configSchema;
+  return map;
+}, {} as ZActionsMapHelper)).partial();
+
+const zRule = z.strictObject({
+  enabled: z.boolean().default(true),
+  // Typed as "never" because you are not expected to supply this directly.
+  // The transform instead picks it up from the property key and the output type is a string.
+  name: z.never().optional().transform((_, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (! ruleName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Automod rules must have names",
+      });
+      return z.NEVER;
+    }
+    return ruleName;
+  }),
+  presets: z.array(z.string().max(100)).max(25).default([]),
+  affects_bots: z.boolean().default(false),
+  affects_self: z.boolean().default(false),
+  cooldown: zDelayString.nullable().default(null),
+  allow_further_rules: z.boolean().default(false),
+  triggers: z.array(zTriggersMap),
+  actions: zActionsMap.refine(
+    (v) => ! (v.clean && v.start_thread),
+    {
+      message: "Cannot have both clean and start_thread active at the same time",
+    }
+  ),
+});
+export type TRule = z.infer<typeof zRule>;
+
+export const zNotify = z.union([
+  z.literal("dm"),
+  z.literal("channel"),
+]);
+
+export const zAutomodConfig = z.strictObject({
+  rules: zBoundedRecord(
+    z.record(z.string().max(100), zRule),
+    0,
+    100,
+  ),
+  antiraid_levels: z.array(z.string().max(100)).max(10),
+  can_set_antiraid: z.boolean(),
+  can_view_antiraid: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface AutomodPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zAutomodConfig>;
 
   customOverrideCriteria: {
     antiraid_level?: string;
diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts
index 76478ca2..653e8def 100644
--- a/backend/src/plugins/BotControl/BotControlPlugin.ts
+++ b/backend/src/plugins/BotControl/BotControlPlugin.ts
@@ -3,7 +3,7 @@ import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
 import { Configs } from "../../data/Configs";
 import { GuildArchives } from "../../data/GuildArchives";
-import { makeIoTsConfigParser, sendSuccessMessage } from "../../pluginUtils";
+import { sendSuccessMessage } from "../../pluginUtils";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
 import { getActiveReload, resetActiveReload } from "./activeReload";
 import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd";
@@ -22,7 +22,7 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd";
 import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
 import { RestPerformanceCmd } from "./commands/RestPerformanceCmd";
 import { ServersCmd } from "./commands/ServersCmd";
-import { BotControlPluginType, ConfigSchema } from "./types";
+import { BotControlPluginType, zBotControlConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -37,7 +37,7 @@ const defaultOptions = {
 
 export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()({
   name: "bot_control",
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zBotControlConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/BotControl/types.ts b/backend/src/plugins/BotControl/types.ts
index 6f4139f8..1c1ccea6 100644
--- a/backend/src/plugins/BotControl/types.ts
+++ b/backend/src/plugins/BotControl/types.ts
@@ -1,23 +1,22 @@
-import * as t from "io-ts";
 import { BasePluginType, globalPluginEventListener, globalPluginMessageCommand } from "knub";
+import z from "zod";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
 import { Configs } from "../../data/Configs";
 import { GuildArchives } from "../../data/GuildArchives";
-import { tNullable } from "../../utils";
+import { zBoundedCharacters } from "../../utils";
 
-export const ConfigSchema = t.type({
-  can_use: t.boolean,
-  can_eligible: t.boolean,
-  can_performance: t.boolean,
-  can_add_server_from_invite: t.boolean,
-  can_list_dashboard_perms: t.boolean,
-  update_cmd: tNullable(t.string),
+export const zBotControlConfig = z.strictObject({
+  can_use: z.boolean(),
+  can_eligible: z.boolean(),
+  can_performance: z.boolean(),
+  can_add_server_from_invite: z.boolean(),
+  can_list_dashboard_perms: z.boolean(),
+  update_cmd: zBoundedCharacters(0, 2000).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface BotControlPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.output<typeof zBotControlConfig>;
   state: {
     archives: GuildArchives;
     allowedGuilds: AllowedGuilds;
diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts
index 45f63c79..9e6df53a 100644
--- a/backend/src/plugins/Cases/CasesPlugin.ts
+++ b/backend/src/plugins/Cases/CasesPlugin.ts
@@ -3,7 +3,7 @@ import { Case } from "../../data/entities/Case";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
@@ -16,7 +16,7 @@ import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUser
 import { getRecentCasesByMod } from "./functions/getRecentCasesByMod";
 import { getTotalCasesByMod } from "./functions/getTotalCasesByMod";
 import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel";
-import { CaseArgs, CaseNoteArgs, CasesPluginType, ConfigSchema } from "./types";
+import { CaseArgs, CaseNoteArgs, CasesPluginType, zCasesConfig } from "./types";
 
 // The `any` cast here is to prevent TypeScript from locking up from the circular dependency
 function getLogsPlugin(): Promise<any> {
@@ -42,11 +42,11 @@ export const CasesPlugin = zeppelinGuildPlugin<CasesPluginType>()({
     description: trimPluginDescription(`
       This plugin contains basic configuration for cases created by other plugins
     `),
-    configSchema: ConfigSchema,
+    configSchema: zCasesConfig,
   },
 
   dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getLogsPlugin()).LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCasesConfig.parse(input),
   defaultOptions,
 
   public: {
diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts
index 9ec1a371..e25b7ed0 100644
--- a/backend/src/plugins/Cases/types.ts
+++ b/backend/src/plugins/Cases/types.ts
@@ -1,24 +1,26 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import { U } from "ts-toolbelt";
+import z from "zod";
 import { CaseNameToType, CaseTypes } from "../../data/CaseTypes";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
-import { tDelayString, tNullable, tPartialDictionary } from "../../utils";
-import { tColor } from "../../utils/tColor";
+import { keys, zBoundedCharacters, zDelayString, zSnowflake } from "../../utils";
+import { zColor } from "../../utils/zColor";
 
-export const ConfigSchema = t.type({
-  log_automatic_actions: t.boolean,
-  case_log_channel: tNullable(t.string),
-  show_relative_times: t.boolean,
-  relative_time_cutoff: tDelayString,
-  case_colors: tNullable(tPartialDictionary(t.keyof(CaseNameToType), tColor)),
-  case_icons: tNullable(tPartialDictionary(t.keyof(CaseNameToType), t.string)),
+const caseKeys = keys(CaseNameToType) as U.ListOf<keyof typeof CaseNameToType>;
+
+export const zCasesConfig = z.strictObject({
+  log_automatic_actions: z.boolean(),
+  case_log_channel: zSnowflake.nullable(),
+  show_relative_times: z.boolean(),
+  relative_time_cutoff: zDelayString.default("1w"),
+  case_colors: z.record(z.enum(caseKeys), zColor).nullable(),
+  case_icons: z.record(z.enum(caseKeys), zBoundedCharacters(0, 32)).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CasesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCasesConfig>;
   state: {
     logs: GuildLogs;
     cases: GuildCases;
diff --git a/backend/src/plugins/Censor/CensorPlugin.ts b/backend/src/plugins/Censor/CensorPlugin.ts
index 61991e12..5764d396 100644
--- a/backend/src/plugins/Censor/CensorPlugin.ts
+++ b/backend/src/plugins/Censor/CensorPlugin.ts
@@ -1,12 +1,11 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
-import { CensorPluginType, ConfigSchema } from "./types";
+import { CensorPluginType, zCensorConfig } from "./types";
 import { onMessageCreate } from "./util/onMessageCreate";
 import { onMessageUpdate } from "./util/onMessageUpdate";
 
@@ -54,11 +53,11 @@ export const CensorPlugin = zeppelinGuildPlugin<CensorPluginType>()({
       For more advanced filtering, check out the Automod plugin!
     `),
     legacy: true,
-    configSchema: ConfigSchema,
+    configSchema: zCensorConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCensorConfig.parse(input),
   defaultOptions,
 
   beforeLoad(pluginData) {
diff --git a/backend/src/plugins/Censor/types.ts b/backend/src/plugins/Censor/types.ts
index 5a1984ce..c1fcd8df 100644
--- a/backend/src/plugins/Censor/types.ts
+++ b/backend/src/plugins/Censor/types.ts
@@ -1,30 +1,28 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { tNullable } from "../../utils";
-import { TRegex } from "../../validatorUtils";
+import { zBoundedCharacters, zRegex, zSnowflake } from "../../utils";
 
-export const ConfigSchema = t.type({
-  filter_zalgo: t.boolean,
-  filter_invites: t.boolean,
-  invite_guild_whitelist: tNullable(t.array(t.string)),
-  invite_guild_blacklist: tNullable(t.array(t.string)),
-  invite_code_whitelist: tNullable(t.array(t.string)),
-  invite_code_blacklist: tNullable(t.array(t.string)),
-  allow_group_dm_invites: t.boolean,
-  filter_domains: t.boolean,
-  domain_whitelist: tNullable(t.array(t.string)),
-  domain_blacklist: tNullable(t.array(t.string)),
-  blocked_tokens: tNullable(t.array(t.string)),
-  blocked_words: tNullable(t.array(t.string)),
-  blocked_regex: tNullable(t.array(TRegex)),
+export const zCensorConfig = z.strictObject({
+  filter_zalgo: z.boolean(),
+  filter_invites: z.boolean(),
+  invite_guild_whitelist: z.array(zSnowflake).nullable(),
+  invite_guild_blacklist: z.array(zSnowflake).nullable(),
+  invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable(),
+  invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable(),
+  allow_group_dm_invites: z.boolean(),
+  filter_domains: z.boolean(),
+  domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable(),
+  domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable(),
+  blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable(),
+  blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable(),
+  blocked_regex: z.array(zRegex(z.string().max(1000))).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CensorPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCensorConfig>;
   state: {
     serverLogs: GuildLogs;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
index b4467819..615d3467 100644
--- a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
+++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts
@@ -1,18 +1,15 @@
-import * as t from "io-ts";
-import { makeIoTsConfigParser } from "../../pluginUtils";
+import z from "zod";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd";
 import { ChannelArchiverPluginType } from "./types";
 
-const ConfigSchema = t.type({});
-
 export const ChannelArchiverPlugin = zeppelinGuildPlugin<ChannelArchiverPluginType>()({
   name: "channel_archiver",
   showInDocs: false,
 
   dependencies: () => [TimeAndDatePlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   // prettier-ignore
   messageCommands: [
diff --git a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts
index bd05d800..4e7987a7 100644
--- a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts
+++ b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts
@@ -1,11 +1,10 @@
 import { CooldownManager } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { VoiceStateUpdateEvt } from "./events/VoiceStateUpdateEvt";
-import { CompanionChannelsPluginType, ConfigSchema } from "./types";
+import { CompanionChannelsPluginType, zCompanionChannelsConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -23,11 +22,11 @@ export const CompanionChannelsPlugin = zeppelinGuildPlugin<CompanionChannelsPlug
       Once set up, any time a user joins one of the specified voice channels,
       they'll get channel permissions applied to them for the text channels.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zCompanionChannelsConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCompanionChannelsConfig.parse(input),
   defaultOptions,
 
   events: [VoiceStateUpdateEvt],
diff --git a/backend/src/plugins/CompanionChannels/types.ts b/backend/src/plugins/CompanionChannels/types.ts
index 46a8a45f..7cee4b3a 100644
--- a/backend/src/plugins/CompanionChannels/types.ts
+++ b/backend/src/plugins/CompanionChannels/types.ts
@@ -1,28 +1,23 @@
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
-import { tNullable } from "../../utils";
+import { zBoundedCharacters, zSnowflake } from "../../utils";
 
-// Permissions using these numbers: https://abal.moe/Eris/docs/reference (add all allowed/denied ones up)
-export const CompanionChannelOpts = t.type({
-  voice_channel_ids: t.array(t.string),
-  text_channel_ids: t.array(t.string),
-  permissions: t.number,
-  enabled: tNullable(t.boolean),
+export const zCompanionChannelOpts = z.strictObject({
+  voice_channel_ids: z.array(zSnowflake),
+  text_channel_ids: z.array(zSnowflake),
+  // See https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
+  permissions: z.number(),
+  enabled: z.boolean().nullable().default(true),
 });
-export type TCompanionChannelOpts = t.TypeOf<typeof CompanionChannelOpts>;
+export type TCompanionChannelOpts = z.infer<typeof zCompanionChannelOpts>;
 
-export const ConfigSchema = t.type({
-  entries: t.record(t.string, CompanionChannelOpts),
+export const zCompanionChannelsConfig = z.strictObject({
+  entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
-export interface ICompanionChannelMap {
-  [channelId: string]: TCompanionChannelOpts;
-}
 
 export interface CompanionChannelsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCompanionChannelsConfig>;
   state: {
     errorCooldownManager: CooldownManager;
     serverLogs: GuildLogs;
diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
index c41e8c09..57d1a763 100644
--- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
+++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts
@@ -1,12 +1,11 @@
 import { PluginOptions } from "knub";
 import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { MutesPlugin } from "../Mutes/MutesPlugin";
 import { UtilityPlugin } from "../Utility/UtilityPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ContextClickedEvt } from "./events/ContextClickedEvt";
-import { ConfigSchema, ContextMenuPluginType } from "./types";
+import { ContextMenuPluginType, zContextMenusConfig } from "./types";
 import { loadAllCommands } from "./utils/loadAllCommands";
 
 const defaultOptions: PluginOptions<ContextMenuPluginType> = {
@@ -37,7 +36,7 @@ export const ContextMenuPlugin = zeppelinGuildPlugin<ContextMenuPluginType>()({
   showInDocs: false,
 
   dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zContextMenusConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts
index 7fe2f5d5..556ee248 100644
--- a/backend/src/plugins/ContextMenus/actions/mute.ts
+++ b/backend/src/plugins/ContextMenus/actions/mute.ts
@@ -45,9 +45,9 @@ export async function muteAction(
   try {
     const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs });
 
-    const muteMessage = `Muted **${result.case.user_name}** ${
+    const muteMessage = `Muted **${result.case!.user_name}** ${
       durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely"
-    } (Case #${result.case.case_number}) (user notified via ${
+    } (Case #${result.case!.case_number}) (user notified via ${
       result.notifyResult.method ?? "dm"
     })\nPlease update the new case with the \`update\` command`;
 
diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts
index 02c4a29c..5dbcd9ce 100644
--- a/backend/src/plugins/ContextMenus/types.ts
+++ b/backend/src/plugins/ContextMenus/types.ts
@@ -1,22 +1,20 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks";
 
-export const ConfigSchema = t.type({
-  can_use: t.boolean,
-
-  user_muteindef: t.boolean,
-  user_mute1d: t.boolean,
-  user_mute1h: t.boolean,
-  user_info: t.boolean,
-  message_clean10: t.boolean,
-  message_clean25: t.boolean,
-  message_clean50: t.boolean,
+export const zContextMenusConfig = z.strictObject({
+  can_use: z.boolean(),
+  user_muteindef: z.boolean(),
+  user_mute1d: z.boolean(),
+  user_mute1h: z.boolean(),
+  user_info: z.boolean(),
+  message_clean10: z.boolean(),
+  message_clean25: z.boolean(),
+  message_clean50: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface ContextMenuPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zContextMenusConfig>;
   state: {
     contextMenuLinks: GuildContextMenuLinks;
   };
diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts
index 14e3432e..85be2cf5 100644
--- a/backend/src/plugins/Counters/CountersPlugin.ts
+++ b/backend/src/plugins/Counters/CountersPlugin.ts
@@ -1,15 +1,12 @@
 import { EventEmitter } from "events";
 import { PluginOptions } from "knub";
-import {
-  buildCounterConditionString,
-  CounterTrigger,
-  getReverseCounterComparisonOp,
-  parseCounterConditionString,
-} from "../../data/entities/CounterTrigger";
 import { GuildCounters } from "../../data/GuildCounters";
+import {
+  CounterTrigger,
+  parseCounterConditionString
+} from "../../data/entities/CounterTrigger";
 import { mapToPublicFn } from "../../pluginUtils";
-import { convertDelayStringToMS, MINUTES } from "../../utils";
-import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils";
+import { MINUTES, convertDelayStringToMS, values } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { AddCounterCmd } from "./commands/AddCounterCmd";
 import { CountersListCmd } from "./commands/CountersListCmd";
@@ -25,10 +22,8 @@ import { getPrettyNameForCounterTrigger } from "./functions/getPrettyNameForCoun
 import { offCounterEvent } from "./functions/offCounterEvent";
 import { onCounterEvent } from "./functions/onCounterEvent";
 import { setCounterValue } from "./functions/setCounterValue";
-import { ConfigSchema, CountersPluginType, TTrigger } from "./types";
+import { CountersPluginType, zCountersConfig } from "./types";
 
-const MAX_COUNTERS = 5;
-const MAX_TRIGGERS_PER_COUNTER = 5;
 const DECAY_APPLY_INTERVAL = 5 * MINUTES;
 
 const defaultOptions: PluginOptions<CountersPluginType> = {
@@ -72,50 +67,12 @@ export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()({
     description:
       "Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number",
     configurationGuide: "See <a href='/docs/setup-guides/counters'>Counters setup guide</a>",
-    configSchema: ConfigSchema,
+    configSchema: zCountersConfig,
   },
 
   defaultOptions,
   // TODO: Separate input and output types
-  configParser: (input) => {
-    for (const [counterName, counter] of Object.entries<any>((input as any).counters || {})) {
-      counter.name = counterName;
-      counter.per_user = counter.per_user ?? false;
-      counter.per_channel = counter.per_channel ?? false;
-      counter.initial_value = counter.initial_value ?? 0;
-      counter.triggers = counter.triggers || {};
-
-      if (Object.values(counter.triggers).length > MAX_TRIGGERS_PER_COUNTER) {
-        throw new StrictValidationError([`You can only have at most ${MAX_TRIGGERS_PER_COUNTER} triggers per counter`]);
-      }
-
-      // Normalize triggers
-      for (const [triggerName, trigger] of Object.entries(counter.triggers)) {
-        const triggerObj = (typeof trigger === "string" ? { condition: trigger } : trigger) as Partial<TTrigger>;
-
-        triggerObj.name = triggerName;
-        const parsedCondition = parseCounterConditionString(triggerObj.condition || "");
-        if (!parsedCondition) {
-          throw new StrictValidationError([
-            `Invalid comparison in counter trigger ${counterName}/${triggerName}: "${triggerObj.condition}"`,
-          ]);
-        }
-
-        triggerObj.condition = buildCounterConditionString(parsedCondition[0], parsedCondition[1]);
-        triggerObj.reverse_condition =
-          triggerObj.reverse_condition ||
-          buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]);
-
-        counter.triggers[triggerName] = triggerObj as TTrigger;
-      }
-    }
-
-    if (Object.values((input as any).counters || {}).length > MAX_COUNTERS) {
-      throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} counters`]);
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zCountersConfig.parse(input),
 
   public: {
     counterExists: mapToPublicFn(counterExists),
@@ -163,13 +120,12 @@ export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()({
       state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers);
 
       // Initialize triggers
-      for (const trigger of Object.values(counter.triggers)) {
-        const theTrigger = trigger as TTrigger;
-        const parsedCondition = parseCounterConditionString(theTrigger.condition)!;
-        const parsedReverseCondition = parseCounterConditionString(theTrigger.reverse_condition)!;
+      for (const trigger of values(counter.triggers)) {
+        const parsedCondition = parseCounterConditionString(trigger.condition)!;
+        const parsedReverseCondition = parseCounterConditionString(trigger.reverse_condition)!;
         const counterTrigger = await state.counters.initCounterTrigger(
           dbCounter.id,
-          theTrigger.name,
+          trigger.name,
           parsedCondition[0],
           parsedCondition[1],
           parsedReverseCondition[0],
diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts
index 1445bdd8..5e891c18 100644
--- a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts
+++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts
@@ -1,5 +1,5 @@
 import { GuildPluginData } from "knub";
-import { CountersPluginType, TTrigger } from "../types";
+import { CountersPluginType } from "../types";
 
 export function getPrettyNameForCounterTrigger(
   pluginData: GuildPluginData<CountersPluginType>,
@@ -12,6 +12,6 @@ export function getPrettyNameForCounterTrigger(
     return "Unknown Counter Trigger";
   }
 
-  const trigger = counter.triggers[triggerName] as TTrigger | undefined;
+  const trigger = counter.triggers[triggerName];
   return trigger ? trigger.pretty_name || trigger.name : "Unknown Counter Trigger";
 }
diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts
index 4187c9e4..2b8cce16 100644
--- a/backend/src/plugins/Counters/types.ts
+++ b/backend/src/plugins/Counters/types.ts
@@ -1,45 +1,98 @@
 import { EventEmitter } from "events";
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildCounters } from "../../data/GuildCounters";
-import { CounterTrigger } from "../../data/entities/CounterTrigger";
-import { tDelayString, tNullable } from "../../utils";
+import { CounterTrigger, buildCounterConditionString, getReverseCounterComparisonOp, parseCounterConditionString } from "../../data/entities/CounterTrigger";
+import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils";
 import Timeout = NodeJS.Timeout;
 
-export const Trigger = t.type({
-  name: t.string,
-  pretty_name: tNullable(t.string),
-  condition: t.string,
-  reverse_condition: t.string,
-});
-export type TTrigger = t.TypeOf<typeof Trigger>;
+const MAX_COUNTERS = 5;
+const MAX_TRIGGERS_PER_COUNTER = 5;
 
-export const Counter = t.type({
-  name: t.string,
-  pretty_name: tNullable(t.string),
-  per_channel: t.boolean,
-  per_user: t.boolean,
-  initial_value: t.number,
-  triggers: t.record(t.string, t.union([t.string, Trigger])),
-  decay: tNullable(
-    t.type({
-      amount: t.number,
-      every: tDelayString,
-    }),
+export const zTrigger = z.strictObject({
+  // Dummy type because name gets replaced by the property key in zTriggerInput
+  name: z.never().optional().transform(() => ""),
+  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
+  condition: zBoundedCharacters(1, 64).refine(
+    (str) => parseCounterConditionString(str) !== null,
+    { message: "Invalid counter trigger condition" },
+  ),
+  reverse_condition: zBoundedCharacters(1, 64).refine(
+    (str) => parseCounterConditionString(str) !== null,
+    { message: "Invalid counter trigger reverse condition" },
   ),
-  can_view: tNullable(t.boolean),
-  can_edit: tNullable(t.boolean),
-  can_reset_all: tNullable(t.boolean),
 });
-export type TCounter = t.TypeOf<typeof Counter>;
 
-export const ConfigSchema = t.type({
-  counters: t.record(t.string, Counter),
-  can_view: t.boolean,
-  can_edit: t.boolean,
-  can_reset_all: t.boolean,
+const zTriggerInput = z.union([zBoundedCharacters(0, 100), zTrigger])
+  .transform((val, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (typeof val === "string") {
+      const parsedCondition = parseCounterConditionString(val);
+      if (!parsedCondition) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Invalid counter trigger condition",
+        });
+        return z.NEVER;
+      }
+      return {
+        name: ruleName,
+        pretty_name: null,
+        condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),
+        reverse_condition: buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]),
+      };
+    }
+    return {
+      ...val,
+      name: ruleName,
+    };
+  });
+
+export const zCounter = z.strictObject({
+  // Typed as "never" because you are not expected to supply this directly.
+  // The transform instead picks it up from the property key and the output type is a string.
+  name: z.never().optional().transform((_, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (! ruleName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Counters must have names",
+      });
+      return z.NEVER;
+    }
+    return ruleName;
+  }),
+  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
+  per_channel: z.boolean().default(false),
+  per_user: z.boolean().default(false),
+  initial_value: z.number().default(0),
+  triggers: zBoundedRecord(
+    z.record(
+      zBoundedCharacters(0, 100),
+      zTriggerInput,
+    ),
+    1,
+    MAX_TRIGGERS_PER_COUNTER,
+  ),
+  decay: z.strictObject({
+    amount: z.number(),
+    every: zDelayString,
+  }).nullable().default(null),
+  can_view: z.boolean(),
+  can_edit: z.boolean(),
+  can_reset_all: z.boolean(),
+});
+
+export const zCountersConfig = z.strictObject({
+  counters: zBoundedRecord(
+    z.record(zBoundedCharacters(0, 100), zCounter),
+    0,
+    MAX_COUNTERS,
+  ),
+  can_view: z.boolean(),
+  can_edit: z.boolean(),
+  can_reset_all: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CounterEvents {
   trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void;
@@ -52,7 +105,7 @@ export interface CounterEventEmitter extends EventEmitter {
 }
 
 export interface CountersPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCountersConfig>;
   state: {
     counters: GuildCounters;
     counterIds: Record<string, number>;
diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
index 6d5ba2ba..e17cd96b 100644
--- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
+++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
@@ -2,7 +2,6 @@ import { GuildChannel, GuildMember, User } from "discord.js";
 import { guildPluginMessageCommand, parseSignature } from "knub";
 import { TSignature } from "knub-command-manager";
 import { commandTypes } from "../../commandTypes";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { TemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter";
 import { UnknownUser } from "../../utils";
 import { isScalar } from "../../utils/isScalar";
@@ -14,7 +13,7 @@ import {
 } from "../../utils/templateSafeObjects";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { runEvent } from "./functions/runEvent";
-import { ConfigSchema, CustomEventsPluginType } from "./types";
+import { CustomEventsPluginType, zCustomEventsConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -26,7 +25,7 @@ export const CustomEventsPlugin = zeppelinGuildPlugin<CustomEventsPluginType>()(
   name: "custom_events",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zCustomEventsConfig.parse(input),
   defaultOptions,
 
   afterLoad(pluginData) {
diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
index 29ded857..f0870958 100644
--- a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
@@ -1,17 +1,17 @@
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { canActOn } from "../../../pluginUtils";
 import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
-import { resolveMember } from "../../../utils";
+import { resolveMember, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const AddRoleAction = t.type({
-  type: t.literal("add_role"),
-  target: t.string,
-  role: t.union([t.string, t.array(t.string)]),
+export const zAddRoleAction = z.strictObject({
+  type: z.literal("add_role"),
+  target: zSnowflake,
+  role: z.union([zSnowflake, z.array(zSnowflake)]),
 });
-export type TAddRoleAction = t.TypeOf<typeof AddRoleAction>;
+export type TAddRoleAction = z.infer<typeof zAddRoleAction>;
 
 export async function addRoleAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
index d8d766b5..e894a446 100644
--- a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
@@ -1,19 +1,20 @@
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
+import { zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const CreateCaseAction = t.type({
-  type: t.literal("create_case"),
-  case_type: t.string,
-  mod: t.string,
-  target: t.string,
-  reason: t.string,
+export const zCreateCaseAction = z.strictObject({
+  type: z.literal("create_case"),
+  case_type: zBoundedCharacters(0, 32),
+  mod: zSnowflake,
+  target: zSnowflake,
+  reason: zBoundedCharacters(0, 4000),
 });
-export type TCreateCaseAction = t.TypeOf<typeof CreateCaseAction>;
+export type TCreateCaseAction = z.infer<typeof zCreateCaseAction>;
 
 export async function createCaseAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
@@ -32,7 +33,7 @@ export async function createCaseAction(
   }
 
   const casesPlugin = pluginData.getPlugin(CasesPlugin);
-  await casesPlugin.createCase({
+  await casesPlugin!.createCase({
     userId: targetId,
     modId,
     type: CaseTypes[action.case_type],
diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts
index bf314429..7727e339 100644
--- a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts
@@ -1,17 +1,17 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer } from "../../../templateFormatter";
-import { convertDelayStringToMS, noop, tDelayString } from "../../../utils";
+import { convertDelayStringToMS, noop, zDelayString, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const MakeRoleMentionableAction = t.type({
-  type: t.literal("make_role_mentionable"),
-  role: t.string,
-  timeout: tDelayString,
+export const zMakeRoleMentionableAction = z.strictObject({
+  type: z.literal("make_role_mentionable"),
+  role: zSnowflake,
+  timeout: zDelayString,
 });
-export type TMakeRoleMentionableAction = t.TypeOf<typeof MakeRoleMentionableAction>;
+export type TMakeRoleMentionableAction = z.infer<typeof zMakeRoleMentionableAction>;
 
 export async function makeRoleMentionableAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts
index e86d03b5..8dca7323 100644
--- a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts
@@ -1,15 +1,16 @@
 import { Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer } from "../../../templateFormatter";
+import { zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const MakeRoleUnmentionableAction = t.type({
-  type: t.literal("make_role_unmentionable"),
-  role: t.string,
+export const zMakeRoleUnmentionableAction = z.strictObject({
+  type: z.literal("make_role_unmentionable"),
+  role: zSnowflake,
 });
-export type TMakeRoleUnmentionableAction = t.TypeOf<typeof MakeRoleUnmentionableAction>;
+export type TMakeRoleUnmentionableAction = z.infer<typeof zMakeRoleUnmentionableAction>;
 
 export async function makeRoleUnmentionableAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts
index f06534ad..40eee4b8 100644
--- a/backend/src/plugins/CustomEvents/actions/messageAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts
@@ -1,16 +1,17 @@
 import { Snowflake, TextChannel } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { zBoundedCharacters, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType } from "../types";
 
-export const MessageAction = t.type({
-  type: t.literal("message"),
-  channel: t.string,
-  content: t.string,
+export const zMessageAction = z.strictObject({
+  type: z.literal("message"),
+  channel: zSnowflake,
+  content: zBoundedCharacters(0, 4000),
 });
-export type TMessageAction = t.TypeOf<typeof MessageAction>;
+export type TMessageAction = z.infer<typeof zMessageAction>;
 
 export async function messageAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
index 8ef746cd..d42059f5 100644
--- a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
@@ -1,18 +1,18 @@
 import { Snowflake, VoiceChannel } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { canActOn } from "../../../pluginUtils";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { resolveMember } from "../../../utils";
+import { resolveMember, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const MoveToVoiceChannelAction = t.type({
-  type: t.literal("move_to_vc"),
-  target: t.string,
-  channel: t.string,
+export const zMoveToVoiceChannelAction = z.strictObject({
+  type: z.literal("move_to_vc"),
+  target: zSnowflake,
+  channel: zSnowflake,
 });
-export type TMoveToVoiceChannelAction = t.TypeOf<typeof MoveToVoiceChannelAction>;
+export type TMoveToVoiceChannelAction = z.infer<typeof zMoveToVoiceChannelAction>;
 
 export async function moveToVoiceChannelAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
index 6bae4c35..dbd7b932 100644
--- a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
+++ b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
@@ -1,23 +1,24 @@
 import { PermissionsBitField, PermissionsString, Snowflake } from "discord.js";
-import * as t from "io-ts";
 import { GuildPluginData } from "knub";
+import z from "zod";
 import { TemplateSafeValueContainer } from "../../../templateFormatter";
+import { zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
-export const SetChannelPermissionOverridesAction = t.type({
-  type: t.literal("set_channel_permission_overrides"),
-  channel: t.string,
-  overrides: t.array(
-    t.type({
-      type: t.union([t.literal("member"), t.literal("role")]),
-      id: t.string,
-      allow: t.number,
-      deny: t.number,
+export const zSetChannelPermissionOverridesAction = z.strictObject({
+  type: z.literal("set_channel_permission_overrides"),
+  channel: zSnowflake,
+  overrides: z.array(
+    z.strictObject({
+      type: z.union([z.literal("member"), z.literal("role")]),
+      id: zSnowflake,
+      allow: z.number(),
+      deny: z.number(),
     }),
-  ),
+  ).max(15),
 });
-export type TSetChannelPermissionOverridesAction = t.TypeOf<typeof SetChannelPermissionOverridesAction>;
+export type TSetChannelPermissionOverridesAction = z.infer<typeof zSetChannelPermissionOverridesAction>;
 
 export async function setChannelPermissionOverridesAction(
   pluginData: GuildPluginData<CustomEventsPluginType>,
diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts
index e273e7b6..4572b1cf 100644
--- a/backend/src/plugins/CustomEvents/types.ts
+++ b/backend/src/plugins/CustomEvents/types.ts
@@ -1,47 +1,50 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
-import { AddRoleAction } from "./actions/addRoleAction";
-import { CreateCaseAction } from "./actions/createCaseAction";
-import { MakeRoleMentionableAction } from "./actions/makeRoleMentionableAction";
-import { MakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction";
-import { MessageAction } from "./actions/messageAction";
-import { MoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction";
-import { SetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides";
+import z from "zod";
+import { zBoundedCharacters, zBoundedRecord } from "../../utils";
+import { zAddRoleAction } from "./actions/addRoleAction";
+import { zCreateCaseAction } from "./actions/createCaseAction";
+import { zMakeRoleMentionableAction } from "./actions/makeRoleMentionableAction";
+import { zMakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction";
+import { zMessageAction } from "./actions/messageAction";
+import { zMoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction";
+import { zSetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides";
 
-// Triggers
-const CommandTrigger = t.type({
-  type: t.literal("command"),
-  name: t.string,
-  params: t.string,
-  can_use: t.boolean,
+const zCommandTrigger = z.strictObject({
+  type: z.literal("command"),
+  name: zBoundedCharacters(0, 100),
+  params: zBoundedCharacters(0, 255),
+  can_use: z.boolean(),
 });
 
-const AnyTrigger = CommandTrigger; // TODO: Make into a union once we have more triggers
+const zAnyTrigger = zCommandTrigger; // TODO: Make into a union once we have more triggers
 
-const AnyAction = t.union([
-  AddRoleAction,
-  CreateCaseAction,
-  MoveToVoiceChannelAction,
-  MessageAction,
-  MakeRoleMentionableAction,
-  MakeRoleUnmentionableAction,
-  SetChannelPermissionOverridesAction,
+const zAnyAction = z.union([
+  zAddRoleAction,
+  zCreateCaseAction,
+  zMoveToVoiceChannelAction,
+  zMessageAction,
+  zMakeRoleMentionableAction,
+  zMakeRoleUnmentionableAction,
+  zSetChannelPermissionOverridesAction,
 ]);
 
-export const CustomEvent = t.type({
-  name: t.string,
-  trigger: AnyTrigger,
-  actions: t.array(AnyAction),
+export const zCustomEvent = z.strictObject({
+  name: zBoundedCharacters(0, 100),
+  trigger: zAnyTrigger,
+  actions: z.array(zAnyAction).max(10),
 });
-export type TCustomEvent = t.TypeOf<typeof CustomEvent>;
+export type TCustomEvent = z.infer<typeof zCustomEvent>;
 
-export const ConfigSchema = t.type({
-  events: t.record(t.string, CustomEvent),
+export const zCustomEventsConfig = z.strictObject({
+  events: zBoundedRecord(
+    z.record(zBoundedCharacters(0, 100), zCustomEvent),
+    0,
+    100,
+  ),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface CustomEventsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zCustomEventsConfig>;
   state: {
     clearTriggers: () => void;
   };
diff --git a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
index c4f7eaaf..b8639134 100644
--- a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
+++ b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
@@ -1,11 +1,10 @@
 import { Guild } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType, GlobalPluginData, globalPluginEventListener } from "knub";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { Configs } from "../../data/Configs";
 import { env } from "../../env";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
+import z from "zod";
 
 interface GuildAccessMonitorPluginType extends BasePluginType {
   state: {
@@ -26,7 +25,7 @@ async function checkGuild(pluginData: GlobalPluginData<GuildAccessMonitorPluginT
  */
 export const GuildAccessMonitorPlugin = zeppelinGlobalPlugin<GuildAccessMonitorPluginType>()({
   name: "guild_access_monitor",
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   events: [
     globalPluginEventListener<GuildAccessMonitorPluginType>()({
diff --git a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts
index 5ae04fb5..6fdc467a 100644
--- a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts
+++ b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts
@@ -1,6 +1,5 @@
-import * as t from "io-ts";
+import z from "zod";
 import { Configs } from "../../data/Configs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
 import { reloadChangedGuilds } from "./functions/reloadChangedGuilds";
 import { GuildConfigReloaderPluginType } from "./types";
@@ -9,7 +8,7 @@ export const GuildConfigReloaderPlugin = zeppelinGlobalPlugin<GuildConfigReloade
   name: "guild_config_reloader",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   async beforeLoad(pluginData) {
     const { state } = pluginData;
diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
index 687175f1..a8d71e06 100644
--- a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
+++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
@@ -1,18 +1,17 @@
 import { Guild } from "discord.js";
-import * as t from "io-ts";
 import { guildPluginEventListener } from "knub";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { MINUTES } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { GuildInfoSaverPluginType } from "./types";
+import z from "zod";
 
 export const GuildInfoSaverPlugin = zeppelinGuildPlugin<GuildInfoSaverPluginType>()({
   name: "guild_info_saver",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   events: [
     guildPluginEventListener({
diff --git a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts
index d29b342d..293d582e 100644
--- a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts
+++ b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts
@@ -1,6 +1,6 @@
-import * as t from "io-ts";
+import z from "zod";
 import { GuildMemberCache } from "../../data/GuildMemberCache";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { SECONDS } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { cancelDeletionOnMemberJoin } from "./events/cancelDeletionOnMemberJoin";
@@ -18,7 +18,7 @@ export const GuildMemberCachePlugin = zeppelinGuildPlugin<GuildMemberCachePlugin
   name: "guild_member_cache",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => z.strictObject({}).parse(input),
 
   events: [
     updateMemberCacheOnMemberUpdate,
diff --git a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts
index 3fbcbbf8..9365109e 100644
--- a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts
+++ b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts
@@ -1,11 +1,12 @@
 import { PluginOptions } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { Webhooks } from "../../data/Webhooks";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { editMessage } from "./functions/editMessage";
 import { sendMessage } from "./functions/sendMessage";
-import { ConfigSchema, InternalPosterPluginType } from "./types";
+import { InternalPosterPluginType } from "./types";
 
 const defaultOptions: PluginOptions<InternalPosterPluginType> = {
   config: {},
@@ -16,7 +17,7 @@ export const InternalPosterPlugin = zeppelinGuildPlugin<InternalPosterPluginType
   name: "internal_poster",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => z.strictObject({}).parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/InternalPoster/types.ts b/backend/src/plugins/InternalPoster/types.ts
index e5f1a1a3..78dabd42 100644
--- a/backend/src/plugins/InternalPoster/types.ts
+++ b/backend/src/plugins/InternalPoster/types.ts
@@ -1,15 +1,9 @@
 import { WebhookClient } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
 import { Queue } from "../../Queue";
 import { Webhooks } from "../../data/Webhooks";
 
-export const ConfigSchema = t.type({});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
 export interface InternalPosterPluginType extends BasePluginType {
-  config: TConfigSchema;
-
   state: {
     queue: Queue;
     webhooks: Webhooks;
diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts
index 51d47945..a99dc84c 100644
--- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts
+++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts
@@ -1,7 +1,6 @@
 import { PluginOptions } from "knub";
 import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildVCAlerts } from "../../data/GuildVCAlerts";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { FollowCmd } from "./commands/FollowCmd";
@@ -9,7 +8,7 @@ import { DeleteFollowCmd, ListFollowCmd } from "./commands/ListFollowCmd";
 import { WhereCmd } from "./commands/WhereCmd";
 import { GuildBanRemoveAlertsEvt } from "./events/BanRemoveAlertsEvt";
 import { VoiceStateUpdateAlertEvt } from "./events/SendAlertsEvts";
-import { ConfigSchema, LocateUserPluginType } from "./types";
+import { LocateUserPluginType, zLocateUserConfig } from "./types";
 import { clearExpiredAlert } from "./utils/clearExpiredAlert";
 import { fillActiveAlertsList } from "./utils/fillAlertsList";
 
@@ -39,10 +38,10 @@ export const LocateUserPlugin = zeppelinGuildPlugin<LocateUserPluginType>()({
       * Instantly receive an invite to the voice channel of a user
       * Be notified as soon as a user switches or joins a voice channel
     `),
-    configSchema: ConfigSchema,
+    configSchema: zLocateUserConfig,
   },
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zLocateUserConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts
index 1bfb063e..4139f465 100644
--- a/backend/src/plugins/LocateUser/types.ts
+++ b/backend/src/plugins/LocateUser/types.ts
@@ -1,15 +1,14 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildVCAlerts } from "../../data/GuildVCAlerts";
 
-export const ConfigSchema = t.type({
-  can_where: t.boolean,
-  can_alert: t.boolean,
+export const zLocateUserConfig = z.strictObject({
+  can_where: z.boolean(),
+  can_alert: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface LocateUserPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zLocateUserConfig>;
   state: {
     alerts: GuildVCAlerts;
     usersWithAlerts: string[];
diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts
index e56ba756..923ac623 100644
--- a/backend/src/plugins/Logs/LogsPlugin.ts
+++ b/backend/src/plugins/Logs/LogsPlugin.ts
@@ -6,7 +6,7 @@ import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { LogType } from "../../data/LogType";
 import { logger } from "../../logger";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { TypedTemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
@@ -31,7 +31,7 @@ import {
 import { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from "./events/LogsThreadModifyEvts";
 import { LogsGuildMemberUpdateEvt } from "./events/LogsUserUpdateEvts";
 import { LogsVoiceStateUpdateEvt } from "./events/LogsVoiceChannelEvts";
-import { ConfigSchema, FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel } from "./types";
+import { FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel, zLogsConfig } from "./types";
 import { getLogMessage } from "./util/getLogMessage";
 import { log } from "./util/log";
 import { onMessageDelete } from "./util/onMessageDelete";
@@ -110,7 +110,6 @@ import { logVoiceChannelForceMove } from "./logFunctions/logVoiceChannelForceMov
 import { logVoiceChannelJoin } from "./logFunctions/logVoiceChannelJoin";
 import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave";
 import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove";
-import { asBoundedString } from "../../utils/iotsUtils";
 
 // The `any` cast here is to prevent TypeScript from locking up from the circular dependency
 function getCasesPlugin(): Promise<any> {
@@ -121,12 +120,12 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
   config: {
     channels: {},
     format: {
-      timestamp: asBoundedString(FORMAT_NO_TIMESTAMP), // Legacy/deprecated, use timestamp_format below instead
+      timestamp: FORMAT_NO_TIMESTAMP,
       ...DefaultLogMessages,
     },
-    ping_user: true, // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
+    ping_user: true, 
     allow_user_mentions: false,
-    timestamp_format: asBoundedString("[<t:]X[>]"),
+    timestamp_format: "[<t:]X[>]",
     include_embed_timestamp: true,
   },
 
@@ -134,7 +133,8 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
     {
       level: ">=50",
       config: {
-        ping_user: false, // Legacy/deprecated, read comment on global ping_user option
+        // Legacy/deprecated, read comment on global ping_user option
+        ping_user: false,
       },
     },
   ],
@@ -145,11 +145,11 @@ export const LogsPlugin = zeppelinGuildPlugin<LogsPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Logs",
-    configSchema: ConfigSchema,
+    configSchema: zLogsConfig,
   },
 
   dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zLogsConfig.parse(input),
   defaultOptions,
 
   events: [
diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
index 0596869c..25c8c340 100644
--- a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
+++ b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
@@ -32,7 +32,7 @@ export function logMessageDelete(pluginData: GuildPluginData<LogsPluginType>, da
   // See comment on FORMAT_NO_TIMESTAMP in types.ts
   const config = pluginData.config.get();
   const timestampFormat =
-    (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format;
+    (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format ?? undefined;
 
   return log(
     pluginData,
diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts
index 759f680d..51f67b87 100644
--- a/backend/src/plugins/Logs/types.ts
+++ b/backend/src/plugins/Logs/types.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub";
 import { z } from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
@@ -7,7 +6,7 @@ import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { LogType } from "../../data/LogType";
-import { tMessageContent, tNullable } from "../../utils";
+import { zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils";
 import { MessageBuffer } from "../../utils/MessageBuffer";
 import {
   TemplateSafeCase,
@@ -22,55 +21,56 @@ import {
   TemplateSafeUnknownUser,
   TemplateSafeUser,
 } from "../../utils/templateSafeObjects";
-import { TRegex } from "../../validatorUtils";
-import { tBoundedString } from "../../utils/iotsUtils";
 
-export const tLogFormats = t.record(t.string, t.union([t.string, tMessageContent]));
-export type TLogFormats = t.TypeOf<typeof tLogFormats>;
+const DEFAULT_BATCH_TIME = 1000;
+const MIN_BATCH_TIME = 250;
+const MAX_BATCH_TIME = 5000;
 
-const LogChannel = t.partial({
-  include: t.array(t.string),
-  exclude: t.array(t.string),
-  batched: t.boolean,
-  batch_time: t.number,
-  excluded_users: t.array(t.string),
-  excluded_message_regexes: t.array(TRegex),
-  excluded_channels: t.array(t.string),
-  excluded_categories: t.array(t.string),
-  excluded_threads: t.array(t.string),
-  exclude_bots: t.boolean,
-  excluded_roles: t.array(t.string),
-  format: tNullable(tLogFormats),
-  timestamp_format: t.string,
-  include_embed_timestamp: t.boolean,
+export const zLogFormats = z.record(
+  zBoundedCharacters(1, 255),
+  zMessageContent,
+);
+export type TLogFormats = z.infer<typeof zLogFormats>;
+
+const zLogChannel = z.strictObject({
+  include: z.array(zBoundedCharacters(1, 255)).default([]),
+  exclude: z.array(zBoundedCharacters(1, 255)).default([]),
+  batched: z.boolean().default(true),
+  batch_time: z.number().min(MIN_BATCH_TIME).max(MAX_BATCH_TIME).default(DEFAULT_BATCH_TIME),
+  excluded_users: z.array(zSnowflake).nullable().default(null),
+  excluded_message_regexes: z.array(zRegex(z.string())).nullable().default(null),
+  excluded_channels: z.array(zSnowflake).nullable().default(null),
+  excluded_categories: z.array(zSnowflake).nullable().default(null),
+  excluded_threads: z.array(zSnowflake).nullable().default(null),
+  exclude_bots: z.boolean().default(false),
+  excluded_roles: z.array(zSnowflake).nullable().default(null),
+  format: zLogFormats.default({}),
+  timestamp_format: z.string().nullable().default(null),
+  include_embed_timestamp: z.boolean().nullable().default(null),
 });
-export type TLogChannel = t.TypeOf<typeof LogChannel>;
+export type TLogChannel = z.infer<typeof zLogChannel>;
 
-const LogChannelMap = t.record(t.string, LogChannel);
-export type TLogChannelMap = t.TypeOf<typeof LogChannelMap>;
+const zLogChannelMap = z.record(zSnowflake, zLogChannel);
+export type TLogChannelMap = z.infer<typeof zLogChannelMap>;
 
-export const ConfigSchema = t.type({
-  channels: LogChannelMap,
-  format: t.intersection([
-    tLogFormats,
-    t.type({
-      timestamp: tBoundedString(0, 64), // Legacy/deprecated
-    }),
-  ]),
-  ping_user: t.boolean, // Legacy/deprecated, if below is false mentions wont actually ping
-  allow_user_mentions: t.boolean,
-  timestamp_format: tBoundedString(0, 64),
-  include_embed_timestamp: t.boolean,
+export const zLogsConfig = z.strictObject({
+  channels: zLogChannelMap,
+  format: z.intersection(zLogFormats, z.strictObject({
+    // Legacy/deprecated, use timestamp_format below instead
+    timestamp: zBoundedCharacters(0, 64).nullable(),
+  })),
+  // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
+  ping_user: z.boolean(),
+  allow_user_mentions: z.boolean(),
+  timestamp_format: z.string().nullable(),
+  include_embed_timestamp: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
-// Hacky way of allowing a """null""" default value for config.format.timestamp
-// The type cannot be made nullable properly because io-ts's intersection type still considers
-// that it has to match the record type of tLogFormats, which includes string.
+// Hacky way of allowing a """null""" default value for config.format.timestamp due to legacy io-ts reasons
 export const FORMAT_NO_TIMESTAMP = "__NO_TIMESTAMP__";
 
 export interface LogsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zLogsConfig>;
   state: {
     guildLogs: GuildLogs;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts
index 9e775251..0b140cd7 100644
--- a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts
+++ b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts
@@ -1,11 +1,10 @@
 import { PluginOptions } from "knub";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB";
 import { SavePinsToDBCmd } from "./commands/SavePinsToDB";
 import { MessageCreateEvt, MessageDeleteBulkEvt, MessageDeleteEvt, MessageUpdateEvt } from "./events/SaveMessagesEvts";
-import { ConfigSchema, MessageSaverPluginType } from "./types";
+import { MessageSaverPluginType, zMessageSaverConfig } from "./types";
 
 const defaultOptions: PluginOptions<MessageSaverPluginType> = {
   config: {
@@ -25,7 +24,7 @@ export const MessageSaverPlugin = zeppelinGuildPlugin<MessageSaverPluginType>()(
   name: "message_saver",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zMessageSaverConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/MessageSaver/types.ts b/backend/src/plugins/MessageSaver/types.ts
index 2fdef665..f42fa2c3 100644
--- a/backend/src/plugins/MessageSaver/types.ts
+++ b/backend/src/plugins/MessageSaver/types.ts
@@ -1,14 +1,13 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 
-export const ConfigSchema = t.type({
-  can_manage: t.boolean,
+export const zMessageSaverConfig = z.strictObject({
+  can_manage: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface MessageSaverPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zMessageSaverConfig>;
   state: {
     savedMessages: GuildSavedMessages;
   };
diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts
index bf83b13e..4c9c8958 100644
--- a/backend/src/plugins/ModActions/ModActionsPlugin.ts
+++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts
@@ -6,7 +6,7 @@ import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildTempbans } from "../../data/GuildTempbans";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { MINUTES, trimPluginDescription } from "../../utils";
 import { CasesPlugin } from "../Cases/CasesPlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
@@ -47,7 +47,7 @@ import { offModActionsEvent } from "./functions/offModActionsEvent";
 import { onModActionsEvent } from "./functions/onModActionsEvent";
 import { updateCase } from "./functions/updateCase";
 import { warnMember } from "./functions/warnMember";
-import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types";
+import { BanOptions, KickOptions, ModActionsPluginType, WarnOptions, zModActionsConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -121,11 +121,11 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
     description: trimPluginDescription(`
       This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zModActionsConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zModActionsConfig.parse(input),
   defaultOptions,
 
   events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents],
diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts
index 447b9638..fbe7890a 100644
--- a/backend/src/plugins/ModActions/types.ts
+++ b/backend/src/plugins/ModActions/types.ts
@@ -1,51 +1,50 @@
 import { GuildTextBasedChannel } from "discord.js";
 import { EventEmitter } from "events";
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildTempbans } from "../../data/GuildTempbans";
 import { Case } from "../../data/entities/Case";
-import { UserNotificationMethod, UserNotificationResult, tNullable } from "../../utils";
+import { UserNotificationMethod, UserNotificationResult } from "../../utils";
 import { CaseArgs } from "../Cases/types";
 
-export const ConfigSchema = t.type({
-  dm_on_warn: t.boolean,
-  dm_on_kick: t.boolean,
-  dm_on_ban: t.boolean,
-  message_on_warn: t.boolean,
-  message_on_kick: t.boolean,
-  message_on_ban: t.boolean,
-  message_channel: tNullable(t.string),
-  warn_message: tNullable(t.string),
-  kick_message: tNullable(t.string),
-  ban_message: tNullable(t.string),
-  tempban_message: tNullable(t.string),
-  alert_on_rejoin: t.boolean,
-  alert_channel: tNullable(t.string),
-  warn_notify_enabled: t.boolean,
-  warn_notify_threshold: t.number,
-  warn_notify_message: t.string,
-  ban_delete_message_days: t.number,
-  can_note: t.boolean,
-  can_warn: t.boolean,
-  can_mute: t.boolean,
-  can_kick: t.boolean,
-  can_ban: t.boolean,
-  can_unban: t.boolean,
-  can_view: t.boolean,
-  can_addcase: t.boolean,
-  can_massunban: t.boolean,
-  can_massban: t.boolean,
-  can_massmute: t.boolean,
-  can_hidecase: t.boolean,
-  can_deletecase: t.boolean,
-  can_act_as_other: t.boolean,
-  create_cases_for_manual_actions: t.boolean,
+export const zModActionsConfig = z.strictObject({
+  dm_on_warn: z.boolean(),
+  dm_on_kick: z.boolean(),
+  dm_on_ban: z.boolean(),
+  message_on_warn: z.boolean(),
+  message_on_kick: z.boolean(),
+  message_on_ban: z.boolean(),
+  message_channel: z.nullable(z.string()),
+  warn_message: z.nullable(z.string()),
+  kick_message: z.nullable(z.string()),
+  ban_message: z.nullable(z.string()),
+  tempban_message: z.nullable(z.string()),
+  alert_on_rejoin: z.boolean(),
+  alert_channel: z.nullable(z.string()),
+  warn_notify_enabled: z.boolean(),
+  warn_notify_threshold: z.number(),
+  warn_notify_message: z.string(),
+  ban_delete_message_days: z.number(),
+  can_note: z.boolean(),
+  can_warn: z.boolean(),
+  can_mute: z.boolean(),
+  can_kick: z.boolean(),
+  can_ban: z.boolean(),
+  can_unban: z.boolean(),
+  can_view: z.boolean(),
+  can_addcase: z.boolean(),
+  can_massunban: z.boolean(),
+  can_massban: z.boolean(),
+  can_massmute: z.boolean(),
+  can_hidecase: z.boolean(),
+  can_deletecase: z.boolean(),
+  can_act_as_other: z.boolean(),
+  create_cases_for_manual_actions: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface ModActionsEvents {
   note: (userId: string, reason?: string) => void;
@@ -62,7 +61,7 @@ export interface ModActionsEventEmitter extends EventEmitter {
 }
 
 export interface ModActionsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zModActionsConfig>;
   state: {
     mutes: GuildMutes;
     cases: GuildCases;
diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts
index 1a205bbe..7c9eada8 100644
--- a/backend/src/plugins/Mutes/MutesPlugin.ts
+++ b/backend/src/plugins/Mutes/MutesPlugin.ts
@@ -5,7 +5,7 @@ import { GuildCases } from "../../data/GuildCases";
 import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { CasesPlugin } from "../Cases/CasesPlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
@@ -22,7 +22,7 @@ import { offMutesEvent } from "./functions/offMutesEvent";
 import { onMutesEvent } from "./functions/onMutesEvent";
 import { renewTimeoutMute } from "./functions/renewTimeoutMute";
 import { unmuteUser } from "./functions/unmuteUser";
-import { ConfigSchema, MutesPluginType } from "./types";
+import { MutesPluginType, zMutesConfig } from "./types";
 
 const defaultOptions = {
   config: {
@@ -65,11 +65,11 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Mutes",
-    configSchema: ConfigSchema,
+    configSchema: zMutesConfig,
   },
 
   dependencies: () => [CasesPlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zMutesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts
index 0c69c657..e1f266a9 100644
--- a/backend/src/plugins/Mutes/types.ts
+++ b/backend/src/plugins/Mutes/types.ts
@@ -1,36 +1,35 @@
 import { GuildMember } from "discord.js";
 import { EventEmitter } from "events";
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { Case } from "../../data/entities/Case";
 import { Mute } from "../../data/entities/Mute";
-import { UserNotificationMethod, UserNotificationResult, tNullable } from "../../utils";
+import { UserNotificationMethod, UserNotificationResult, zSnowflake } from "../../utils";
 import { CaseArgs } from "../Cases/types";
 
-export const ConfigSchema = t.type({
-  mute_role: tNullable(t.string),
-  move_to_voice_channel: tNullable(t.string),
-  kick_from_voice_channel: t.boolean,
+export const zMutesConfig = z.strictObject({
+  mute_role: zSnowflake.nullable(),
+  move_to_voice_channel: zSnowflake.nullable(),
+  kick_from_voice_channel: z.boolean(),
 
-  dm_on_mute: t.boolean,
-  dm_on_update: t.boolean,
-  message_on_mute: t.boolean,
-  message_on_update: t.boolean,
-  message_channel: tNullable(t.string),
-  mute_message: tNullable(t.string),
-  timed_mute_message: tNullable(t.string),
-  update_mute_message: tNullable(t.string),
-  remove_roles_on_mute: t.union([t.boolean, t.array(t.string)]),
-  restore_roles_on_mute: t.union([t.boolean, t.array(t.string)]),
+  dm_on_mute: z.boolean(),
+  dm_on_update: z.boolean(),
+  message_on_mute: z.boolean(),
+  message_on_update: z.boolean(),
+  message_channel: z.string().nullable(),
+  mute_message: z.string().nullable(),
+  timed_mute_message: z.string().nullable(),
+  update_mute_message: z.string().nullable(),
+  remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),
+  restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),
 
-  can_view_list: t.boolean,
-  can_cleanup: t.boolean,
+  can_view_list: z.boolean(),
+  can_cleanup: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface MutesEvents {
   mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void;
@@ -43,7 +42,7 @@ export interface MutesEventEmitter extends EventEmitter {
 }
 
 export interface MutesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zMutesConfig>;
   state: {
     mutes: GuildMutes;
     cases: GuildCases;
diff --git a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts
index 7b9273e3..915ffa3c 100644
--- a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts
+++ b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts
@@ -2,10 +2,9 @@ import { PluginOptions } from "knub";
 import { Queue } from "../../Queue";
 import { GuildNicknameHistory } from "../../data/GuildNicknameHistory";
 import { UsernameHistory } from "../../data/UsernameHistory";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { NamesCmd } from "./commands/NamesCmd";
-import { ConfigSchema, NameHistoryPluginType } from "./types";
+import { NameHistoryPluginType, zNameHistoryConfig } from "./types";
 
 const defaultOptions: PluginOptions<NameHistoryPluginType> = {
   config: {
@@ -25,7 +24,7 @@ export const NameHistoryPlugin = zeppelinGuildPlugin<NameHistoryPluginType>()({
   name: "name_history",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zNameHistoryConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/NameHistory/types.ts b/backend/src/plugins/NameHistory/types.ts
index e85f2f40..70101b53 100644
--- a/backend/src/plugins/NameHistory/types.ts
+++ b/backend/src/plugins/NameHistory/types.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { GuildNicknameHistory } from "../../data/GuildNicknameHistory";
 import { UsernameHistory } from "../../data/UsernameHistory";
 
-export const ConfigSchema = t.type({
-  can_view: t.boolean,
+export const zNameHistoryConfig = z.strictObject({
+  can_view: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface NameHistoryPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zNameHistoryConfig>;
   state: {
     nicknameHistory: GuildNicknameHistory;
     usernameHistory: UsernameHistory;
diff --git a/backend/src/plugins/Persist/PersistPlugin.ts b/backend/src/plugins/Persist/PersistPlugin.ts
index ecd50067..dc489a25 100644
--- a/backend/src/plugins/Persist/PersistPlugin.ts
+++ b/backend/src/plugins/Persist/PersistPlugin.ts
@@ -1,7 +1,6 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildPersistedData } from "../../data/GuildPersistedData";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { GuildMemberCachePlugin } from "../GuildMemberCache/GuildMemberCachePlugin";
 import { LogsPlugin } from "../Logs/LogsPlugin";
@@ -9,7 +8,7 @@ import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { LoadDataEvt } from "./events/LoadDataEvt";
 import { StoreDataEvt } from "./events/StoreDataEvt";
-import { ConfigSchema, PersistPluginType } from "./types";
+import { PersistPluginType, zPersistConfig } from "./types";
 
 const defaultOptions: PluginOptions<PersistPluginType> = {
   config: {
@@ -28,11 +27,11 @@ export const PersistPlugin = zeppelinGuildPlugin<PersistPluginType>()({
       Re-apply roles or nicknames for users when they rejoin the server.
       Mute roles are re-applied automatically, this plugin is not required for that.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zPersistConfig,
   },
 
   dependencies: () => [LogsPlugin, RoleManagerPlugin, GuildMemberCachePlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPersistConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Persist/types.ts b/backend/src/plugins/Persist/types.ts
index 8ab258b7..4f42faa9 100644
--- a/backend/src/plugins/Persist/types.ts
+++ b/backend/src/plugins/Persist/types.ts
@@ -1,17 +1,17 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildPersistedData } from "../../data/GuildPersistedData";
+import { zSnowflake } from "../../utils";
 
-export const ConfigSchema = t.type({
-  persisted_roles: t.array(t.string),
-  persist_nicknames: t.boolean,
-  persist_voice_mutes: t.boolean, // Deprecated, here to not break old configs
+export const zPersistConfig = z.strictObject({
+  persisted_roles: z.array(zSnowflake),
+  persist_nicknames: z.boolean(),
+  persist_voice_mutes: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PersistPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPersistConfig>;
 
   state: {
     persistedData: GuildPersistedData;
diff --git a/backend/src/plugins/Phisherman/PhishermanPlugin.ts b/backend/src/plugins/Phisherman/PhishermanPlugin.ts
index e8f74ce3..8e895d84 100644
--- a/backend/src/plugins/Phisherman/PhishermanPlugin.ts
+++ b/backend/src/plugins/Phisherman/PhishermanPlugin.ts
@@ -1,10 +1,10 @@
 import { PluginOptions } from "knub";
 import { hasPhishermanMasterAPIKey, phishermanApiKeyIsValid } from "../../data/Phisherman";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { getDomainInfo } from "./functions/getDomainInfo";
 import { pluginInfo } from "./info";
-import { ConfigSchema, PhishermanPluginType } from "./types";
+import { PhishermanPluginType, zPhishermanConfig } from "./types";
 
 const defaultOptions: PluginOptions<PhishermanPluginType> = {
   config: {
@@ -18,7 +18,7 @@ export const PhishermanPlugin = zeppelinGuildPlugin<PhishermanPluginType>()({
   showInDocs: true,
   info: pluginInfo,
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPhishermanConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Phisherman/info.ts b/backend/src/plugins/Phisherman/info.ts
index 826da43d..24530dce 100644
--- a/backend/src/plugins/Phisherman/info.ts
+++ b/backend/src/plugins/Phisherman/info.ts
@@ -1,6 +1,6 @@
 import { trimPluginDescription } from "../../utils";
 import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
-import { ConfigSchema } from "./types";
+import { zPhishermanConfig } from "./types";
 
 export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
   prettyName: "Phisherman",
@@ -39,5 +39,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
               clean: true
     ~~~
   `),
-  configSchema: ConfigSchema,
+  configSchema: zPhishermanConfig,
 };
diff --git a/backend/src/plugins/Phisherman/types.ts b/backend/src/plugins/Phisherman/types.ts
index 56ed7aca..d21eb38e 100644
--- a/backend/src/plugins/Phisherman/types.ts
+++ b/backend/src/plugins/Phisherman/types.ts
@@ -1,14 +1,12 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
-import { tNullable } from "../../utils";
+import z from "zod";
 
-export const ConfigSchema = t.type({
-  api_key: tNullable(t.string),
+export const zPhishermanConfig = z.strictObject({
+  api_key: z.string().max(255).nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PhishermanPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPhishermanConfig>;
 
   state: {
     validApiKey: string | null;
diff --git a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts
index 331410f9..a33103bf 100644
--- a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts
+++ b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts
@@ -1,10 +1,9 @@
 import { PluginOptions } from "knub";
 import { GuildPingableRoles } from "../../data/GuildPingableRoles";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { PingableRoleDisableCmd } from "./commands/PingableRoleDisableCmd";
 import { PingableRoleEnableCmd } from "./commands/PingableRoleEnableCmd";
-import { ConfigSchema, PingableRolesPluginType } from "./types";
+import { PingableRolesPluginType, zPingableRolesConfig } from "./types";
 
 const defaultOptions: PluginOptions<PingableRolesPluginType> = {
   config: {
@@ -25,10 +24,10 @@ export const PingableRolesPlugin = zeppelinGuildPlugin<PingableRolesPluginType>(
   showInDocs: true,
   info: {
     prettyName: "Pingable roles",
-    configSchema: ConfigSchema,
+    configSchema: zPingableRolesConfig,
   },
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPingableRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/PingableRoles/types.ts b/backend/src/plugins/PingableRoles/types.ts
index 272f7594..3bd6faa8 100644
--- a/backend/src/plugins/PingableRoles/types.ts
+++ b/backend/src/plugins/PingableRoles/types.ts
@@ -1,15 +1,14 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildPingableRoles } from "../../data/GuildPingableRoles";
 import { PingableRole } from "../../data/entities/PingableRole";
 
-export const ConfigSchema = t.type({
-  can_manage: t.boolean,
+export const zPingableRolesConfig = z.strictObject({
+  can_manage: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PingableRolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPingableRolesConfig>;
 
   state: {
     pingableRoles: GuildPingableRoles;
diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts
index 09d069a2..304d6b65 100644
--- a/backend/src/plugins/Post/PostPlugin.ts
+++ b/backend/src/plugins/Post/PostPlugin.ts
@@ -3,7 +3,6 @@ import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildScheduledPosts } from "../../data/GuildScheduledPosts";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
@@ -14,7 +13,7 @@ import { PostEmbedCmd } from "./commands/PostEmbedCmd";
 import { ScheduledPostsDeleteCmd } from "./commands/ScheduledPostsDeleteCmd";
 import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd";
 import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd";
-import { ConfigSchema, PostPluginType } from "./types";
+import { PostPluginType, zPostConfig } from "./types";
 import { postScheduledPost } from "./util/postScheduledPost";
 
 const defaultOptions: PluginOptions<PostPluginType> = {
@@ -36,11 +35,11 @@ export const PostPlugin = zeppelinGuildPlugin<PostPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Post",
-    configSchema: ConfigSchema,
+    configSchema: zPostConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zPostConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts
index 815b19b8..e0ec7d2c 100644
--- a/backend/src/plugins/Post/types.ts
+++ b/backend/src/plugins/Post/types.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildScheduledPosts } from "../../data/GuildScheduledPosts";
 
-export const ConfigSchema = t.type({
-  can_post: t.boolean,
+export const zPostConfig = z.strictObject({
+  can_post: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface PostPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zPostConfig>;
   state: {
     savedMessages: GuildSavedMessages;
     scheduledPosts: GuildScheduledPosts;
diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
index ac68de33..06fff977 100644
--- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
+++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
@@ -2,7 +2,6 @@ import { PluginOptions } from "knub";
 import { Queue } from "../../Queue";
 import { GuildReactionRoles } from "../../data/GuildReactionRoles";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd";
@@ -10,7 +9,7 @@ import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd";
 import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd";
 import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt";
 import { MessageDeletedEvt } from "./events/MessageDeletedEvt";
-import { ConfigSchema, ReactionRolesPluginType } from "./types";
+import { ReactionRolesPluginType, zReactionRolesConfig } from "./types";
 
 const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
 
@@ -40,11 +39,11 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin<ReactionRolesPluginType>(
   info: {
     prettyName: "Reaction roles",
     legacy: "Consider using the [Role buttons](/docs/plugins/role_buttons) plugin instead.",
-    configSchema: ConfigSchema,
+    configSchema: zReactionRolesConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zReactionRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts
index 6f65ad98..a1c09f00 100644
--- a/backend/src/plugins/ReactionRoles/types.ts
+++ b/backend/src/plugins/ReactionRoles/types.ts
@@ -1,17 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { GuildReactionRoles } from "../../data/GuildReactionRoles";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { tNullable } from "../../utils";
 
-export const ConfigSchema = t.type({
-  auto_refresh_interval: t.number,
-  remove_user_reactions: t.boolean,
-  can_manage: t.boolean,
-  button_groups: tNullable(t.unknown),
+export const zReactionRolesConfig = z.strictObject({
+  auto_refresh_interval: z.number(),
+  remove_user_reactions: z.boolean(),
+  can_manage: z.boolean(),
+  button_groups: z.nullable(z.unknown()),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export type RoleChangeMode = "+" | "-";
 
@@ -24,12 +22,14 @@ export type PendingMemberRoleChanges = {
   }>;
 };
 
-const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]);
-export type TReactionRolePair = t.TypeOf<typeof ReactionRolePair>;
-type ReactionRolePair = [string, string, string?];
+const zReactionRolePair = z.union([
+  z.tuple([z.string(), z.string(), z.string()]),
+  z.tuple([z.string(), z.string()]),
+]);
+export type TReactionRolePair = z.infer<typeof zReactionRolePair>;
 
 export interface ReactionRolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zReactionRolesConfig>;
   state: {
     reactionRoles: GuildReactionRoles;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts
index 128e945c..5e86ab49 100644
--- a/backend/src/plugins/Reminders/RemindersPlugin.ts
+++ b/backend/src/plugins/Reminders/RemindersPlugin.ts
@@ -1,14 +1,13 @@
 import { PluginOptions } from "knub";
 import { onGuildEvent } from "../../data/GuildEvents";
 import { GuildReminders } from "../../data/GuildReminders";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { RemindCmd } from "./commands/RemindCmd";
 import { RemindersCmd } from "./commands/RemindersCmd";
 import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd";
 import { postReminder } from "./functions/postReminder";
-import { ConfigSchema, RemindersPluginType } from "./types";
+import { RemindersPluginType, zRemindersConfig } from "./types";
 
 const defaultOptions: PluginOptions<RemindersPluginType> = {
   config: {
@@ -29,11 +28,11 @@ export const RemindersPlugin = zeppelinGuildPlugin<RemindersPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Reminders",
-    configSchema: ConfigSchema,
+    configSchema: zRemindersConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zRemindersConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts
index 5bc896ba..4356fac0 100644
--- a/backend/src/plugins/Reminders/types.ts
+++ b/backend/src/plugins/Reminders/types.ts
@@ -1,14 +1,13 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildReminders } from "../../data/GuildReminders";
 
-export const ConfigSchema = t.type({
-  can_use: t.boolean,
+export const zRemindersConfig = z.strictObject({
+  can_use: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface RemindersPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRemindersConfig>;
 
   state: {
     reminders: GuildReminders;
diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
index 79cfb8b4..3f1e34ea 100644
--- a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
+++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts
@@ -1,15 +1,12 @@
 import { GuildRoleButtons } from "../../data/GuildRoleButtons";
-import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { resetButtonsCmd } from "./commands/resetButtons";
 import { onButtonInteraction } from "./events/buttonInteraction";
 import { applyAllRoleButtons } from "./functions/applyAllRoleButtons";
-import { createButtonComponents } from "./functions/createButtonComponents";
-import { TooManyComponentsError } from "./functions/TooManyComponentsError";
 import { pluginInfo } from "./info";
-import { ConfigSchema, RoleButtonsPluginType } from "./types";
+import { RoleButtonsPluginType, zRoleButtonsConfig } from "./types";
 
 export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
   name: "role_buttons",
@@ -31,41 +28,7 @@ export const RoleButtonsPlugin = zeppelinGuildPlugin<RoleButtonsPluginType>()({
     ],
   },
 
-  configParser(input) {
-    // Auto-fill "name" property for buttons based on the object key
-    const seenMessages = new Set();
-    for (const [name, buttonsConfig] of Object.entries<any>((input as any).buttons ?? {})) {
-      if (name.length > 16) {
-        throw new StrictValidationError(["Name for role buttons can be at most 16 characters long"]);
-      }
-
-      if (buttonsConfig) {
-        buttonsConfig.name = name;
-
-        if (buttonsConfig.message) {
-          if ("message_id" in buttonsConfig.message) {
-            if (seenMessages.has(buttonsConfig.message.message_id)) {
-              throw new StrictValidationError(["Can't target the same message with two sets of role buttons"]);
-            }
-            seenMessages.add(buttonsConfig.message.message_id);
-          }
-        }
-
-        if (buttonsConfig.options) {
-          try {
-            createButtonComponents(buttonsConfig);
-          } catch (err) {
-            if (err instanceof TooManyComponentsError) {
-              throw new StrictValidationError(["Too many options; can only have max 5 buttons per row on max 5 rows."]);
-            }
-            throw new StrictValidationError(["Error validating options"]);
-          }
-        }
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zRoleButtonsConfig.parse(input),
 
   dependencies: () => [LogsPlugin, RoleManagerPlugin],
 
diff --git a/backend/src/plugins/RoleButtons/info.ts b/backend/src/plugins/RoleButtons/info.ts
index d63fb17c..6076419c 100644
--- a/backend/src/plugins/RoleButtons/info.ts
+++ b/backend/src/plugins/RoleButtons/info.ts
@@ -1,6 +1,6 @@
 import { trimPluginDescription } from "../../utils";
 import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint";
-import { ConfigSchema } from "./types";
+import { zRoleButtonsConfig } from "./types";
 
 export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
   prettyName: "Role buttons",
@@ -78,5 +78,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = {
               ... # See above for examples for options
     ~~~
   `),
-  configSchema: ConfigSchema,
+  configSchema: zRoleButtonsConfig,
 };
diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts
index f5792f36..3c94f8a8 100644
--- a/backend/src/plugins/RoleButtons/types.ts
+++ b/backend/src/plugins/RoleButtons/types.ts
@@ -1,58 +1,102 @@
 import { ButtonStyle } from "discord.js";
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildRoleButtons } from "../../data/GuildRoleButtons";
-import { tMessageContent, tNullable } from "../../utils";
+import { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from "../../utils";
+import { TooManyComponentsError } from "./functions/TooManyComponentsError";
+import { createButtonComponents } from "./functions/createButtonComponents";
 
-const RoleButtonOption = t.type({
-  role_id: t.string,
-  label: tNullable(t.string),
-  emoji: tNullable(t.string),
+const zRoleButtonOption = z.strictObject({
+  role_id: zSnowflake,
+  label: z.string().nullable().default(null),
+  emoji: z.string().nullable().default(null),
   // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle
-  style: tNullable(
-    t.union([
-      t.literal(ButtonStyle.Primary),
-      t.literal(ButtonStyle.Secondary),
-      t.literal(ButtonStyle.Success),
-      t.literal(ButtonStyle.Danger),
+  style: z.union([
+    z.literal(ButtonStyle.Primary),
+    z.literal(ButtonStyle.Secondary),
+    z.literal(ButtonStyle.Success),
+    z.literal(ButtonStyle.Danger),
 
-      // The following are deprecated
-      t.literal("PRIMARY"),
-      t.literal("SECONDARY"),
-      t.literal("SUCCESS"),
-      t.literal("DANGER"),
-      // t.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
-    ]),
-  ),
-  start_new_row: tNullable(t.boolean),
+    // The following are deprecated
+    z.literal("PRIMARY"),
+    z.literal("SECONDARY"),
+    z.literal("SUCCESS"),
+    z.literal("DANGER"),
+    // z.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
+  ]).nullable().default(null),
+  start_new_row: z.boolean().default(false),
 });
-export type TRoleButtonOption = t.TypeOf<typeof RoleButtonOption>;
+export type TRoleButtonOption = z.infer<typeof zRoleButtonOption>;
 
-const RoleButtonsConfigItem = t.type({
-  name: t.string,
-  message: t.union([
-    t.type({
-      channel_id: t.string,
-      message_id: t.string,
+const zRoleButtonsConfigItem = z.strictObject({
+  // Typed as "never" because you are not expected to supply this directly.
+  // The transform instead picks it up from the property key and the output type is a string.
+  name: z.never().optional().transform((_, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    if (! ruleName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Role buttons must have names",
+      });
+      return z.NEVER;
+    }
+    return ruleName;
+  }),
+  message: z.union([
+    z.strictObject({
+      channel_id: zSnowflake,
+      message_id: zSnowflake,
     }),
-    t.type({
-      channel_id: t.string,
-      content: tMessageContent,
+    z.strictObject({
+      channel_id: zSnowflake,
+      content: zMessageContent,
     }),
   ]),
-  options: t.array(RoleButtonOption),
-  exclusive: tNullable(t.boolean),
-});
-export type TRoleButtonsConfigItem = t.TypeOf<typeof RoleButtonsConfigItem>;
+  options: z.array(zRoleButtonOption).max(25),
+  exclusive: z.boolean().default(false),
+})
+  .refine((parsed) => {
+    try {
+      createButtonComponents(parsed);
+    } catch (err) {
+      if (err instanceof TooManyComponentsError) {
+        return false;
+      }
+      throw err;
+    }
+    return true;
+  }, {
+    message: "Too many options; can only have max 5 buttons per row on max 5 rows."
+  });
+export type TRoleButtonsConfigItem = z.infer<typeof zRoleButtonsConfigItem>;
 
-export const ConfigSchema = t.type({
-  buttons: t.record(t.string, RoleButtonsConfigItem),
-  can_reset: t.boolean,
-});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+export const zRoleButtonsConfig = z.strictObject({
+  buttons: zBoundedRecord(
+    z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem),
+    0,
+    100,
+  ),
+  can_reset: z.boolean(),
+})
+  .refine((parsed) => {
+    const seenMessages = new Set();
+    for (const button of Object.values(parsed.buttons)) {
+      if (button.message) {
+        if ("message_id" in button.message) {
+          if (seenMessages.has(button.message.message_id)) {
+            return false;
+          }
+          seenMessages.add(button.message.message_id);
+        }
+      }
+    }
+    return true;
+  }, {
+    message: "Can't target the same message with two sets of role buttons",
+  });
 
 export interface RoleButtonsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRoleButtonsConfig>;
   state: {
     roleButtons: GuildRoleButtons;
   };
diff --git a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts
index 878b2cd4..34427d98 100644
--- a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts
+++ b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts
@@ -1,5 +1,5 @@
 import { GuildRoleQueue } from "../../data/GuildRoleQueue";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { addPriorityRole } from "./functions/addPriorityRole";
@@ -7,14 +7,14 @@ import { addRole } from "./functions/addRole";
 import { removePriorityRole } from "./functions/removePriorityRole";
 import { removeRole } from "./functions/removeRole";
 import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop";
-import { ConfigSchema, RoleManagerPluginType } from "./types";
+import { RoleManagerPluginType, zRoleManagerConfig } from "./types";
 
 export const RoleManagerPlugin = zeppelinGuildPlugin<RoleManagerPluginType>()({
   name: "role_manager",
   showInDocs: false,
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zRoleManagerConfig.parse(input),
 
   public: {
     addRole: mapToPublicFn(addRole),
diff --git a/backend/src/plugins/RoleManager/types.ts b/backend/src/plugins/RoleManager/types.ts
index 54f5033a..51ba3080 100644
--- a/backend/src/plugins/RoleManager/types.ts
+++ b/backend/src/plugins/RoleManager/types.ts
@@ -1,12 +1,11 @@
-import * as t from "io-ts";
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildRoleQueue } from "../../data/GuildRoleQueue";
 
-export const ConfigSchema = t.type({});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+export const zRoleManagerConfig = z.strictObject({});
 
 export interface RoleManagerPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRoleManagerConfig>;
   state: {
     roleQueue: GuildRoleQueue;
     roleAssignmentLoopRunning: boolean;
diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts
index 6c810940..0e181cfa 100644
--- a/backend/src/plugins/Roles/RolesPlugin.ts
+++ b/backend/src/plugins/Roles/RolesPlugin.ts
@@ -1,6 +1,5 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin";
@@ -9,13 +8,13 @@ import { AddRoleCmd } from "./commands/AddRoleCmd";
 import { MassAddRoleCmd } from "./commands/MassAddRoleCmd";
 import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd";
 import { RemoveRoleCmd } from "./commands/RemoveRoleCmd";
-import { ConfigSchema, RolesPluginType } from "./types";
+import { RolesPluginType, zRolesConfig } from "./types";
 
 const defaultOptions: PluginOptions<RolesPluginType> = {
   config: {
     can_assign: false,
     can_mass_assign: false,
-    assignable_roles: ["558037973581430785"],
+    assignable_roles: [],
   },
   overrides: [
     {
@@ -41,11 +40,11 @@ export const RolesPlugin = zeppelinGuildPlugin<RolesPluginType>()({
     description: trimPluginDescription(`
       Enables authorised users to add and remove whitelisted roles with a command.
     `),
-    configSchema: ConfigSchema,
+    configSchema: zRolesConfig,
   },
 
   dependencies: () => [LogsPlugin, RoleManagerPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Roles/types.ts b/backend/src/plugins/Roles/types.ts
index 346dc562..caf55b76 100644
--- a/backend/src/plugins/Roles/types.ts
+++ b/backend/src/plugins/Roles/types.ts
@@ -1,16 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 
-export const ConfigSchema = t.type({
-  can_assign: t.boolean,
-  can_mass_assign: t.boolean,
-  assignable_roles: t.array(t.string),
+export const zRolesConfig = z.strictObject({
+  can_assign: z.boolean(),
+  can_mass_assign: z.boolean(),
+  assignable_roles: z.array(z.string()).max(100),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface RolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zRolesConfig>;
   state: {
     logs: GuildLogs;
   };
diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts
index f16cff92..6f0d666b 100644
--- a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts
+++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts
@@ -1,11 +1,10 @@
 import { CooldownManager, PluginOptions } from "knub";
 import { trimPluginDescription } from "../../utils";
-import { parseIoTsSchema } from "../../validatorUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { RoleAddCmd } from "./commands/RoleAddCmd";
 import { RoleHelpCmd } from "./commands/RoleHelpCmd";
 import { RoleRemoveCmd } from "./commands/RoleRemoveCmd";
-import { ConfigSchema, SelfGrantableRolesPluginType, defaultSelfGrantableRoleEntry } from "./types";
+import { SelfGrantableRolesPluginType, zSelfGrantableRolesConfig } from "./types";
 
 const defaultOptions: PluginOptions<SelfGrantableRolesPluginType> = {
   config: {
@@ -66,25 +65,10 @@ export const SelfGrantableRolesPlugin = zeppelinGuildPlugin<SelfGrantableRolesPl
                   can_use: true
       ~~~
     `),
-    configSchema: ConfigSchema,
+    configSchema: zSelfGrantableRolesConfig,
   },
 
-  configParser: (input) => {
-    const entries = (input as any).entries;
-    for (const [key, entry] of Object.entries<any>(entries)) {
-      // Apply default entry config
-      entries[key] = { ...defaultSelfGrantableRoleEntry, ...entry };
-
-      // Normalize alias names
-      if (entry.roles) {
-        for (const [roleId, aliases] of Object.entries<string[]>(entry.roles)) {
-          entry.roles[roleId] = aliases.map((a) => a.toLowerCase());
-        }
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zSelfGrantableRolesConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts
index 08d665df..a1aed241 100644
--- a/backend/src/plugins/SelfGrantableRoles/types.ts
+++ b/backend/src/plugins/SelfGrantableRoles/types.ts
@@ -1,31 +1,29 @@
-import * as t from "io-ts";
 import { BasePluginType, CooldownManager, guildPluginMessageCommand } from "knub";
+import z from "zod";
+import { zBoundedCharacters, zBoundedRecord } from "../../utils";
 
-const RoleMap = t.record(t.string, t.array(t.string));
+const zRoleMap = z.record(
+  zBoundedCharacters(1, 100),
+  z.array(zBoundedCharacters(1, 2000))
+    .max(100)
+    .transform((parsed) => parsed.map(v => v.toLowerCase())),
+);
 
-const SelfGrantableRoleEntry = t.type({
-  roles: RoleMap,
-  can_use: t.boolean,
-  can_ignore_cooldown: t.boolean,
-  max_roles: t.number,
+const zSelfGrantableRoleEntry = z.strictObject({
+  roles: zBoundedRecord(zRoleMap, 0, 100),
+  can_use: z.boolean().default(false),
+  can_ignore_cooldown: z.boolean().default(false),
+  max_roles: z.number().default(0),
 });
-const PartialRoleEntry = t.partial(SelfGrantableRoleEntry.props);
-export type TSelfGrantableRoleEntry = t.TypeOf<typeof SelfGrantableRoleEntry>;
+export type TSelfGrantableRoleEntry = z.infer<typeof zSelfGrantableRoleEntry>;
 
-export const ConfigSchema = t.type({
-  entries: t.record(t.string, SelfGrantableRoleEntry),
-  mention_roles: t.boolean,
+export const zSelfGrantableRolesConfig = z.strictObject({
+  entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100),
+  mention_roles: z.boolean(),
 });
-type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
-export const defaultSelfGrantableRoleEntry: t.TypeOf<typeof PartialRoleEntry> = {
-  can_use: false,
-  can_ignore_cooldown: false,
-  max_roles: 0,
-};
 
 export interface SelfGrantableRolesPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zSelfGrantableRolesConfig>;
   state: {
     cooldowns: CooldownManager;
   };
diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts
index 4f6c0996..174f38a9 100644
--- a/backend/src/plugins/Slowmode/SlowmodePlugin.ts
+++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts
@@ -2,7 +2,6 @@ import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildSlowmodes } from "../../data/GuildSlowmodes";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { SECONDS } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
@@ -11,7 +10,7 @@ import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd";
 import { SlowmodeGetCmd } from "./commands/SlowmodeGetCmd";
 import { SlowmodeListCmd } from "./commands/SlowmodeListCmd";
 import { SlowmodeSetCmd } from "./commands/SlowmodeSetCmd";
-import { ConfigSchema, SlowmodePluginType } from "./types";
+import { SlowmodePluginType, zSlowmodeConfig } from "./types";
 import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes";
 import { onMessageCreate } from "./util/onMessageCreate";
 
@@ -41,7 +40,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin<SlowmodePluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Slowmode",
-    configSchema: ConfigSchema,
+    configSchema: zSlowmodeConfig,
   },
 
   // prettier-ignore
@@ -49,7 +48,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin<SlowmodePluginType>()({
     LogsPlugin,
   ],
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zSlowmodeConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts
index 089a59ef..c4fe44d5 100644
--- a/backend/src/plugins/Slowmode/types.ts
+++ b/backend/src/plugins/Slowmode/types.ts
@@ -1,20 +1,19 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildSlowmodes } from "../../data/GuildSlowmodes";
 import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel";
 
-export const ConfigSchema = t.type({
-  use_native_slowmode: t.boolean,
-
-  can_manage: t.boolean,
-  is_affected: t.boolean,
+export const zSlowmodeConfig = z.strictObject({
+  use_native_slowmode: z.boolean(),
+  
+  can_manage: z.boolean(),
+  is_affected: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface SlowmodePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zSlowmodeConfig>;
   state: {
     slowmodes: GuildSlowmodes;
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Spam/SpamPlugin.ts b/backend/src/plugins/Spam/SpamPlugin.ts
index e1a7efc0..54ec540c 100644
--- a/backend/src/plugins/Spam/SpamPlugin.ts
+++ b/backend/src/plugins/Spam/SpamPlugin.ts
@@ -3,12 +3,11 @@ import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { SpamVoiceStateUpdateEvt } from "./events/SpamVoiceEvt";
-import { ConfigSchema, SpamPluginType } from "./types";
+import { SpamPluginType, zSpamConfig } from "./types";
 import { clearOldRecentActions } from "./util/clearOldRecentActions";
 import { onMessageCreate } from "./util/onMessageCreate";
 
@@ -53,11 +52,11 @@ export const SpamPlugin = zeppelinGuildPlugin<SpamPluginType>()({
       For more advanced spam filtering, check out the Automod plugin!
     `),
     legacy: true,
-    configSchema: ConfigSchema,
+    configSchema: zSpamConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zSpamConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts
index 1e561477..74ea872f 100644
--- a/backend/src/plugins/Spam/types.ts
+++ b/backend/src/plugins/Spam/types.ts
@@ -1,35 +1,40 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildMutes } from "../../data/GuildMutes";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { tNullable } from "../../utils";
+import { zSnowflake } from "../../utils";
 
-const BaseSingleSpamConfig = t.type({
-  interval: t.number,
-  count: t.number,
-  mute: tNullable(t.boolean),
-  mute_time: tNullable(t.number),
-  remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-  restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
-  clean: tNullable(t.boolean),
+const zBaseSingleSpamConfig = z.strictObject({
+  interval: z.number(),
+  count: z.number(),
+  mute: z.boolean().default(false),
+  mute_time: z.number().nullable().default(null),
+  remove_roles_on_mute: z.union([
+    z.boolean(),
+    z.array(zSnowflake),
+  ]).default(false),
+  restore_roles_on_mute: z.union([
+    z.boolean(),
+    z.array(zSnowflake),
+  ]).default(false),
+  clean: z.boolean().default(false),
 });
-export type TBaseSingleSpamConfig = t.TypeOf<typeof BaseSingleSpamConfig>;
+export type TBaseSingleSpamConfig = z.infer<typeof zBaseSingleSpamConfig>;
 
-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 const zSpamConfig = z.strictObject({
+  max_censor: zBaseSingleSpamConfig.nullable(),
+  max_messages: zBaseSingleSpamConfig.nullable(),
+  max_mentions: zBaseSingleSpamConfig.nullable(),
+  max_links: zBaseSingleSpamConfig.nullable(),
+  max_attachments: zBaseSingleSpamConfig.nullable(),
+  max_emojis: zBaseSingleSpamConfig.nullable(),
+  max_newlines: zBaseSingleSpamConfig.nullable(),
+  max_duplicates: zBaseSingleSpamConfig.nullable(),
+  max_characters: zBaseSingleSpamConfig.nullable(),
+  max_voice_moves: zBaseSingleSpamConfig.nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export enum RecentActionType {
   Message = 1,
@@ -53,7 +58,7 @@ interface IRecentAction<T> {
 }
 
 export interface SpamPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zSpamConfig>;
   state: {
     logs: GuildLogs;
     archives: GuildArchives;
diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts
index 050b9037..4ca3c441 100644
--- a/backend/src/plugins/Starboard/StarboardPlugin.ts
+++ b/backend/src/plugins/Starboard/StarboardPlugin.ts
@@ -3,12 +3,11 @@ import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildStarboardMessages } from "../../data/GuildStarboardMessages";
 import { GuildStarboardReactions } from "../../data/GuildStarboardReactions";
 import { trimPluginDescription } from "../../utils";
-import { parseIoTsSchema } from "../../validatorUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { MigratePinsCmd } from "./commands/MigratePinsCmd";
 import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt";
 import { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from "./events/StarboardReactionRemoveEvts";
-import { ConfigSchema, StarboardPluginType, defaultStarboardOpts } from "./types";
+import { StarboardPluginType, zStarboardConfig } from "./types";
 import { onMessageDelete } from "./util/onMessageDelete";
 
 const defaultOptions: PluginOptions<StarboardPluginType> = {
@@ -120,19 +119,10 @@ export const StarboardPlugin = zeppelinGuildPlugin<StarboardPluginType>()({
                   enabled: true
       ~~~
     `),
-    configSchema: ConfigSchema,
+    configSchema: zStarboardConfig,
   },
 
-  configParser(input) {
-    const boards = (input as any).boards;
-    if (boards) {
-      for (const [name, opts] of Object.entries(boards)) {
-        boards[name] = Object.assign({}, defaultStarboardOpts, opts);
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zStarboardConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts
index 223501e3..7dc2d8f5 100644
--- a/backend/src/plugins/Starboard/types.ts
+++ b/backend/src/plugins/Starboard/types.ts
@@ -1,39 +1,33 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildStarboardMessages } from "../../data/GuildStarboardMessages";
 import { GuildStarboardReactions } from "../../data/GuildStarboardReactions";
-import { tDeepPartial, tNullable } from "../../utils";
+import { zBoundedRecord, zSnowflake } from "../../utils";
 
-const StarboardOpts = t.type({
-  channel_id: t.string,
-  stars_required: t.number,
-  star_emoji: tNullable(t.array(t.string)),
-  allow_selfstars: tNullable(t.boolean),
-  copy_full_embed: tNullable(t.boolean),
-  enabled: tNullable(t.boolean),
-  show_star_count: t.boolean,
-  color: tNullable(t.number),
+const zStarboardOpts = z.strictObject({
+  channel_id: zSnowflake,
+  stars_required: z.number(),
+  star_emoji: z.array(z.string()).default(["⭐"]),
+  allow_selfstars: z.boolean().default(false),
+  copy_full_embed: z.boolean().default(false),
+  enabled: z.boolean().default(true),
+  show_star_count: z.boolean().default(true),
+  color: z.number().nullable().default(null),
 });
-export type TStarboardOpts = t.TypeOf<typeof StarboardOpts>;
+export type TStarboardOpts = z.infer<typeof zStarboardOpts>;
 
-export const ConfigSchema = t.type({
-  boards: t.record(t.string, StarboardOpts),
-  can_migrate: t.boolean,
+export const zStarboardConfig = z.strictObject({
+  boards: zBoundedRecord(
+    z.record(z.string(), zStarboardOpts),
+    0,
+    100,
+  ),
+  can_migrate: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
-
-export const PartialConfigSchema = tDeepPartial(ConfigSchema);
-
-export const defaultStarboardOpts: Partial<TStarboardOpts> = {
-  star_emoji: ["⭐"],
-  enabled: true,
-  show_star_count: true,
-  color: null,
-};
 
 export interface StarboardPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zStarboardConfig>;
 
   state: {
     savedMessages: GuildSavedMessages;
diff --git a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
deleted file mode 100644
index 57258759..00000000
--- a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as t from "io-ts";
-import { defaultStarboardOpts, PartialConfigSchema } from "../types";
-
-export function preprocessStaticConfig(config: t.TypeOf<typeof PartialConfigSchema>) {
-  if (config.boards) {
-    for (const [name, opts] of Object.entries(config.boards)) {
-      config.boards[name] = Object.assign({}, defaultStarboardOpts, opts);
-    }
-  }
-
-  return config;
-}
diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts
index 3e0a5bb4..c68f90bf 100644
--- a/backend/src/plugins/Tags/TagsPlugin.ts
+++ b/backend/src/plugins/Tags/TagsPlugin.ts
@@ -2,7 +2,6 @@ import { Snowflake } from "discord.js";
 import humanizeDuration from "humanize-duration";
 import { PluginOptions } from "knub";
 import moment from "moment-timezone";
-import { parseIoTsSchema, StrictValidationError } from "src/validatorUtils";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
@@ -19,7 +18,7 @@ import { TagListCmd } from "./commands/TagListCmd";
 import { TagSourceCmd } from "./commands/TagSourceCmd";
 import { generateTemplateMarkdown } from "./docs";
 import { TemplateFunctions } from "./templateFunctions";
-import { ConfigSchema, TagsPluginType } from "./types";
+import { TagsPluginType, zTagsConfig } from "./types";
 import { findTagByName } from "./util/findTagByName";
 import { onMessageCreate } from "./util/onMessageCreate";
 import { onMessageDelete } from "./util/onMessageDelete";
@@ -71,7 +70,7 @@ export const TagsPlugin = zeppelinGuildPlugin<TagsPluginType>()({
 
       ${generateTemplateMarkdown(TemplateFunctions)}
     `),
-    configSchema: ConfigSchema,
+    configSchema: zTagsConfig,
   },
 
   dependencies: () => [LogsPlugin],
@@ -96,28 +95,7 @@ export const TagsPlugin = zeppelinGuildPlugin<TagsPluginType>()({
     findTagByName: mapToPublicFn(findTagByName),
   },
 
-  configParser(_input) {
-    const input = _input as any;
-
-    if (input.delete_with_command && input.auto_delete_command) {
-      throw new StrictValidationError([
-        `Cannot have both (global) delete_with_command and global_delete_invoke enabled`,
-      ]);
-    }
-
-    // Check each category for conflicting options
-    if (input.categories) {
-      for (const [name, cat] of Object.entries(input.categories)) {
-        if ((cat as any).delete_with_command && (cat as any).auto_delete_command) {
-          throw new StrictValidationError([
-            `Cannot have both (category specific) delete_with_command and category_delete_invoke enabled at <categories/${name}>`,
-          ]);
-        }
-      }
-    }
-
-    return parseIoTsSchema(ConfigSchema, input);
-  },
+  configParser: (input) => zTagsConfig.parse(input),
 
   beforeLoad(pluginData) {
     const { state, guild } = pluginData;
diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts
index 3a7eb2b3..441906fe 100644
--- a/backend/src/plugins/Tags/types.ts
+++ b/backend/src/plugins/Tags/types.ts
@@ -1,52 +1,63 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { GuildTags } from "../../data/GuildTags";
-import { tEmbed, tNullable } from "../../utils";
+import { zEmbedInput } from "../../utils";
 
-export const Tag = t.union([t.string, tEmbed]);
-export type TTag = t.TypeOf<typeof Tag>;
+export const zTag = z.union([z.string(), zEmbedInput]);
+export type TTag = z.infer<typeof zTag>;
 
-export const TagCategory = t.type({
-  prefix: tNullable(t.string),
-  delete_with_command: tNullable(t.boolean),
+export const zTagCategory = z.strictObject({
+  prefix: z.string().nullable().default(null),
+  delete_with_command: z.boolean().default(false),
 
-  user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag
-  user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category
-  global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag
-  allow_mentions: tNullable(t.boolean), // Per user, per category
-  global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category
-  auto_delete_command: tNullable(t.boolean), // Any tag, per tag category
+  user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag
+  user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category
+  global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag
+  allow_mentions: z.boolean().nullable().default(null),
+  global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category
+  auto_delete_command: z.boolean().nullable().default(null),
 
-  tags: t.record(t.string, Tag),
+  tags: z.record(z.string(), zTag),
 
-  can_use: tNullable(t.boolean),
-});
-export type TTagCategory = t.TypeOf<typeof TagCategory>;
+  can_use: z.boolean().nullable().default(null),
+})
+  .refine(
+    (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command),
+    {
+      message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
+    },
+  );
+export type TTagCategory = z.infer<typeof zTagCategory>;
 
-export const ConfigSchema = t.type({
-  prefix: t.string,
-  delete_with_command: t.boolean,
+export const zTagsConfig = z.strictObject({
+  prefix: z.string(),
+  delete_with_command: z.boolean(),
 
-  user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag
-  global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag
-  user_cooldown: tNullable(t.union([t.string, t.number])), // Per user
-  allow_mentions: t.boolean, // Per user
-  global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use
-  auto_delete_command: t.boolean, // Any tag
+  user_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user, per tag
+  global_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Any user, per tag
+  user_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user
+  allow_mentions: z.boolean(), // Per user
+  global_cooldown: z.union([z.string(), z.number()]).nullable(), // Any tag use
+  auto_delete_command: z.boolean(), // Any tag
 
-  categories: t.record(t.string, TagCategory),
+  categories: z.record(z.string(), zTagCategory),
 
-  can_create: t.boolean,
-  can_use: t.boolean,
-  can_list: t.boolean,
-});
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+  can_create: z.boolean(),
+  can_use: z.boolean(),
+  can_list: z.boolean(),
+})
+.refine(
+  (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command),
+  {
+    message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
+  },
+);
 
 export interface TagsPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zTagsConfig>;
   state: {
     archives: GuildArchives;
     tags: GuildTags;
diff --git a/backend/src/plugins/Tags/util/findTagByName.ts b/backend/src/plugins/Tags/util/findTagByName.ts
index 1b877f33..98fafc3f 100644
--- a/backend/src/plugins/Tags/util/findTagByName.ts
+++ b/backend/src/plugins/Tags/util/findTagByName.ts
@@ -1,12 +1,11 @@
-import * as t from "io-ts";
 import { ExtendedMatchParams, GuildPluginData } from "knub";
-import { Tag, TagsPluginType } from "../types";
+import { TTag, TagsPluginType } from "../types";
 
 export async function findTagByName(
   pluginData: GuildPluginData<TagsPluginType>,
   name: string,
   matchParams: ExtendedMatchParams = {},
-): Promise<t.TypeOf<typeof Tag> | null> {
+): Promise<TTag | null> {
   const config = await pluginData.config.getMatchingConfig(matchParams);
 
   // Tag from a hardcoded category
diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts
index 12c6cf8d..f5b0b04a 100644
--- a/backend/src/plugins/Tags/util/onMessageCreate.ts
+++ b/backend/src/plugins/Tags/util/onMessageCreate.ts
@@ -2,9 +2,8 @@ import { Snowflake, TextChannel } from "discord.js";
 import { GuildPluginData } from "knub";
 import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
-import { convertDelayStringToMS, resolveMember, tStrictMessageContent } from "../../../utils";
+import { convertDelayStringToMS, resolveMember, zStrictMessageContent } from "../../../utils";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
-import { validate } from "../../../validatorUtils";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TagsPluginType } from "../types";
 import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString";
@@ -85,10 +84,10 @@ export async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType
     pluginData.cooldowns.setCooldown(cd[0], cd[1]);
   }
 
-  const validationError = await validate(tStrictMessageContent, tagResult.renderedContent);
-  if (validationError) {
+  const validated = zStrictMessageContent.safeParse(tagResult.renderedContent);
+  if (! validated.success) {
     pluginData.getPlugin(LogsPlugin).logBotAlert({
-      body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validationError.message}`,
+      body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validated.error.message}`,
     });
     return;
   }
diff --git a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts
index f4d5ded0..885604a2 100644
--- a/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts
+++ b/backend/src/plugins/TimeAndDate/TimeAndDatePlugin.ts
@@ -1,6 +1,6 @@
 import { PluginOptions } from "knub";
 import { GuildMemberTimezones } from "../../data/GuildMemberTimezones";
-import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils";
+import { mapToPublicFn } from "../../pluginUtils";
 import { trimPluginDescription } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { ResetTimezoneCmd } from "./commands/ResetTimezoneCmd";
@@ -12,7 +12,7 @@ import { getGuildTz } from "./functions/getGuildTz";
 import { getMemberTz } from "./functions/getMemberTz";
 import { inGuildTz } from "./functions/inGuildTz";
 import { inMemberTz } from "./functions/inMemberTz";
-import { ConfigSchema, TimeAndDatePluginType } from "./types";
+import { TimeAndDatePluginType, zTimeAndDateConfig } from "./types";
 
 const defaultOptions: PluginOptions<TimeAndDatePluginType> = {
   config: {
@@ -39,10 +39,10 @@ export const TimeAndDatePlugin = zeppelinGuildPlugin<TimeAndDatePluginType>()({
     description: trimPluginDescription(`
       Allows controlling the displayed time/date formats and timezones
     `),
-    configSchema: ConfigSchema,
+    configSchema: zTimeAndDateConfig,
   },
 
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zTimeAndDateConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/TimeAndDate/types.ts b/backend/src/plugins/TimeAndDate/types.ts
index 102d620b..7a942518 100644
--- a/backend/src/plugins/TimeAndDate/types.ts
+++ b/backend/src/plugins/TimeAndDate/types.ts
@@ -1,19 +1,21 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginMessageCommand } from "knub";
+import { U } from "ts-toolbelt";
+import z from "zod";
 import { GuildMemberTimezones } from "../../data/GuildMemberTimezones";
-import { tNullable, tPartialDictionary } from "../../utils";
-import { tValidTimezone } from "../../utils/tValidTimezone";
+import { keys } from "../../utils";
+import { zValidTimezone } from "../../utils/zValidTimezone";
 import { defaultDateFormats } from "./defaultDateFormats";
 
-export const ConfigSchema = t.type({
-  timezone: tValidTimezone,
-  date_formats: tNullable(tPartialDictionary(t.keyof(defaultDateFormats), t.string)),
-  can_set_timezone: t.boolean,
+const zDateFormatKeys = z.enum(keys(defaultDateFormats) as U.ListOf<keyof typeof defaultDateFormats>);
+
+export const zTimeAndDateConfig = z.strictObject({
+  timezone: zValidTimezone(z.string()),
+  date_formats: z.record(zDateFormatKeys, z.string()).nullable(),
+  can_set_timezone: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface TimeAndDatePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zTimeAndDateConfig>;
   state: {
     memberTimezones: GuildMemberTimezones;
   };
diff --git a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
index 3bdb310c..e64d3c98 100644
--- a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
+++ b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts
@@ -1,16 +1,14 @@
-import * as t from "io-ts";
 import { Queue } from "../../Queue";
 import { UsernameHistory } from "../../data/UsernameHistory";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { MessageCreateUpdateUsernameEvt, VoiceChannelJoinUpdateUsernameEvt } from "./events/UpdateUsernameEvts";
-import { UsernameSaverPluginType } from "./types";
+import { UsernameSaverPluginType, zUsernameSaverConfig } from "./types";
 
 export const UsernameSaverPlugin = zeppelinGuildPlugin<UsernameSaverPluginType>()({
   name: "username_saver",
   showInDocs: false,
 
-  configParser: makeIoTsConfigParser(t.type({})),
+  configParser: (input) => zUsernameSaverConfig.parse(input),
 
   // prettier-ignore
   events: [
diff --git a/backend/src/plugins/UsernameSaver/types.ts b/backend/src/plugins/UsernameSaver/types.ts
index d3c5518f..16d6e342 100644
--- a/backend/src/plugins/UsernameSaver/types.ts
+++ b/backend/src/plugins/UsernameSaver/types.ts
@@ -1,8 +1,12 @@
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { Queue } from "../../Queue";
 import { UsernameHistory } from "../../data/UsernameHistory";
 
+export const zUsernameSaverConfig = z.strictObject({});
+
 export interface UsernameSaverPluginType extends BasePluginType {
+  config: z.infer<typeof zUsernameSaverConfig>;
   state: {
     usernameHistory: UsernameHistory;
     updateQueue: Queue;
diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts
index 68f6987a..efb892ec 100644
--- a/backend/src/plugins/Utility/UtilityPlugin.ts
+++ b/backend/src/plugins/Utility/UtilityPlugin.ts
@@ -5,7 +5,7 @@ import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { Supporters } from "../../data/Supporters";
-import { makeIoTsConfigParser, sendSuccessMessage } from "../../pluginUtils";
+import { sendSuccessMessage } from "../../pluginUtils";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { ModActionsPlugin } from "../ModActions/ModActionsPlugin";
@@ -42,7 +42,7 @@ import { getUserInfoEmbed } from "./functions/getUserInfoEmbed";
 import { hasPermission } from "./functions/hasPermission";
 import { activeReloads } from "./guildReloads";
 import { refreshMembersIfNeeded } from "./refreshMembers";
-import { ConfigSchema, UtilityPluginType } from "./types";
+import { UtilityPluginType, zUtilityConfig } from "./types";
 
 const defaultOptions: PluginOptions<UtilityPluginType> = {
   config: {
@@ -117,11 +117,11 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()({
   showInDocs: true,
   info: {
     prettyName: "Utility",
-    configSchema: ConfigSchema,
+    configSchema: zUtilityConfig,
   },
 
   dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zUtilityConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts
index 19710b58..dbaacf27 100644
--- a/backend/src/plugins/Utility/search.ts
+++ b/backend/src/plugins/Utility/search.ts
@@ -14,10 +14,9 @@ import { ArgsFromSignatureOrArray, GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { RegExpRunner, allowTimeout } from "../../RegExpRunner";
 import { getBaseUrl, sendErrorMessage } from "../../pluginUtils";
-import { MINUTES, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils";
+import { InvalidRegexError, MINUTES, inputPatternToRegExp, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils";
 import { asyncFilter } from "../../utils/async";
 import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions";
-import { InvalidRegexError, inputPatternToRegExp } from "../../validatorUtils";
 import { banSearchSignature } from "./commands/BanSearchCmd";
 import { searchCmdSignature } from "./commands/SearchCmd";
 import { getUserInfoEmbed } from "./functions/getUserInfoEmbed";
diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts
index aaac036a..77b3d8ac 100644
--- a/backend/src/plugins/Utility/types.ts
+++ b/backend/src/plugins/Utility/types.ts
@@ -1,5 +1,5 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub";
+import z from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
@@ -7,39 +7,38 @@ import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { Supporters } from "../../data/Supporters";
 
-export const ConfigSchema = t.type({
-  can_roles: t.boolean,
-  can_level: t.boolean,
-  can_search: t.boolean,
-  can_clean: t.boolean,
-  can_info: t.boolean,
-  can_server: t.boolean,
-  can_inviteinfo: t.boolean,
-  can_channelinfo: t.boolean,
-  can_messageinfo: t.boolean,
-  can_userinfo: t.boolean,
-  can_roleinfo: t.boolean,
-  can_emojiinfo: t.boolean,
-  can_snowflake: t.boolean,
-  can_reload_guild: t.boolean,
-  can_nickname: t.boolean,
-  can_ping: t.boolean,
-  can_source: t.boolean,
-  can_vcmove: t.boolean,
-  can_vckick: t.boolean,
-  can_help: t.boolean,
-  can_about: t.boolean,
-  can_context: t.boolean,
-  can_jumbo: t.boolean,
-  jumbo_size: t.Integer,
-  can_avatar: t.boolean,
-  info_on_single_result: t.boolean,
-  autojoin_threads: t.boolean,
+export const zUtilityConfig = z.strictObject({
+  can_roles: z.boolean(),
+  can_level: z.boolean(),
+  can_search: z.boolean(),
+  can_clean: z.boolean(),
+  can_info: z.boolean(),
+  can_server: z.boolean(),
+  can_inviteinfo: z.boolean(),
+  can_channelinfo: z.boolean(),
+  can_messageinfo: z.boolean(),
+  can_userinfo: z.boolean(),
+  can_roleinfo: z.boolean(),
+  can_emojiinfo: z.boolean(),
+  can_snowflake: z.boolean(),
+  can_reload_guild: z.boolean(),
+  can_nickname: z.boolean(),
+  can_ping: z.boolean(),
+  can_source: z.boolean(),
+  can_vcmove: z.boolean(),
+  can_vckick: z.boolean(),
+  can_help: z.boolean(),
+  can_about: z.boolean(),
+  can_context: z.boolean(),
+  can_jumbo: z.boolean(),
+  jumbo_size: z.number(),
+  can_avatar: z.boolean(),
+  info_on_single_result: z.boolean(),
+  autojoin_threads: z.boolean(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface UtilityPluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zUtilityConfig>;
   state: {
     logs: GuildLogs;
     cases: GuildCases;
diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
index e007b968..ba8f9de3 100644
--- a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
+++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts
@@ -1,10 +1,9 @@
 import { PluginOptions } from "knub";
 import { GuildLogs } from "../../data/GuildLogs";
-import { makeIoTsConfigParser } from "../../pluginUtils";
 import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { SendWelcomeMessageEvt } from "./events/SendWelcomeMessageEvt";
-import { ConfigSchema, WelcomeMessagePluginType } from "./types";
+import { WelcomeMessagePluginType, zWelcomeMessageConfig } from "./types";
 
 const defaultOptions: PluginOptions<WelcomeMessagePluginType> = {
   config: {
@@ -19,11 +18,11 @@ export const WelcomeMessagePlugin = zeppelinGuildPlugin<WelcomeMessagePluginType
   showInDocs: true,
   info: {
     prettyName: "Welcome message",
-    configSchema: ConfigSchema,
+    configSchema: zWelcomeMessageConfig,
   },
 
   dependencies: () => [LogsPlugin],
-  configParser: makeIoTsConfigParser(ConfigSchema),
+  configParser: (input) => zWelcomeMessageConfig.parse(input),
   defaultOptions,
 
   // prettier-ignore
diff --git a/backend/src/plugins/WelcomeMessage/types.ts b/backend/src/plugins/WelcomeMessage/types.ts
index 682cfe39..25e9afb1 100644
--- a/backend/src/plugins/WelcomeMessage/types.ts
+++ b/backend/src/plugins/WelcomeMessage/types.ts
@@ -1,17 +1,15 @@
-import * as t from "io-ts";
 import { BasePluginType, guildPluginEventListener } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
-import { tNullable } from "../../utils";
 
-export const ConfigSchema = t.type({
-  send_dm: t.boolean,
-  send_to_channel: tNullable(t.string),
-  message: tNullable(t.string),
+export const zWelcomeMessageConfig = z.strictObject({
+  send_dm: z.boolean(),
+  send_to_channel: z.string().nullable(),
+  message: z.string().nullable(),
 });
-export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
 
 export interface WelcomeMessagePluginType extends BasePluginType {
-  config: TConfigSchema;
+  config: z.infer<typeof zWelcomeMessageConfig>;
   state: {
     logs: GuildLogs;
     sentWelcomeMessages: Set<string>;
diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts
index 165c67b5..aa266abc 100644
--- a/backend/src/plugins/ZeppelinPluginBlueprint.ts
+++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts
@@ -1,4 +1,3 @@
-import * as t from "io-ts";
 import {
   BasePluginType,
   globalPlugin,
@@ -9,6 +8,7 @@ import {
   GuildPluginData,
 } from "knub";
 import { TMarkdown } from "../types";
+import { ZodTypeAny } from "zod";
 
 /**
  * GUILD PLUGINS
@@ -23,7 +23,7 @@ export interface ZeppelinGuildPluginBlueprint<TPluginData extends GuildPluginDat
     usageGuide?: TMarkdown;
     configurationGuide?: TMarkdown;
     legacy?: boolean | string;
-    configSchema?: t.Type<any>;
+    configSchema?: ZodTypeAny;
   };
 }
 
diff --git a/backend/src/types.ts b/backend/src/types.ts
index 45974aee..409c4dc6 100644
--- a/backend/src/types.ts
+++ b/backend/src/types.ts
@@ -1,5 +1,6 @@
-import * as t from "io-ts";
 import { BaseConfig, Knub } from "knub";
+import z from "zod";
+import { zSnowflake } from "./utils";
 
 export interface ZeppelinGuildConfig extends BaseConfig {
   success_emoji?: string;
@@ -10,31 +11,19 @@ export interface ZeppelinGuildConfig extends BaseConfig {
   date_formats?: any;
 }
 
-export const ZeppelinGuildConfigSchema = t.type({
+export const zZeppelinGuildConfig = z.strictObject({
   // From BaseConfig
-  prefix: t.string,
-  levels: t.record(t.string, t.number),
-  plugins: t.record(t.string, t.unknown),
+  prefix: z.string().optional(),
+  levels: z.record(zSnowflake, z.number()).optional(),
+  plugins: z.record(z.string(), z.unknown()).optional(),
 
   // From ZeppelinGuildConfig
-  success_emoji: t.string,
-  error_emoji: t.string,
+  success_emoji: z.string().optional(),
+  error_emoji: z.string().optional(),
 
   // Deprecated
-  timezone: t.string,
-  date_formats: t.unknown,
-});
-export const PartialZeppelinGuildConfigSchema = t.partial(ZeppelinGuildConfigSchema.props);
-
-export interface ZeppelinGlobalConfig extends BaseConfig {
-  url: string;
-  owners?: string[];
-}
-
-export const ZeppelinGlobalConfigSchema = t.type({
-  url: t.string,
-  owners: t.array(t.string),
-  plugins: t.record(t.string, t.unknown),
+  timezone: z.string().optional(),
+  date_formats: z.unknown().optional(),
 });
 
 export type TZeppelinKnub = Knub;
diff --git a/backend/src/utils.test.ts b/backend/src/utils.test.ts
index f5c9f935..461be47b 100644
--- a/backend/src/utils.test.ts
+++ b/backend/src/utils.test.ts
@@ -1,6 +1,6 @@
 import test from "ava";
-import * as ioTs from "io-ts";
-import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, tAllowedMentions } from "./utils";
+import z from "zod";
+import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, zAllowedMentions } from "./utils";
 import { ErisAllowedMentionFormat } from "./utils/erisAllowedMentionsToDjsMentionOptions";
 
 type AssertEquals<TActual, TExpected> = TActual extends TExpected ? true : false;
@@ -50,7 +50,7 @@ test("delay strings: reverse conversion (conservative)", (t) => {
 });
 
 test("tAllowedMentions matches Eris's AllowedMentions", (t) => {
-  type TAllowedMentions = ioTs.TypeOf<typeof tAllowedMentions>;
+  type TAllowedMentions = z.infer<typeof zAllowedMentions>;
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const typeTest: AssertEquals<TAllowedMentions, ErisAllowedMentionFormat> = true;
   t.pass();
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 1bb4d6ce..641a2573 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -21,31 +21,26 @@ import {
   PartialChannelData,
   PartialMessage,
   RoleResolvable,
-  Snowflake,
   Sticker,
   TextBasedChannel,
   User,
 } from "discord.js";
 import emojiRegex from "emoji-regex";
-import { either } from "fp-ts/lib/Either";
-import { unsafeCoerce } from "fp-ts/lib/function";
 import fs from "fs";
 import https from "https";
 import humanizeDuration from "humanize-duration";
-import * as t from "io-ts";
 import { isEqual } from "lodash";
-import moment from "moment-timezone";
 import { performance } from "perf_hooks";
 import tlds from "tlds";
 import tmp from "tmp";
 import { URL } from "url";
-import { z, ZodError } from "zod";
+import { z, ZodEffects, ZodError, ZodRecord, ZodString } from "zod";
 import { ISavedMessageAttachmentData, SavedMessage } from "./data/entities/SavedMessage";
 import { getProfiler } from "./profiler";
 import { SimpleCache } from "./SimpleCache";
 import { sendDM } from "./utils/sendDM";
+import { Brand } from "./utils/typeUtils";
 import { waitForButtonConfirm } from "./utils/waitForInteraction";
-import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
 
 const fsp = fs.promises;
 
@@ -91,71 +86,9 @@ export function isDiscordAPIError(err: Error | string): err is DiscordAPIError {
   return err instanceof DiscordAPIError;
 }
 
-export function tNullable<T extends t.Type<any, any>>(type: T) {
-  return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`);
-}
-
-export const tNormalizedNullOrUndefined = new t.Type<undefined, null | undefined>(
-  "tNormalizedNullOrUndefined",
-  (v): v is undefined => typeof v === "undefined",
-  (v, c) => (v == null ? t.success(undefined) : t.failure(v, c, "Value must be null or undefined")),
-  () => undefined,
-);
-
-/**
- * Similar to `tNullable`, but normalizes both `null` and `undefined` to `undefined`.
- * This allows adding optional config options that can be "removed" by setting the value to `null`.
- */
-export function tNormalizedNullOptional<T extends t.Type<any, any>>(type: T) {
-  return t.union(
-    [type, tNormalizedNullOrUndefined],
-    `Optional<${type.name}>`, // Simplified name for errors and config schema views
-  );
-}
-
-export type TDeepPartial<T> = T extends t.InterfaceType<any>
-  ? TDeepPartialProps<T["props"]>
-  : T extends t.DictionaryType<any, any>
-  ? t.DictionaryType<T["domain"], TDeepPartial<T["codomain"]>>
-  : T extends t.UnionType<any[]>
-  ? t.UnionType<Array<TDeepPartial<T["types"][number]>>>
-  : T extends t.IntersectionType<any>
-  ? t.IntersectionType<Array<TDeepPartial<T["types"][number]>>>
-  : T extends t.ArrayType<any>
-  ? t.ArrayType<TDeepPartial<T["type"]>>
-  : T;
-
-// Based on t.PartialC
-export interface TDeepPartialProps<P extends t.Props>
-  extends t.PartialType<
-    P,
-    {
-      [K in keyof P]?: TDeepPartial<t.TypeOf<P[K]>>;
-    },
-    {
-      [K in keyof P]?: TDeepPartial<t.OutputOf<P[K]>>;
-    }
-  > {}
-
-export function tDeepPartial<T>(type: T): TDeepPartial<T> {
-  if (type instanceof t.InterfaceType || type instanceof t.PartialType) {
-    const newProps = {};
-    for (const [key, prop] of Object.entries(type.props)) {
-      newProps[key] = tDeepPartial(prop);
-    }
-    return t.partial(newProps) as TDeepPartial<T>;
-  } else if (type instanceof t.DictionaryType) {
-    return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial<T>;
-  } else if (type instanceof t.UnionType) {
-    return t.union(type.types.map((unionType) => tDeepPartial(unionType))) as TDeepPartial<T>;
-  } else if (type instanceof t.IntersectionType) {
-    const types = type.types.map((intersectionType) => tDeepPartial(intersectionType));
-    return t.intersection(types as [t.Mixed, t.Mixed]) as unknown as TDeepPartial<T>;
-  } else if (type instanceof t.ArrayType) {
-    return t.array(tDeepPartial(type.type)) as TDeepPartial<T>;
-  } else {
-    return type as TDeepPartial<T>;
-  }
+// null | undefined -> undefined
+export function zNullishToUndefined<T extends z.ZodTypeAny>(type: T): ZodEffects<T, NonNullable<z.output<T>> | undefined> {
+  return type.transform(v => v ?? undefined);
 }
 
 export function getScalarDifference<T extends object>(
@@ -207,29 +140,6 @@ export function differenceToString(diff: Map<string, { was: any; is: any }>): st
 // https://stackoverflow.com/a/49262929/316944
 export type Not<T, E> = T & Exclude<T, E>;
 
-// io-ts partial dictionary type
-// From https://github.com/gcanti/io-ts/issues/429#issuecomment-655394345
-export interface PartialDictionaryC<D extends t.Mixed, C extends t.Mixed>
-  extends t.DictionaryType<
-    D,
-    C,
-    {
-      [K in t.TypeOf<D>]?: t.TypeOf<C>;
-    },
-    {
-      [K in t.OutputOf<D>]?: t.OutputOf<C>;
-    },
-    unknown
-  > {}
-
-export const tPartialDictionary = <D extends t.Mixed, C extends t.Mixed>(
-  domain: D,
-  codomain: C,
-  name?: string,
-): PartialDictionaryC<D, C> => {
-  return unsafeCoerce(t.record(t.union([domain, t.undefined]), codomain, name));
-};
-
 export function nonNullish<V>(v: V): v is NonNullable<V> {
   return v != null;
 }
@@ -240,70 +150,60 @@ export type GroupDMInvite = Invite & {
   type: typeof ChannelType.GroupDM;
 };
 
-/**
- * Mirrors EmbedOptions from Eris
- */
-export const tEmbed = t.type({
-  title: tNullable(t.string),
-  description: tNullable(t.string),
-  url: tNullable(t.string),
-  timestamp: tNullable(t.string),
-  color: tNullable(t.number),
-  footer: tNullable(
-    t.type({
-      text: t.string,
-      icon_url: tNullable(t.string),
-      proxy_icon_url: tNullable(t.string),
-    }),
-  ),
-  image: tNullable(
-    t.type({
-      url: tNullable(t.string),
-      proxy_url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
-  thumbnail: tNullable(
-    t.type({
-      url: tNullable(t.string),
-      proxy_url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
-  video: tNullable(
-    t.type({
-      url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
-  provider: tNullable(
-    t.type({
-      name: t.string,
-      url: tNullable(t.string),
-    }),
-  ),
-  fields: tNullable(
-    t.array(
-      t.type({
-        name: tNullable(t.string),
-        value: tNullable(t.string),
-        inline: tNullable(t.boolean),
-      }),
-    ),
-  ),
-  author: tNullable(
-    t.type({
-      name: t.string,
-      url: tNullable(t.string),
-      width: tNullable(t.number),
-      height: tNullable(t.number),
-    }),
-  ),
+function isBoundedString(str: unknown, min: number, max: number): str is string {
+  if (typeof str !== "string") {
+    return false;
+  }
+  return (str.length >= min && str.length <= max);
+}
+
+export function zBoundedCharacters(min: number, max: number) {
+  return z.string().refine(str => {
+    const len = [...str].length; // Unicode aware character split
+    return (len >= min && len <= max);
+  }, {
+    message: `String must be between ${min} and ${max} characters long`,
+  });
+}
+
+export const zSnowflake = z.string().refine(str => isSnowflake(str), {
+  message: "Invalid snowflake ID",
 });
 
+const regexWithFlags = /^\/(.*?)\/([i]*)$/;
+
+export class InvalidRegexError extends Error {}
+
+/**
+ * This function supports two input syntaxes for regexes: /<pattern>/<flags> and just <pattern>
+ */
+export function inputPatternToRegExp(pattern: string) {
+  const advancedSyntaxMatch = pattern.match(regexWithFlags);
+  const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""];
+  try {
+    return new RegExp(finalPattern, flags);
+  } catch (e) {
+    throw new InvalidRegexError(e.message);
+  }
+}
+
+export function zRegex<T extends ZodString>(zStr: T) {
+  return zStr.transform((str, ctx) => {
+    try {
+      return inputPatternToRegExp(str);
+    } catch (err) {
+      if (err instanceof InvalidRegexError) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Invalid regex"
+        });
+        return z.NEVER;
+      }
+      throw err;
+    }
+  });
+}
+
 export const zEmbedInput = z.object({
   title: z.string().optional(),
   description: z.string().optional(),
@@ -387,15 +287,7 @@ export type StrictMessageContent = {
   embeds?: APIEmbed[];
 };
 
-export const tStrictMessageContent = t.type({
-  content: tNullable(t.string),
-  tts: tNullable(t.boolean),
-  disableEveryone: tNullable(t.boolean),
-  embed: tNullable(tEmbed),
-  embeds: tNullable(t.array(tEmbed)),
-});
-
-export const tMessageContent = t.union([t.string, tStrictMessageContent]);
+export const zMessageContent = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]);
 
 export function validateAndParseMessageContent(input: unknown): StrictMessageContent {
   if (input == null) {
@@ -454,11 +346,11 @@ function dropNullValuesRecursively(obj: any) {
 /**
  * Mirrors AllowedMentions from Eris
  */
-export const tAllowedMentions = t.type({
-  everyone: tNormalizedNullOptional(t.boolean),
-  users: tNormalizedNullOptional(t.union([t.boolean, t.array(t.string)])),
-  roles: tNormalizedNullOptional(t.union([t.boolean, t.array(t.string)])),
-  repliedUser: tNormalizedNullOptional(t.boolean),
+export const zAllowedMentions = z.strictObject({
+  everyone: zNullishToUndefined(z.boolean().nullable().optional()),
+  users: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()),
+  roles: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()),
+  replied_user: zNullishToUndefined(z.boolean().nullable().optional()),
 });
 
 export function dropPropertiesByName(obj, propName) {
@@ -472,39 +364,18 @@ export function dropPropertiesByName(obj, propName) {
   }
 }
 
-export const tAlphanumeric = new t.Type<string, string>(
-  "tAlphanumeric",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s);
-    }),
-  (s) => s,
-);
+export function zBoundedRecord<TRecord extends ZodRecord<any, any>>(record: TRecord, minKeys: number, maxKeys: number): ZodEffects<TRecord> {
+  return record.refine(data => {
+    const len = Object.keys(data).length;
+    return (len >= minKeys && len <= maxKeys);
+  }, {
+    message: `Object must have ${minKeys}-${maxKeys} keys`,
+  });
+}
 
-export const tDateTime = new t.Type<string, string>(
-  "tDateTime",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      const parsed =
-        s.length === 10 ? moment.utc(s, "YYYY-MM-DD") : s.length === 19 ? moment.utc(s, "YYYY-MM-DD HH:mm:ss") : null;
-
-      return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime");
-    }),
-  (s) => s,
-);
-
-export const tDelayString = new t.Type<string, string>(
-  "tDelayString",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      const ms = convertDelayStringToMS(s);
-      return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s);
-    }),
-  (s) => s,
-);
+export const zDelayString = z.string().max(32).refine(str => convertDelayStringToMS(str) !== null, {
+  message: "Invalid delay string",
+});
 
 // To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that.
 // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#The_ECMAScript_epoch_and_timestamps
@@ -609,9 +480,11 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) {
 
 export const snowflakeRegex = /[1-9][0-9]{5,19}/;
 
+export type Snowflake = Brand<string, "Snowflake">;
+
 const isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`);
-export function isSnowflake(v: string): boolean {
-  return isSnowflakeRegex.test(v);
+export function isSnowflake(v: unknown): v is Snowflake {
+  return typeof v === "string" && isSnowflakeRegex.test(v);
 }
 
 export function sleep(ms: number): Promise<void> {
@@ -1474,8 +1347,7 @@ export function messageLink(guildIdOrMessage: string | Message | null, channelId
 }
 
 export function isValidEmbed(embed: any): boolean {
-  const result = decodeAndValidateStrict(tEmbed, embed);
-  return !(result instanceof StrictValidationError);
+  return zEmbedInput.safeParse(embed).success;
 }
 
 const formatter = new Intl.NumberFormat("en-US");
@@ -1613,3 +1485,19 @@ export function renderUsername(username: string, discriminator: string): string
 export function renderUserUsername(user: User | UnknownUser): string {
   return renderUsername(user.username, user.discriminator);
 }
+
+type Entries<T> = Array<{
+  [Key in keyof T]-?: [Key, T[Key]];
+}[keyof T]>;
+
+export function entries<T extends object>(object: T) {
+  return Object.entries(object) as Entries<T>;
+}
+
+export function keys<T extends object>(object: T) {
+  return Object.keys(object) as Array<keyof T>;
+}
+
+export function values<T extends object>(object: T) {
+  return Object.values(object) as Array<T[keyof T]>;
+}
diff --git a/backend/src/utils/iotsUtils.ts b/backend/src/utils/iotsUtils.ts
deleted file mode 100644
index a3636c84..00000000
--- a/backend/src/utils/iotsUtils.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as t from "io-ts";
-
-interface BoundedStringBrand {
-  readonly BoundedString: unique symbol;
-}
-
-export function asBoundedString(str: string) {
-  return str as t.Branded<string, BoundedStringBrand>;
-}
-
-export function tBoundedString(min: number, max: number) {
-  return t.brand(
-    t.string,
-    (str): str is t.Branded<string, BoundedStringBrand> => (str.length >= min && str.length <= max),
-    "BoundedString",
-  );
-}
diff --git a/backend/src/utils/tColor.ts b/backend/src/utils/tColor.ts
deleted file mode 100644
index f240f7f4..00000000
--- a/backend/src/utils/tColor.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { either } from "fp-ts/lib/Either";
-import * as t from "io-ts";
-import { intToRgb } from "./intToRgb";
-import { parseColor } from "./parseColor";
-import { rgbToInt } from "./rgbToInt";
-
-export const tColor = new t.Type<number, string>(
-  "tColor",
-  (s): s is number => typeof s === "number",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (input) => {
-      const parsedColor = parseColor(input);
-      return parsedColor == null ? t.failure(from, to, "Invalid color") : t.success(rgbToInt(parsedColor));
-    }),
-  (s) => intToRgb(s).join(","),
-);
diff --git a/backend/src/utils/tValidTimezone.ts b/backend/src/utils/tValidTimezone.ts
deleted file mode 100644
index 35fc97c5..00000000
--- a/backend/src/utils/tValidTimezone.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { either } from "fp-ts/lib/Either";
-import * as t from "io-ts";
-import { isValidTimezone } from "./isValidTimezone";
-
-export const tValidTimezone = new t.Type<string, string>(
-  "tValidTimezone",
-  (s): s is string => typeof s === "string",
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (input) => {
-      return isValidTimezone(input) ? t.success(input) : t.failure(from, to, `Invalid timezone: ${input}`);
-    }),
-  (s) => s,
-);
diff --git a/backend/src/utils/typeUtils.ts b/backend/src/utils/typeUtils.ts
index 7c8b4cbd..98301581 100644
--- a/backend/src/utils/typeUtils.ts
+++ b/backend/src/utils/typeUtils.ts
@@ -14,3 +14,11 @@ export type Awaitable<T = unknown> = T | Promise<T>;
 export type DeepMutable<T> = {
   -readonly [P in keyof T]: DeepMutable<T[P]>;
 };
+
+// From https://stackoverflow.com/a/70262876/316944
+export declare abstract class As<Tag extends keyof never> {
+  private static readonly $as$: unique symbol;
+  private [As.$as$]: Record<Tag, true>;
+}
+
+export type Brand<T, B extends keyof never> = T & As<B>;
diff --git a/backend/src/utils/zColor.ts b/backend/src/utils/zColor.ts
new file mode 100644
index 00000000..1bc3ae92
--- /dev/null
+++ b/backend/src/utils/zColor.ts
@@ -0,0 +1,15 @@
+import z from "zod";
+import { parseColor } from "./parseColor";
+import { rgbToInt } from "./rgbToInt";
+
+export const zColor = z.string().transform((val, ctx) => {
+  const parsedColor = parseColor(val);
+  if (parsedColor == null) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Invalid color",
+    });
+    return z.NEVER;
+  }
+  return rgbToInt(parsedColor);
+});
diff --git a/backend/src/utils/zValidTimezone.ts b/backend/src/utils/zValidTimezone.ts
new file mode 100644
index 00000000..b83c47c1
--- /dev/null
+++ b/backend/src/utils/zValidTimezone.ts
@@ -0,0 +1,8 @@
+import { ZodString } from "zod";
+import { isValidTimezone } from "./isValidTimezone";
+
+export function zValidTimezone<Z extends ZodString>(z: Z) {
+  return z.refine((val) => isValidTimezone(val), {
+    message: "Invalid timezone",
+  });
+}
diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts
deleted file mode 100644
index d41d6c4a..00000000
--- a/backend/src/validation.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import test from "ava";
-import * as t from "io-ts";
-import { tDeepPartial } from "./utils";
-import * as validatorUtils from "./validatorUtils";
-
-test("tDeepPartial works", (ava) => {
-  const originalSchema = t.type({
-    listOfThings: t.record(
-      t.string,
-      t.type({
-        enabled: t.boolean,
-        someValue: t.number,
-      }),
-    ),
-  });
-
-  const deepPartialSchema = tDeepPartial(originalSchema);
-
-  const partialValidValue = {
-    listOfThings: {
-      myThing: {
-        someValue: 5,
-      },
-    },
-  };
-
-  const partialErrorValue = {
-    listOfThings: {
-      myThing: {
-        someValue: "test",
-      },
-    },
-  };
-
-  const result1 = validatorUtils.validate(deepPartialSchema, partialValidValue);
-  ava.is(result1, null);
-
-  const result2 = validatorUtils.validate(deepPartialSchema, partialErrorValue);
-  ava.not(result2, null);
-});
diff --git a/backend/src/validatorUtils.ts b/backend/src/validatorUtils.ts
deleted file mode 100644
index 31139596..00000000
--- a/backend/src/validatorUtils.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import deepDiff from "deep-diff";
-import { either, fold, isLeft } from "fp-ts/lib/Either";
-import { pipe } from "fp-ts/lib/pipeable";
-import * as t from "io-ts";
-import { noop } from "./utils";
-
-const regexWithFlags = /^\/(.*?)\/([i]*)$/;
-
-export class InvalidRegexError extends Error {}
-
-/**
- * This function supports two input syntaxes for regexes: /<pattern>/<flags> and just <pattern>
- */
-export function inputPatternToRegExp(pattern: string) {
-  const advancedSyntaxMatch = pattern.match(regexWithFlags);
-  const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""];
-  try {
-    return new RegExp(finalPattern, flags);
-  } catch (e) {
-    throw new InvalidRegexError(e.message);
-  }
-}
-
-export const TRegex = new t.Type<RegExp, string>(
-  "TRegex",
-  (s): s is RegExp => s instanceof RegExp,
-  (from, to) =>
-    either.chain(t.string.validate(from, to), (s) => {
-      try {
-        return t.success(inputPatternToRegExp(s));
-      } catch (err) {
-        if (err instanceof InvalidRegexError) {
-          return t.failure(s, [], err.message);
-        }
-
-        throw err;
-      }
-    }),
-  (s) => `/${s.source}/${s.flags}`,
-);
-
-// From io-ts/lib/PathReporter
-function stringify(v) {
-  if (typeof v === "function") {
-    return t.getFunctionName(v);
-  }
-  if (typeof v === "number" && !isFinite(v)) {
-    if (isNaN(v)) {
-      return "NaN";
-    }
-    return v > 0 ? "Infinity" : "-Infinity";
-  }
-  return JSON.stringify(v);
-}
-
-export class StrictValidationError extends Error {
-  private readonly errors;
-
-  constructor(errors: string[]) {
-    errors = Array.from(new Set(errors));
-    super(errors.join("\n"));
-    this.errors = errors;
-  }
-  getErrors() {
-    return this.errors;
-  }
-}
-
-const report = fold((errors: any): StrictValidationError | void => {
-  const errorStrings = errors.map((err) => {
-    const context = err.context.map((c) => c.key).filter((k) => k && !k.startsWith("{"));
-    while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1);
-
-    const value = stringify(err.value);
-    return value === undefined
-      ? `<${context.join("/")}> is required`
-      : `Invalid value supplied to <${context.join("/")}>${err.message ? `: ${err.message}` : ""}`;
-  });
-
-  return new StrictValidationError(errorStrings);
-}, noop);
-
-export function validate(schema: t.Type<any>, value: any): StrictValidationError | null {
-  const validationResult = schema.decode(value);
-  return (
-    pipe(
-      validationResult,
-      fold(
-        () => report(validationResult),
-        () => null,
-      ),
-    ) || null
-  );
-}
-
-export function parseIoTsSchema<T extends t.Type<any>>(schema: T, value: unknown): t.TypeOf<T> {
-  const decodeResult = schema.decode(value);
-  if (isLeft(decodeResult)) {
-    throw report(decodeResult);
-  }
-  return decodeResult.right;
-}
-
-/**
- * Decodes and validates the given value against the given schema while also disallowing extra properties
- * See: https://github.com/gcanti/io-ts/issues/322
- */
-export function decodeAndValidateStrict<T extends t.HasProps>(
-  schema: T,
-  value: any,
-  debug = false,
-): StrictValidationError | any {
-  const validationResult = t.exact(schema).decode(value);
-  return pipe(
-    validationResult,
-    fold(
-      () => report(validationResult),
-      (result) => {
-        // Make sure there are no extra properties
-        if (debug) {
-          // tslint:disable-next-line:no-console
-          console.log(
-            "JSON.stringify() check:",
-            JSON.stringify(value) === JSON.stringify(result)
-              ? "they are the same, no excess"
-              : "they are not the same, might have excess",
-            result,
-          );
-        }
-        if (JSON.stringify(value) !== JSON.stringify(result)) {
-          const diff = deepDiff(result, value);
-          const errors = diff.filter((d) => d.kind === "N").map((d) => `Unknown property <${d.path.join(".")}>`);
-          if (errors.length) return new StrictValidationError(errors);
-        }
-
-        return result;
-      },
-    ),
-  );
-}
diff --git a/package-lock.json b/package-lock.json
index 6fe63dc2..d853c8db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
         "lint-staged": "^9.4.2",
         "prettier": "^2.8.4",
         "prettier-plugin-organize-imports": "^3.2.2",
+        "ts-toolbelt": "^9.6.0",
         "tsc-watch": "^6.0.4",
         "typescript": "^5.0.4"
       }
@@ -3483,6 +3484,12 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/ts-toolbelt": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz",
+      "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==",
+      "dev": true
+    },
     "node_modules/tsc-watch": {
       "version": "6.0.4",
       "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-6.0.4.tgz",
diff --git a/package.json b/package.json
index 0cec2093..0309d59c 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
     "lint-staged": "^9.4.2",
     "prettier": "^2.8.4",
     "prettier-plugin-organize-imports": "^3.2.2",
+    "ts-toolbelt": "^9.6.0",
     "tsc-watch": "^6.0.4",
     "typescript": "^5.0.4"
   },

From 3db90907058b4a14ef52fcf8a862b93b66b4e819 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 18:03:45 +0000
Subject: [PATCH 26/51] fix: circular dependency in automod types

---
 backend/src/plugins/Automod/actions/ban.ts  | 2 +-
 backend/src/plugins/Automod/actions/kick.ts | 2 +-
 backend/src/plugins/Automod/actions/mute.ts | 2 +-
 backend/src/plugins/Automod/actions/warn.ts | 2 +-
 backend/src/plugins/Automod/constants.ts    | 6 ++++++
 backend/src/plugins/Automod/types.ts        | 5 -----
 6 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts
index dcc9563f..22cc8167 100644
--- a/backend/src/plugins/Automod/actions/ban.ts
+++ b/backend/src/plugins/Automod/actions/ban.ts
@@ -4,7 +4,7 @@ import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../types";
+import { zNotify } from "../constants";
 
 const configSchema = z.strictObject({
   reason: zBoundedCharacters(0, 4000).nullable().default(null),
diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts
index 5afba467..412309cd 100644
--- a/backend/src/plugins/Automod/actions/kick.ts
+++ b/backend/src/plugins/Automod/actions/kick.ts
@@ -4,7 +4,7 @@ import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../types";
+import { zNotify } from "../constants";
 
 export const KickAction = automodAction({
   configSchema: z.strictObject({
diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts
index 4a2a42ad..c2f8e186 100644
--- a/backend/src/plugins/Automod/actions/mute.ts
+++ b/backend/src/plugins/Automod/actions/mute.ts
@@ -6,7 +6,7 @@ import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../types";
+import { zNotify } from "../constants";
 
 export const MuteAction = automodAction({
   configSchema: z.strictObject({
diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts
index e8c63340..7ccb3898 100644
--- a/backend/src/plugins/Automod/actions/warn.ts
+++ b/backend/src/plugins/Automod/actions/warn.ts
@@ -4,7 +4,7 @@ import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../types";
+import { zNotify } from "../constants";
 
 export const WarnAction = automodAction({
   configSchema: z.strictObject({
diff --git a/backend/src/plugins/Automod/constants.ts b/backend/src/plugins/Automod/constants.ts
index 53ff75ad..cc37f402 100644
--- a/backend/src/plugins/Automod/constants.ts
+++ b/backend/src/plugins/Automod/constants.ts
@@ -1,3 +1,4 @@
+import z from "zod";
 import { MINUTES, SECONDS } from "../../utils";
 
 export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS;
@@ -18,3 +19,8 @@ export enum RecentActionType {
   MemberLeave,
   ThreadCreate,
 }
+
+export const zNotify = z.union([
+  z.literal("dm"),
+  z.literal("channel"),
+]);
diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts
index 59a09a33..251ef643 100644
--- a/backend/src/plugins/Automod/types.ts
+++ b/backend/src/plugins/Automod/types.ts
@@ -65,11 +65,6 @@ const zRule = z.strictObject({
 });
 export type TRule = z.infer<typeof zRule>;
 
-export const zNotify = z.union([
-  z.literal("dm"),
-  z.literal("channel"),
-]);
-
 export const zAutomodConfig = z.strictObject({
   rules: zBoundedRecord(
     z.record(z.string().max(100), zRule),

From ac8926cdb80a52874ba71ad8abb1e398db3d6966 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 18:05:01 +0000
Subject: [PATCH 27/51] chore: fix inconsistent import paths

---
 backend/src/data/GuildArchives.ts                        | 2 +-
 backend/src/plugins/Automod/actions/alert.ts             | 2 +-
 backend/src/plugins/ContextMenus/actions/mute.ts         | 4 ++--
 backend/src/plugins/Logs/logFunctions/logCensor.ts       | 2 +-
 backend/src/plugins/ModActions/functions/clearTempban.ts | 4 ++--
 backend/src/plugins/Tags/docs.ts                         | 2 +-
 backend/src/plugins/Tags/util/onMessageCreate.ts         | 2 +-
 backend/src/utils/templateSafeObjects.ts                 | 2 +-
 8 files changed, 10 insertions(+), 10 deletions(-)

diff --git a/backend/src/data/GuildArchives.ts b/backend/src/data/GuildArchives.ts
index de1e9d07..0451dfe0 100644
--- a/backend/src/data/GuildArchives.ts
+++ b/backend/src/data/GuildArchives.ts
@@ -1,6 +1,5 @@
 import { Guild, Snowflake } from "discord.js";
 import moment from "moment-timezone";
-import { isDefaultSticker } from "src/utils/isDefaultSticker";
 import { Repository } from "typeorm";
 import { TemplateSafeValueContainer, renderTemplate } from "../templateFormatter";
 import { renderUsername, trimLines } from "../utils";
@@ -10,6 +9,7 @@ import { BaseGuildRepository } from "./BaseGuildRepository";
 import { dataSource } from "./dataSource";
 import { ArchiveEntry } from "./entities/ArchiveEntry";
 import { SavedMessage } from "./entities/SavedMessage";
+import { isDefaultSticker } from "../utils/isDefaultSticker";
 
 const DEFAULT_EXPIRY_DAYS = 30;
 
diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts
index 1a767a0e..c5fcd4b3 100644
--- a/backend/src/plugins/Automod/actions/alert.ts
+++ b/backend/src/plugins/Automod/actions/alert.ts
@@ -1,5 +1,4 @@
 import { Snowflake } from "discord.js";
-import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
 import z from "zod";
 import { LogType } from "../../../data/LogType";
 import {
@@ -19,6 +18,7 @@ import {
   zNullishToUndefined,
   zSnowflake
 } from "../../../utils";
+import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
 import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin";
diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts
index 556ee248..c6eedbc6 100644
--- a/backend/src/plugins/ContextMenus/actions/mute.ts
+++ b/backend/src/plugins/ContextMenus/actions/mute.ts
@@ -1,14 +1,14 @@
 import { ContextMenuCommandInteraction } from "discord.js";
 import humanizeDuration from "humanize-duration";
 import { GuildPluginData } from "knub";
-import { canActOn } from "src/pluginUtils";
-import { ModActionsPlugin } from "src/plugins/ModActions/ModActionsPlugin";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
 import { convertDelayStringToMS } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
 import { ContextMenuPluginType } from "../types";
+import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
+import { canActOn } from "../../../pluginUtils";
 
 export async function muteAction(
   pluginData: GuildPluginData<ContextMenuPluginType>,
diff --git a/backend/src/plugins/Logs/logFunctions/logCensor.ts b/backend/src/plugins/Logs/logFunctions/logCensor.ts
index cec6e441..c1853baf 100644
--- a/backend/src/plugins/Logs/logFunctions/logCensor.ts
+++ b/backend/src/plugins/Logs/logFunctions/logCensor.ts
@@ -1,11 +1,11 @@
 import { GuildTextBasedChannel, User } from "discord.js";
 import { GuildPluginData } from "knub";
 import { deactivateMentions, disableCodeBlocks } from "knub/helpers";
-import { resolveChannelIds } from "src/utils/resolveChannelIds";
 import { LogType } from "../../../data/LogType";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
 import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter";
 import { UnknownUser } from "../../../utils";
+import { resolveChannelIds } from "../../../utils/resolveChannelIds";
 import {
   channelToTemplateSafeChannel,
   savedMessageToTemplateSafeSavedMessage,
diff --git a/backend/src/plugins/ModActions/functions/clearTempban.ts b/backend/src/plugins/ModActions/functions/clearTempban.ts
index 25807df8..5173ca7e 100644
--- a/backend/src/plugins/ModActions/functions/clearTempban.ts
+++ b/backend/src/plugins/ModActions/functions/clearTempban.ts
@@ -2,8 +2,6 @@ import { Snowflake } from "discord.js";
 import humanizeDuration from "humanize-duration";
 import { GuildPluginData } from "knub";
 import moment from "moment-timezone";
-import { LogType } from "src/data/LogType";
-import { logger } from "src/logger";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { Tempban } from "../../../data/entities/Tempban";
 import { resolveUser } from "../../../utils";
@@ -13,6 +11,8 @@ import { IgnoredEventType, ModActionsPluginType } from "../types";
 import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
 import { ignoreEvent } from "./ignoreEvent";
 import { isBanned } from "./isBanned";
+import { LogType } from "../../../data/LogType";
+import { logger } from "../../../logger";
 
 export async function clearTempban(pluginData: GuildPluginData<ModActionsPluginType>, tempban: Tempban) {
   if (!(await isBanned(pluginData, tempban.user_id))) {
diff --git a/backend/src/plugins/Tags/docs.ts b/backend/src/plugins/Tags/docs.ts
index bc1608bb..61f4dfcb 100644
--- a/backend/src/plugins/Tags/docs.ts
+++ b/backend/src/plugins/Tags/docs.ts
@@ -1,4 +1,4 @@
-import { trimPluginDescription } from "src/utils";
+import { trimPluginDescription } from "../../utils";
 import { TemplateFunction } from "./types";
 
 export function generateTemplateMarkdown(definitions: TemplateFunction[]): string {
diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts
index f5b0b04a..aa7c8490 100644
--- a/backend/src/plugins/Tags/util/onMessageCreate.ts
+++ b/backend/src/plugins/Tags/util/onMessageCreate.ts
@@ -1,12 +1,12 @@
 import { Snowflake, TextChannel } from "discord.js";
 import { GuildPluginData } from "knub";
-import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
 import { convertDelayStringToMS, resolveMember, zStrictMessageContent } from "../../../utils";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TagsPluginType } from "../types";
 import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString";
+import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions";
 
 export async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType>, msg: SavedMessage) {
   if (msg.is_bot) return;
diff --git a/backend/src/utils/templateSafeObjects.ts b/backend/src/utils/templateSafeObjects.ts
index 67517f19..3395cd6f 100644
--- a/backend/src/utils/templateSafeObjects.ts
+++ b/backend/src/utils/templateSafeObjects.ts
@@ -13,7 +13,6 @@ import {
   User,
 } from "discord.js";
 import { GuildPluginData } from "knub";
-import { UnknownUser, renderUserUsername } from "src/utils";
 import { Case } from "../data/entities/Case";
 import {
   ISavedMessageAttachmentData,
@@ -27,6 +26,7 @@ import {
   TypedTemplateSafeValueContainer,
   ingestDataIntoTemplateSafeValueContainer,
 } from "../templateFormatter";
+import { UnknownUser, renderUserUsername } from "../utils";
 
 type InputProps<T> = Omit<
   {

From cbec80981daacd5e03ba88ac7c6e2357ff8fcffb Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:36:10 +0000
Subject: [PATCH 28/51] feat: improve ZodIssue rendering in config validation

---
 backend/src/configValidator.ts      | 10 +++++++---
 backend/src/utils/formatZodIssue.ts |  6 ++++++
 2 files changed, 13 insertions(+), 3 deletions(-)
 create mode 100644 backend/src/utils/formatZodIssue.ts

diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts
index 96e08ba3..eb115d46 100644
--- a/backend/src/configValidator.ts
+++ b/backend/src/configValidator.ts
@@ -1,9 +1,10 @@
-import { PluginConfigManager } from "knub";
+import { ConfigValidationError, PluginConfigManager } from "knub";
 import moment from "moment-timezone";
 import { ZodError } from "zod";
 import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin";
 import { guildPlugins } from "./plugins/availablePlugins";
 import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types";
+import { formatZodIssue } from "./utils/formatZodIssue";
 
 const pluginNameToPlugin = new Map<string, ZeppelinPlugin>();
 for (const plugin of guildPlugins) {
@@ -13,7 +14,7 @@ for (const plugin of guildPlugins) {
 export async function validateGuildConfig(config: any): Promise<string | null> {
   const validationResult = zZeppelinGuildConfig.safeParse(config);
   if (!validationResult.success) {
-    return validationResult.error.issues.join("\n");
+    return validationResult.error.issues.map(formatZodIssue).join("\n");
   }
 
   const guildConfig = config as ZeppelinGuildConfig;
@@ -44,7 +45,10 @@ export async function validateGuildConfig(config: any): Promise<string | null> {
         await configManager.init();
       } catch (err) {
         if (err instanceof ZodError) {
-          return `${pluginName}: ${err.issues.join("\n")}`;
+          return `${pluginName}: ${err.issues.map(formatZodIssue).join("\n")}`;
+        }
+        if (err instanceof ConfigValidationError) {
+          return `${pluginName}: ${err.message}`;
         }
 
         throw err;
diff --git a/backend/src/utils/formatZodIssue.ts b/backend/src/utils/formatZodIssue.ts
new file mode 100644
index 00000000..2b5c3d8e
--- /dev/null
+++ b/backend/src/utils/formatZodIssue.ts
@@ -0,0 +1,6 @@
+import { ZodIssue } from "zod";
+
+export function formatZodIssue(issue: ZodIssue): string {
+    const path = issue.path.join("/");
+    return `${path}: ${issue.message}`;
+}

From 82d720d308a2998b731354200b384c317244a30e Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:37:39 +0000
Subject: [PATCH 29/51] refactor: change LogType to a plain object instead of
 an enum

---
 backend/src/data/Configs.ts                   |   6 +
 backend/src/data/GuildLogs.ts                 |  10 +-
 backend/src/data/LogType.ts                   | 176 ++++++++----------
 backend/src/plugins/Logs/types.ts             |  19 +-
 backend/src/plugins/Logs/util/isLogIgnored.ts |   2 +-
 5 files changed, 97 insertions(+), 116 deletions(-)

diff --git a/backend/src/data/Configs.ts b/backend/src/data/Configs.ts
index 1b9ade22..bc702b5e 100644
--- a/backend/src/data/Configs.ts
+++ b/backend/src/data/Configs.ts
@@ -27,6 +27,12 @@ export class Configs extends BaseRepository {
     this.configs = dataSource.getRepository(Config);
   }
 
+  getActive() {
+    return this.configs.find({
+      where: { is_active: true },
+    });
+  }
+
   getActiveByKey(key) {
     return this.configs.findOne({
       where: {
diff --git a/backend/src/data/GuildLogs.ts b/backend/src/data/GuildLogs.ts
index b324984e..5f7dc4ed 100644
--- a/backend/src/data/GuildLogs.ts
+++ b/backend/src/data/GuildLogs.ts
@@ -5,7 +5,7 @@ import { LogType } from "./LogType";
 const guildInstances: Map<string, GuildLogs> = new Map();
 
 interface IIgnoredLog {
-  type: LogType;
+  type: keyof typeof LogType;
   ignoreId: any;
 }
 
@@ -27,7 +27,7 @@ export class GuildLogs extends events.EventEmitter {
     guildInstances.set(guildId, this);
   }
 
-  log(type: LogType, data: any, ignoreId?: string) {
+  log(type: keyof typeof LogType, data: any, ignoreId?: string) {
     if (ignoreId && this.isLogIgnored(type, ignoreId)) {
       this.clearIgnoredLog(type, ignoreId);
       return;
@@ -36,7 +36,7 @@ export class GuildLogs extends events.EventEmitter {
     this.emit("log", { type, data });
   }
 
-  ignoreLog(type: LogType, ignoreId: any, timeout?: number) {
+  ignoreLog(type: keyof typeof LogType, ignoreId: any, timeout?: number) {
     this.ignoredLogs.push({ type, ignoreId });
 
     // Clear after expiry (15sec by default)
@@ -45,11 +45,11 @@ export class GuildLogs extends events.EventEmitter {
     }, timeout || 1000 * 15);
   }
 
-  isLogIgnored(type: LogType, ignoreId: any) {
+  isLogIgnored(type: keyof typeof LogType, ignoreId: any) {
     return this.ignoredLogs.some((info) => type === info.type && ignoreId === info.ignoreId);
   }
 
-  clearIgnoredLog(type: LogType, ignoreId: any) {
+  clearIgnoredLog(type: keyof typeof LogType, ignoreId: any) {
     this.ignoredLogs.splice(
       this.ignoredLogs.findIndex((info) => type === info.type && ignoreId === info.ignoreId),
       1,
diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts
index 90c81173..1aba45ec 100644
--- a/backend/src/data/LogType.ts
+++ b/backend/src/data/LogType.ts
@@ -1,102 +1,74 @@
-export enum LogType {
-  MEMBER_WARN = 1,
-  MEMBER_MUTE,
-  MEMBER_UNMUTE,
-  MEMBER_MUTE_EXPIRED,
-  MEMBER_KICK,
-  MEMBER_BAN,
-  MEMBER_UNBAN,
-  MEMBER_FORCEBAN,
-  MEMBER_SOFTBAN,
-  MEMBER_JOIN,
-  MEMBER_LEAVE,
-  MEMBER_ROLE_ADD,
-  MEMBER_ROLE_REMOVE,
-  MEMBER_NICK_CHANGE,
-  MEMBER_USERNAME_CHANGE,
-  MEMBER_RESTORE,
-
-  CHANNEL_CREATE,
-  CHANNEL_DELETE,
-  CHANNEL_UPDATE,
-
-  THREAD_CREATE,
-  THREAD_DELETE,
-  THREAD_UPDATE,
-
-  ROLE_CREATE,
-  ROLE_DELETE,
-  ROLE_UPDATE,
-
-  MESSAGE_EDIT,
-  MESSAGE_DELETE,
-  MESSAGE_DELETE_BULK,
-  MESSAGE_DELETE_BARE,
-
-  VOICE_CHANNEL_JOIN,
-  VOICE_CHANNEL_LEAVE,
-  VOICE_CHANNEL_MOVE,
-
-  STAGE_INSTANCE_CREATE,
-  STAGE_INSTANCE_DELETE,
-  STAGE_INSTANCE_UPDATE,
-
-  EMOJI_CREATE,
-  EMOJI_DELETE,
-  EMOJI_UPDATE,
-
-  STICKER_CREATE,
-  STICKER_DELETE,
-  STICKER_UPDATE,
-
-  COMMAND,
-
-  MESSAGE_SPAM_DETECTED,
-  CENSOR,
-  CLEAN,
-
-  CASE_CREATE,
-
-  MASSUNBAN,
-  MASSBAN,
-  MASSMUTE,
-
-  MEMBER_TIMED_MUTE,
-  MEMBER_TIMED_UNMUTE,
-  MEMBER_TIMED_BAN,
-  MEMBER_TIMED_UNBAN,
-
-  MEMBER_JOIN_WITH_PRIOR_RECORDS,
-  OTHER_SPAM_DETECTED,
-
-  MEMBER_ROLE_CHANGES,
-  VOICE_CHANNEL_FORCE_MOVE,
-  VOICE_CHANNEL_FORCE_DISCONNECT,
-
-  CASE_UPDATE,
-
-  MEMBER_MUTE_REJOIN,
-
-  SCHEDULED_MESSAGE,
-  POSTED_SCHEDULED_MESSAGE,
-
-  BOT_ALERT,
-  AUTOMOD_ACTION,
-
-  SCHEDULED_REPEATED_MESSAGE,
-  REPEATED_MESSAGE,
-
-  MESSAGE_DELETE_AUTO,
-
-  SET_ANTIRAID_USER,
-  SET_ANTIRAID_AUTO,
-
-  MASS_ASSIGN_ROLES,
-  MASS_UNASSIGN_ROLES,
-
-  MEMBER_NOTE,
-
-  CASE_DELETE,
-
-  DM_FAILED,
-}
+export const LogType = {
+  MEMBER_WARN: "MEMBER_WARN",
+  MEMBER_MUTE: "MEMBER_MUTE",
+  MEMBER_UNMUTE: "MEMBER_UNMUTE",
+  MEMBER_MUTE_EXPIRED: "MEMBER_MUTE_EXPIRED",
+  MEMBER_KICK: "MEMBER_KICK",
+  MEMBER_BAN: "MEMBER_BAN",
+  MEMBER_UNBAN: "MEMBER_UNBAN",
+  MEMBER_FORCEBAN: "MEMBER_FORCEBAN",
+  MEMBER_SOFTBAN: "MEMBER_SOFTBAN",
+  MEMBER_JOIN: "MEMBER_JOIN",
+  MEMBER_LEAVE: "MEMBER_LEAVE",
+  MEMBER_ROLE_ADD: "MEMBER_ROLE_ADD",
+  MEMBER_ROLE_REMOVE: "MEMBER_ROLE_REMOVE",
+  MEMBER_NICK_CHANGE: "MEMBER_NICK_CHANGE",
+  MEMBER_USERNAME_CHANGE: "MEMBER_USERNAME_CHANGE",
+  MEMBER_RESTORE: "MEMBER_RESTORE",
+  CHANNEL_CREATE: "CHANNEL_CREATE",
+  CHANNEL_DELETE: "CHANNEL_DELETE",
+  CHANNEL_UPDATE: "CHANNEL_UPDATE",
+  THREAD_CREATE: "THREAD_CREATE",
+  THREAD_DELETE: "THREAD_DELETE",
+  THREAD_UPDATE: "THREAD_UPDATE",
+  ROLE_CREATE: "ROLE_CREATE",
+  ROLE_DELETE: "ROLE_DELETE",
+  ROLE_UPDATE: "ROLE_UPDATE",
+  MESSAGE_EDIT: "MESSAGE_EDIT",
+  MESSAGE_DELETE: "MESSAGE_DELETE",
+  MESSAGE_DELETE_BULK: "MESSAGE_DELETE_BULK",
+  MESSAGE_DELETE_BARE: "MESSAGE_DELETE_BARE",
+  VOICE_CHANNEL_JOIN: "VOICE_CHANNEL_JOIN",
+  VOICE_CHANNEL_LEAVE: "VOICE_CHANNEL_LEAVE",
+  VOICE_CHANNEL_MOVE: "VOICE_CHANNEL_MOVE",
+  STAGE_INSTANCE_CREATE: "STAGE_INSTANCE_CREATE",
+  STAGE_INSTANCE_DELETE: "STAGE_INSTANCE_DELETE",
+  STAGE_INSTANCE_UPDATE: "STAGE_INSTANCE_UPDATE",
+  EMOJI_CREATE: "EMOJI_CREATE",
+  EMOJI_DELETE: "EMOJI_DELETE",
+  EMOJI_UPDATE: "EMOJI_UPDATE",
+  STICKER_CREATE: "STICKER_CREATE",
+  STICKER_DELETE: "STICKER_DELETE",
+  STICKER_UPDATE: "STICKER_UPDATE",
+  COMMAND: "COMMAND",
+  MESSAGE_SPAM_DETECTED: "MESSAGE_SPAM_DETECTED",
+  CENSOR: "CENSOR",
+  CLEAN: "CLEAN",
+  CASE_CREATE: "CASE_CREATE",
+  MASSUNBAN: "MASSUNBAN",
+  MASSBAN: "MASSBAN",
+  MASSMUTE: "MASSMUTE",
+  MEMBER_TIMED_MUTE: "MEMBER_TIMED_MUTE",
+  MEMBER_TIMED_UNMUTE: "MEMBER_TIMED_UNMUTE",
+  MEMBER_TIMED_BAN: "MEMBER_TIMED_BAN",
+  MEMBER_TIMED_UNBAN: "MEMBER_TIMED_UNBAN",
+  MEMBER_JOIN_WITH_PRIOR_RECORDS: "MEMBER_JOIN_WITH_PRIOR_RECORDS",
+  OTHER_SPAM_DETECTED: "OTHER_SPAM_DETECTED",
+  MEMBER_ROLE_CHANGES: "MEMBER_ROLE_CHANGES",
+  VOICE_CHANNEL_FORCE_MOVE: "VOICE_CHANNEL_FORCE_MOVE",
+  VOICE_CHANNEL_FORCE_DISCONNECT: "VOICE_CHANNEL_FORCE_DISCONNECT",
+  CASE_UPDATE: "CASE_UPDATE",
+  MEMBER_MUTE_REJOIN: "MEMBER_MUTE_REJOIN",
+  SCHEDULED_MESSAGE: "SCHEDULED_MESSAGE",
+  POSTED_SCHEDULED_MESSAGE: "POSTED_SCHEDULED_MESSAGE",
+  BOT_ALERT: "BOT_ALERT",
+  AUTOMOD_ACTION: "AUTOMOD_ACTION",
+  SCHEDULED_REPEATED_MESSAGE: "SCHEDULED_REPEATED_MESSAGE",
+  REPEATED_MESSAGE: "REPEATED_MESSAGE",
+  MESSAGE_DELETE_AUTO: "MESSAGE_DELETE_AUTO",
+  SET_ANTIRAID_USER: "SET_ANTIRAID_USER",
+  SET_ANTIRAID_AUTO: "SET_ANTIRAID_AUTO",
+  MEMBER_NOTE: "MEMBER_NOTE",
+  CASE_DELETE: "CASE_DELETE",
+  DM_FAILED: "DM_FAILED",
+} as const;
diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts
index 51f67b87..2960c2ae 100644
--- a/backend/src/plugins/Logs/types.ts
+++ b/backend/src/plugins/Logs/types.ts
@@ -1,12 +1,12 @@
 import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub";
-import { z } from "zod";
+import { ZodString, z } from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { LogType } from "../../data/LogType";
-import { zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils";
+import { keys, zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils";
 import { MessageBuffer } from "../../utils/MessageBuffer";
 import {
   TemplateSafeCase,
@@ -26,10 +26,13 @@ const DEFAULT_BATCH_TIME = 1000;
 const MIN_BATCH_TIME = 250;
 const MAX_BATCH_TIME = 5000;
 
-export const zLogFormats = z.record(
-  zBoundedCharacters(1, 255),
-  zMessageContent,
-);
+type ZLogFormatsHelper = {
+  -readonly [K in keyof typeof LogType]: typeof zMessageContent;
+};
+export const zLogFormats = z.strictObject(keys(LogType).reduce((map, logType) => {
+  map[logType] = zMessageContent;
+  return map;
+}, {} as ZLogFormatsHelper));
 export type TLogFormats = z.infer<typeof zLogFormats>;
 
 const zLogChannel = z.strictObject({
@@ -44,7 +47,7 @@ const zLogChannel = z.strictObject({
   excluded_threads: z.array(zSnowflake).nullable().default(null),
   exclude_bots: z.boolean().default(false),
   excluded_roles: z.array(zSnowflake).nullable().default(null),
-  format: zLogFormats.default({}),
+  format: zLogFormats.partial().default({}),
   timestamp_format: z.string().nullable().default(null),
   include_embed_timestamp: z.boolean().nullable().default(null),
 });
@@ -55,7 +58,7 @@ export type TLogChannelMap = z.infer<typeof zLogChannelMap>;
 
 export const zLogsConfig = z.strictObject({
   channels: zLogChannelMap,
-  format: z.intersection(zLogFormats, z.strictObject({
+  format: zLogFormats.merge(z.strictObject({
     // Legacy/deprecated, use timestamp_format below instead
     timestamp: zBoundedCharacters(0, 64).nullable(),
   })),
diff --git a/backend/src/plugins/Logs/util/isLogIgnored.ts b/backend/src/plugins/Logs/util/isLogIgnored.ts
index eb23b927..71255f50 100644
--- a/backend/src/plugins/Logs/util/isLogIgnored.ts
+++ b/backend/src/plugins/Logs/util/isLogIgnored.ts
@@ -2,6 +2,6 @@ import { GuildPluginData } from "knub";
 import { LogType } from "../../../data/LogType";
 import { LogsPluginType } from "../types";
 
-export function isLogIgnored(pluginData: GuildPluginData<LogsPluginType>, type: LogType, ignoreId: string) {
+export function isLogIgnored(pluginData: GuildPluginData<LogsPluginType>, type: keyof typeof LogType, ignoreId: string) {
   return pluginData.state.guildLogs.isLogIgnored(type, ignoreId);
 }

From 5a5be89573337f701e1115a2e7c8497608f917ad Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:38:48 +0000
Subject: [PATCH 30/51] fix: counter types

---
 backend/src/plugins/Counters/types.ts | 57 ++++++++++++++++-----------
 1 file changed, 34 insertions(+), 23 deletions(-)

diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts
index 2b8cce16..783ec666 100644
--- a/backend/src/plugins/Counters/types.ts
+++ b/backend/src/plugins/Counters/types.ts
@@ -10,7 +10,7 @@ const MAX_COUNTERS = 5;
 const MAX_TRIGGERS_PER_COUNTER = 5;
 
 export const zTrigger = z.strictObject({
-  // Dummy type because name gets replaced by the property key in zTriggerInput
+  // Dummy type because name gets replaced by the property key in transform()
   name: z.never().optional().transform(() => ""),
   pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
   condition: zBoundedCharacters(1, 64).refine(
@@ -20,34 +20,45 @@ export const zTrigger = z.strictObject({
   reverse_condition: zBoundedCharacters(1, 64).refine(
     (str) => parseCounterConditionString(str) !== null,
     { message: "Invalid counter trigger reverse condition" },
-  ),
-});
-
-const zTriggerInput = z.union([zBoundedCharacters(0, 100), zTrigger])
+  ).optional(),
+})
   .transform((val, ctx) => {
     const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
-    if (typeof val === "string") {
-      const parsedCondition = parseCounterConditionString(val);
-      if (!parsedCondition) {
-        ctx.addIssue({
-          code: z.ZodIssueCode.custom,
-          message: "Invalid counter trigger condition",
-        });
-        return z.NEVER;
-      }
-      return {
-        name: ruleName,
-        pretty_name: null,
-        condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),
-        reverse_condition: buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]),
-      };
+
+    let reverseCondition = val.reverse_condition;
+    if (! reverseCondition) {
+      const parsedCondition = parseCounterConditionString(val.condition)!;
+      reverseCondition = buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]);
     }
+
     return {
       ...val,
       name: ruleName,
+      reverse_condition: reverseCondition,
     };
   });
 
+const zTriggerFromString = zBoundedCharacters(0, 100)
+  .transform((val, ctx) => {
+    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+    const parsedCondition = parseCounterConditionString(val);
+    if (!parsedCondition) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Invalid counter trigger condition",
+      });
+      return z.NEVER;
+    }
+    return {
+      name: ruleName,
+      pretty_name: null,
+      condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),
+      reverse_condition: buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]),
+    };
+  });
+
+const zTriggerInput = z.union([zTrigger, zTriggerFromString]);
+
 export const zCounter = z.strictObject({
   // Typed as "never" because you are not expected to supply this directly.
   // The transform instead picks it up from the property key and the output type is a string.
@@ -78,9 +89,9 @@ export const zCounter = z.strictObject({
     amount: z.number(),
     every: zDelayString,
   }).nullable().default(null),
-  can_view: z.boolean(),
-  can_edit: z.boolean(),
-  can_reset_all: z.boolean(),
+  can_view: z.boolean().default(false),
+  can_edit: z.boolean().default(false),
+  can_reset_all: z.boolean().default(false),
 });
 
 export const zCountersConfig = z.strictObject({

From e4b098b5639aaf579fcb41b1dc0ab2954cb090fe Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:39:27 +0000
Subject: [PATCH 31/51] fix: automod types

---
 backend/src/plugins/Automod/actions/changePerms.ts     |  6 +++---
 .../Automod/functions/createMessageSpamTrigger.ts      |  2 +-
 backend/src/plugins/Automod/triggers/matchLinks.ts     | 10 +++++-----
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts
index b5bc9686..33b0c283 100644
--- a/backend/src/plugins/Automod/actions/changePerms.ts
+++ b/backend/src/plugins/Automod/actions/changePerms.ts
@@ -2,7 +2,7 @@ import { PermissionsBitField, PermissionsString } from "discord.js";
 import { U } from "ts-toolbelt";
 import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { isValidSnowflake, keys, noop, zSnowflake } from "../../../utils";
+import { isValidSnowflake, keys, noop, zBoundedCharacters, zSnowflake } from "../../../utils";
 import {
   guildToTemplateSafeGuild,
   savedMessageToTemplateSafeSavedMessage,
@@ -66,8 +66,8 @@ const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as con
 
 export const ChangePermsAction = automodAction({
   configSchema: z.strictObject({
-    target: zSnowflake,
-    channel: zSnowflake.nullable().default(null),
+    target: zBoundedCharacters(1, 255),
+    channel: zBoundedCharacters(1, 255).nullable().default(null),
     perms: z.record(
       z.enum(allPermissionNames),
       z.boolean().nullable(),
diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
index 19364eee..562ade6b 100644
--- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
+++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
@@ -16,7 +16,7 @@ interface TMessageSpamMatchResultType {
 const configSchema = z.strictObject({
   amount: z.number().int(),
   within: zDelayString,
-  per_channel: z.boolean().optional(),
+  per_channel: z.boolean().nullable().default(false),
 });
 
 export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts
index 128b3cd1..fe3280fc 100644
--- a/backend/src/plugins/Automod/triggers/matchLinks.ts
+++ b/backend/src/plugins/Automod/triggers/matchLinks.ts
@@ -21,18 +21,18 @@ const regexCache = new WeakMap<any, RegExp[]>();
 const quickLinkCheck = /^https?:\/\//i;
 
 const configSchema = z.strictObject({
-  include_domains: z.array(z.string().max(255)).max(255).optional(),
-  exclude_domains: z.array(z.string().max(255)).max(255).optional(),
+  include_domains: z.array(z.string().max(255)).max(700).optional(),
+  exclude_domains: z.array(z.string().max(255)).max(700).optional(),
   include_subdomains: z.boolean().default(true),
-  include_words: z.array(z.string().max(2000)).max(512).optional(),
-  exclude_words: z.array(z.string().max(2000)).max(512).optional(),
+  include_words: z.array(z.string().max(2000)).max(700).optional(),
+  exclude_words: z.array(z.string().max(2000)).max(700).optional(),
   include_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
   exclude_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
   phisherman: z.strictObject({
     include_suspected: z.boolean().optional(),
     include_verified: z.boolean().optional(),
   }).optional(),
-  only_real_links: z.boolean(),
+  only_real_links: z.boolean().default(true),
   match_messages: z.boolean().default(true),
   match_embeds: z.boolean().default(true),
   match_visible_names: z.boolean().default(false),

From 61b5f3f0d39d0b0f59ba599310cf97e4e068c0ea Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:44:00 +0000
Subject: [PATCH 32/51] feat: add cli command to validate active configs

---
 .gitignore                           |  2 ++
 backend/package.json                 |  1 +
 backend/src/data/db.ts               |  6 ++++
 backend/src/validateActiveConfigs.ts | 46 ++++++++++++++++++++++++++++
 4 files changed, 55 insertions(+)
 create mode 100644 backend/src/validateActiveConfigs.ts

diff --git a/.gitignore b/.gitignore
index 890e9c97..5b912745 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,5 @@ npm-audit.txt
 *.debug.js
 
 .vscode/
+
+config-errors.txt
diff --git a/backend/package.json b/backend/package.json
index fbbe2ddc..b23dd8b6 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -24,6 +24,7 @@
     "migrate-rollback": "npm run typeorm -- migration:revert -d dist/backend/src/data/dataSource.js",
     "migrate-rollback-prod": "cross-env NODE_ENV=production npm run migrate",
     "migrate-rollback-dev": "cross-env NODE_ENV=development npm run build && npm run migrate",
+    "validate-active-configs": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps dist/backend/src/validateActiveConfigs.js > ../config-errors.txt",
     "test": "npm run build && npm run run-tests",
     "run-tests": "ava",
     "test-watch": "tsc-watch --onSuccess \"npx ava\""
diff --git a/backend/src/data/db.ts b/backend/src/data/db.ts
index a4f9b88b..f8d8f3bf 100644
--- a/backend/src/data/db.ts
+++ b/backend/src/data/db.ts
@@ -15,3 +15,9 @@ export function connect() {
 
   return connectionPromise;
 }
+
+export function disconnect() {
+  if (connectionPromise) {
+    connectionPromise.then(() => dataSource.destroy());
+  }
+}
diff --git a/backend/src/validateActiveConfigs.ts b/backend/src/validateActiveConfigs.ts
new file mode 100644
index 00000000..c9e3414b
--- /dev/null
+++ b/backend/src/validateActiveConfigs.ts
@@ -0,0 +1,46 @@
+import { YAMLException } from "js-yaml";
+import { validateGuildConfig } from "./configValidator";
+import { Configs } from "./data/Configs";
+import { connect, disconnect } from "./data/db";
+import { loadYamlSafely } from "./utils/loadYamlSafely";
+import { ObjectAliasError } from "./utils/validateNoObjectAliases";
+
+function writeError(key: string, error: string) {
+  const indented = error.split("\n").map(s => " ".repeat(64) + s).join("\n");
+  const prefix = `Invalid config ${key}:`;
+  const prefixed = prefix + indented.slice(prefix.length);
+  console.log(prefixed + "\n\n");
+}
+
+connect().then(async () => {
+  const configs = new Configs();
+  const activeConfigs = await configs.getActive();
+  for (const config of activeConfigs) {
+    if (config.key === "global") {
+      continue;
+    }
+
+    let parsed: unknown;
+    try {
+      parsed = loadYamlSafely(config.config);
+    } catch (err) {
+      if (err instanceof ObjectAliasError) {
+        writeError(config.key, err.message);
+        continue;
+      }
+      if (err instanceof YAMLException) {
+        writeError(config.key, `invalid YAML: ${err.message}`);
+        continue;
+      }
+      throw err;
+    }
+
+    const errors = await validateGuildConfig(parsed);
+    if (errors) {
+      writeError(config.key, errors);
+    }
+  }
+
+  await disconnect();
+  process.exit(0);
+});

From 1b76af357999b4926d04e104aa4318a2da0b2056 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:45:07 +0000
Subject: [PATCH 33/51] fix: cases types

---
 backend/src/plugins/Cases/types.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts
index e25b7ed0..bcc50eed 100644
--- a/backend/src/plugins/Cases/types.ts
+++ b/backend/src/plugins/Cases/types.ts
@@ -16,7 +16,7 @@ export const zCasesConfig = z.strictObject({
   show_relative_times: z.boolean(),
   relative_time_cutoff: zDelayString.default("1w"),
   case_colors: z.record(z.enum(caseKeys), zColor).nullable(),
-  case_icons: z.record(z.enum(caseKeys), zBoundedCharacters(0, 32)).nullable(),
+  case_icons: z.record(z.enum(caseKeys), zBoundedCharacters(0, 100)).nullable(),
 });
 
 export interface CasesPluginType extends BasePluginType {

From a562182e7ca8024ff58a8181d6e6404a893edf61 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:45:38 +0000
Subject: [PATCH 34/51] chore: remove unused log types from
 presetup-configurator

---
 presetup-configurator/src/LogChannels.tsx | 2 --
 1 file changed, 2 deletions(-)

diff --git a/presetup-configurator/src/LogChannels.tsx b/presetup-configurator/src/LogChannels.tsx
index 40c0ca0f..bdc2c5b6 100644
--- a/presetup-configurator/src/LogChannels.tsx
+++ b/presetup-configurator/src/LogChannels.tsx
@@ -67,8 +67,6 @@ const LOG_TYPES = {
   MESSAGE_DELETE_AUTO: "Message deleted (auto)",
   SET_ANTIRAID_USER: "Set antiraid (user)",
   SET_ANTIRAID_AUTO: "Set antiraid (auto)",
-  MASS_ASSIGN_ROLES: "Mass-assigned roles",
-  MASS_UNASSIGN_ROLES: "Mass-unassigned roles",
   MEMBER_NOTE: "Member noted",
   CASE_DELETE: "Case deleted",
   DM_FAILED: "Failed to DM member",

From be87fe6c43389fab07c2eb364b610f998fc6e0f2 Mon Sep 17 00:00:00 2001
From: Almeida <github@almeidx.dev>
Date: Tue, 23 Jan 2024 16:36:45 +0000
Subject: [PATCH 35/51] fix case note on timeout expiration for manual timeouts

---
 backend/src/plugins/ModActions/events/AuditLogEvents.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/plugins/ModActions/events/AuditLogEvents.ts b/backend/src/plugins/ModActions/events/AuditLogEvents.ts
index a8a7f4ef..347d594e 100644
--- a/backend/src/plugins/ModActions/events/AuditLogEvents.ts
+++ b/backend/src/plugins/ModActions/events/AuditLogEvents.ts
@@ -42,7 +42,7 @@ export const AuditLogEvents = modActionsEvt({
             caseId: existingCaseId,
             modId: auditLogEntry.executor?.id || "0",
             body: auditLogEntry.reason || "",
-            noteDetails: [`Timeout set to expire on <t:${moment.utc(muteChange.new as string).valueOf()}>`],
+            noteDetails: [`Timeout set to expire on <t:${Math.ceil(moment.utc(muteChange.new as string).valueOf() / 1_000)}>`],
           });
         } else {
           await casesPlugin.createCase({

From 681b19b69c5b5ff7c9d636bac7e1d8cd036aa413 Mon Sep 17 00:00:00 2001
From: Almeida <github@almeidx.dev>
Date: Tue, 23 Jan 2024 16:47:58 +0000
Subject: [PATCH 36/51] fmt

---
 backend/src/plugins/ModActions/events/AuditLogEvents.ts | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/backend/src/plugins/ModActions/events/AuditLogEvents.ts b/backend/src/plugins/ModActions/events/AuditLogEvents.ts
index 347d594e..a66328b9 100644
--- a/backend/src/plugins/ModActions/events/AuditLogEvents.ts
+++ b/backend/src/plugins/ModActions/events/AuditLogEvents.ts
@@ -42,7 +42,9 @@ export const AuditLogEvents = modActionsEvt({
             caseId: existingCaseId,
             modId: auditLogEntry.executor?.id || "0",
             body: auditLogEntry.reason || "",
-            noteDetails: [`Timeout set to expire on <t:${Math.ceil(moment.utc(muteChange.new as string).valueOf() / 1_000)}>`],
+            noteDetails: [
+              `Timeout set to expire on <t:${Math.ceil(moment.utc(muteChange.new as string).valueOf() / 1_000)}>`,
+            ],
           });
         } else {
           await casesPlugin.createCase({

From 6110b8190c4152735acbb8bf64bcbed4ec152195 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 12:45:52 +0200
Subject: [PATCH 37/51] chore: use @zeppelinbot scope over @zeppelin for
 package names

---
 backend/package.json   | 2 +-
 dashboard/package.json | 2 +-
 package.json           | 2 +-
 shared/package.json    | 2 +-
 4 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/backend/package.json b/backend/package.json
index b23dd8b6..0ac26264 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "@zeppelin/backend",
+  "name": "@zeppelinbot/backend",
   "version": "0.0.1",
   "description": "",
   "private": true,
diff --git a/dashboard/package.json b/dashboard/package.json
index 633f3d2c..269e9d6d 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "@zeppelin/dashboard",
+  "name": "@zeppelinbot/dashboard",
   "version": "1.0.0",
   "description": "",
   "private": true,
diff --git a/package.json b/package.json
index 0309d59c..4cae320e 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "@zeppelin/zeppelin",
+  "name": "@zeppelinbot/zeppelin",
   "version": "0.0.1",
   "description": "",
   "private": true,
diff --git a/shared/package.json b/shared/package.json
index 134cab28..83a2dd16 100644
--- a/shared/package.json
+++ b/shared/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "@zeppelin/shared",
+  "name": "@zeppelinbot/shared",
   "version": "0.0.1",
   "description": "",
   "private": true,

From b0a9cf1bcd4b28ad3a5184bbb8ba0b1ab2a11325 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 12:46:48 +0200
Subject: [PATCH 38/51] fix: tweaks to config types

---
 backend/src/plugins/Automod/actions/alert.ts       | 2 +-
 backend/src/plugins/Automod/actions/changePerms.ts | 6 +++---
 backend/src/plugins/Automod/triggers/matchWords.ts | 2 +-
 backend/src/plugins/Automod/types.ts               | 2 +-
 backend/src/utils.ts                               | 2 ++
 5 files changed, 8 insertions(+), 6 deletions(-)

diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts
index c5fcd4b3..65456218 100644
--- a/backend/src/plugins/Automod/actions/alert.ts
+++ b/backend/src/plugins/Automod/actions/alert.ts
@@ -27,7 +27,7 @@ import { automodAction } from "../helpers";
 
 const configSchema = z.object({
   channel: zSnowflake,
-  text: zBoundedCharacters(1, 4000),
+  text: zBoundedCharacters(0, 4000),
   allowed_mentions: zNullishToUndefined(zAllowedMentions.nullable().default(null)),
 });
 
diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts
index 33b0c283..92fdbb84 100644
--- a/backend/src/plugins/Automod/actions/changePerms.ts
+++ b/backend/src/plugins/Automod/actions/changePerms.ts
@@ -2,7 +2,7 @@ import { PermissionsBitField, PermissionsString } from "discord.js";
 import { U } from "ts-toolbelt";
 import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
-import { isValidSnowflake, keys, noop, zBoundedCharacters, zSnowflake } from "../../../utils";
+import { isValidSnowflake, keys, noop, zBoundedCharacters } from "../../../utils";
 import {
   guildToTemplateSafeGuild,
   savedMessageToTemplateSafeSavedMessage,
@@ -66,8 +66,8 @@ const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as con
 
 export const ChangePermsAction = automodAction({
   configSchema: z.strictObject({
-    target: zBoundedCharacters(1, 255),
-    channel: zBoundedCharacters(1, 255).nullable().default(null),
+    target: zBoundedCharacters(1, 2000),
+    channel: zBoundedCharacters(1, 2000).nullable().default(null),
     perms: z.record(
       z.enum(allPermissionNames),
       z.boolean().nullable(),
diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts
index e8c275e1..95556289 100644
--- a/backend/src/plugins/Automod/triggers/matchWords.ts
+++ b/backend/src/plugins/Automod/triggers/matchWords.ts
@@ -14,7 +14,7 @@ interface MatchResultType {
 const regexCache = new WeakMap<any, RegExp[]>();
 
 const configSchema = z.strictObject({
-  words: z.array(z.string().max(2000)).max(512),
+  words: z.array(z.string().max(2000)).max(1024),
   case_sensitive: z.boolean().default(false),
   only_full_words: z.boolean().default(true),
   normalize: z.boolean().default(false),
diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts
index 251ef643..086060a0 100644
--- a/backend/src/plugins/Automod/types.ts
+++ b/backend/src/plugins/Automod/types.ts
@@ -69,7 +69,7 @@ export const zAutomodConfig = z.strictObject({
   rules: zBoundedRecord(
     z.record(z.string().max(100), zRule),
     0,
-    100,
+    255,
   ),
   antiraid_levels: z.array(z.string().max(100)).max(10),
   can_set_antiraid: z.boolean(),
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 641a2573..e7160e6b 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -289,6 +289,8 @@ export type StrictMessageContent = {
 
 export const zMessageContent = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]);
 
+export type MessageContent = string | StrictMessageContent;
+
 export function validateAndParseMessageContent(input: unknown): StrictMessageContent {
   if (input == null) {
     return {};

From b83f38809694f20be5a03549567cd4f4d3f79fe4 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 12:47:30 +0200
Subject: [PATCH 39/51] feat: add cli command to export configs as json schema

---
 .gitignore                   |  1 +
 backend/package-lock.json    | 18 ++++++++++++++----
 backend/package.json         |  4 +++-
 backend/src/exportSchemas.ts | 24 ++++++++++++++++++++++++
 4 files changed, 42 insertions(+), 5 deletions(-)
 create mode 100644 backend/src/exportSchemas.ts

diff --git a/.gitignore b/.gitignore
index 5b912745..a312a7f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,3 +84,4 @@ npm-audit.txt
 .vscode/
 
 config-errors.txt
+/config-schema.json
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 1f2f5992..12d2473a 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -75,7 +75,8 @@
         "@types/uuid": "^9.0.2",
         "ava": "^5.3.1",
         "rimraf": "^2.6.2",
-        "source-map-support": "^0.5.16"
+        "source-map-support": "^0.5.16",
+        "zod-to-json-schema": "^3.22.3"
       }
     },
     "node_modules/@assemblyscript/loader": {
@@ -10192,12 +10193,21 @@
       }
     },
     "node_modules/zod": {
-      "version": "3.21.4",
-      "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
-      "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+      "version": "3.22.4",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
+      "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.22.3",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.22.3.tgz",
+      "integrity": "sha512-9isG8SqRe07p+Aio2ruBZmLm2Q6Sq4EqmXOiNpDxp+7f0LV6Q/LX65fs5Nn+FV/CzfF3NLBoksXbS2jNYIfpKw==",
+      "dev": true,
+      "peerDependencies": {
+        "zod": "^3.22.4"
+      }
     }
   }
 }
diff --git a/backend/package.json b/backend/package.json
index 0ac26264..8a0c0e8f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,6 +25,7 @@
     "migrate-rollback-prod": "cross-env NODE_ENV=production npm run migrate",
     "migrate-rollback-dev": "cross-env NODE_ENV=development npm run build && npm run migrate",
     "validate-active-configs": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps dist/backend/src/validateActiveConfigs.js > ../config-errors.txt",
+    "export-config-json-schema": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --enable-source-maps dist/backend/src/exportSchemas.js > ../config-schema.json",
     "test": "npm run build && npm run run-tests",
     "run-tests": "ava",
     "test-watch": "tsc-watch --onSuccess \"npx ava\""
@@ -97,7 +98,8 @@
     "@types/uuid": "^9.0.2",
     "ava": "^5.3.1",
     "rimraf": "^2.6.2",
-    "source-map-support": "^0.5.16"
+    "source-map-support": "^0.5.16",
+    "zod-to-json-schema": "^3.22.3"
   },
   "ava": {
     "files": [
diff --git a/backend/src/exportSchemas.ts b/backend/src/exportSchemas.ts
new file mode 100644
index 00000000..9fa5c3a5
--- /dev/null
+++ b/backend/src/exportSchemas.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+import { guildPlugins } from "./plugins/availablePlugins";
+import zodToJsonSchema from "zod-to-json-schema";
+import { zZeppelinGuildConfig } from "./types";
+
+const pluginSchemaMap = guildPlugins.reduce((map, plugin) => {
+  if (! plugin.info) {
+    return map;
+  }
+  map[plugin.name] = plugin.info.configSchema;
+  return map;
+}, {});
+
+const fullSchema = zZeppelinGuildConfig
+  .omit({ plugins: true })
+  .merge(z.strictObject({
+    plugins: z.strictObject(pluginSchemaMap).partial(),
+  }));
+
+const jsonSchema = zodToJsonSchema(fullSchema);
+
+console.log(JSON.stringify(jsonSchema, null, 2));
+
+process.exit(0);

From 7ba318a6d9b6eebcfd587bbab427241b8dc91fa5 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 12:50:09 +0200
Subject: [PATCH 40/51] chore: centralize common TS config options

---
 backend/tsconfig.json   | 19 ++-----------------
 dashboard/tsconfig.json |  9 +--------
 shared/tsconfig.json    |  9 +--------
 tsconfig.json           | 19 +++++++++++++++++++
 4 files changed, 23 insertions(+), 33 deletions(-)
 create mode 100644 tsconfig.json

diff --git a/backend/tsconfig.json b/backend/tsconfig.json
index bec249ad..2000565b 100644
--- a/backend/tsconfig.json
+++ b/backend/tsconfig.json
@@ -1,28 +1,13 @@
 {
+  "extends": "../tsconfig.json",
   "compilerOptions": {
     "moduleResolution": "NodeNext",
     "module": "NodeNext",
-    "noImplicitAny": false,
-    "allowSyntheticDefaultImports": true,
-    "experimentalDecorators": true,
-    "emitDecoratorMetadata": true,
-    "target": "esnext",
-    "lib": ["es2023"],
     "baseUrl": ".",
-    "resolveJsonModule": true,
-    "esModuleInterop": true,
     "outDir": "./dist",
     "paths": {
       "@shared/*": ["../shared/src/*"]
-    },
-    "sourceMap": true,
-    "alwaysStrict": true,
-    "noImplicitThis": true,
-    "skipLibCheck": true,
-    "strict": true,
-    "strictPropertyInitialization": false,
-    "useUnknownInCatchVariables": false,
-    "allowJs": true
+    }
   },
   "include": ["src/**/*.ts"]
 }
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index 1bd1fd32..34507dfc 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -1,18 +1,11 @@
 {
+  "extends": "../tsconfig.json",
   "compilerOptions": {
     "moduleResolution": "node",
     "module": "esnext",
     "target": "es2018",
-    "sourceMap": true,
-    "noImplicitAny": false,
-    "allowSyntheticDefaultImports": true,
-    "experimentalDecorators": true,
-    "emitDecoratorMetadata": true,
-    "strict": false,
     "lib": ["esnext", "dom"],
     "baseUrl": ".",
-    "resolveJsonModule": true,
-    "esModuleInterop": true,
     "allowJs": true,
     "paths": {
       "@shared/*": ["../shared/src/*"]
diff --git a/shared/tsconfig.json b/shared/tsconfig.json
index c5c16316..cea9087a 100644
--- a/shared/tsconfig.json
+++ b/shared/tsconfig.json
@@ -1,16 +1,9 @@
 {
+  "extends": "../tsconfig.json",
   "compilerOptions": {
     "moduleResolution": "NodeNext",
     "module": "NodeNext",
-    "noImplicitAny": false,
-    "allowSyntheticDefaultImports": true,
-    "experimentalDecorators": true,
-    "emitDecoratorMetadata": true,
-    "target": "es2022",
-    "lib": ["es2022"],
     "baseUrl": "src",
-    "resolveJsonModule": true,
-    "esModuleInterop": true,
     "outDir": "./dist"
   },
   "include": ["src/**/*.ts"]
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..3701310e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "allowSyntheticDefaultImports": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "target": "esnext",
+    "lib": ["es2023"],
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
+    "sourceMap": true,
+    "alwaysStrict": true,
+    "noImplicitThis": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "strictPropertyInitialization": false,
+    "useUnknownInCatchVariables": false
+  }
+}

From 49866d375acf14f0ebe9d5c177f3c7c7526e28ff Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 13:55:26 +0200
Subject: [PATCH 41/51] feat: zod config schema formatting

---
 backend/src/api/docs.ts | 107 ++++++++++++++++++++++++++++++++--------
 1 file changed, 86 insertions(+), 21 deletions(-)

diff --git a/backend/src/api/docs.ts b/backend/src/api/docs.ts
index dcb75c49..5b9de52b 100644
--- a/backend/src/api/docs.ts
+++ b/backend/src/api/docs.ts
@@ -1,34 +1,99 @@
 import express from "express";
+import z from "zod";
 import { guildPlugins } from "../plugins/availablePlugins";
 import { indentLines } from "../utils";
 import { notFound } from "./responses";
 
-function formatConfigSchema(schema) {
-  if (schema._tag === "InterfaceType" || schema._tag === "PartialType") {
+function isZodObject(schema: z.ZodTypeAny): schema is z.ZodObject<any> {
+  return schema._def.typeName === "ZodObject";
+}
+
+function isZodRecord(schema: z.ZodTypeAny): schema is z.ZodRecord<any> {
+  return schema._def.typeName === "ZodRecord";
+}
+
+function isZodEffects(schema: z.ZodTypeAny): schema is z.ZodEffects<any, any> {
+  return schema._def.typeName === "ZodEffects";
+}
+
+function isZodOptional(schema: z.ZodTypeAny): schema is z.ZodOptional<any> {
+  return schema._def.typeName === "ZodOptional";
+}
+
+function isZodArray(schema: z.ZodTypeAny): schema is z.ZodArray<any> {
+  return schema._def.typeName === "ZodArray";
+}
+
+function isZodUnion(schema: z.ZodTypeAny): schema is z.ZodUnion<any> {
+  return schema._def.typeName === "ZodUnion";
+}
+
+function isZodNullable(schema: z.ZodTypeAny): schema is z.ZodNullable<any> {
+  return schema._def.typeName === "ZodNullable";
+}
+
+function isZodDefault(schema: z.ZodTypeAny): schema is z.ZodDefault<any> {
+  return schema._def.typeName === "ZodDefault";
+}
+
+function isZodLiteral(schema: z.ZodTypeAny): schema is z.ZodLiteral<any> {
+  return schema._def.typeName === "ZodLiteral";
+}
+
+function isZodIntersection(schema: z.ZodTypeAny): schema is z.ZodIntersection<any, any> {
+  return schema._def.typeName === "ZodIntersection";
+}
+
+function formatZodConfigSchema(schema: z.ZodTypeAny) {
+  if (isZodObject(schema)) {
     return (
       `{\n` +
-      Object.entries(schema.props)
-        .map(([k, value]) => indentLines(`${k}: ${formatConfigSchema(value)}`, 2))
+      Object.entries(schema._def.shape())
+        .map(([k, value]) => indentLines(`${k}: ${formatZodConfigSchema(value as z.ZodTypeAny)}`, 2))
         .join("\n") +
       "\n}"
     );
-  } else if (schema._tag === "DictionaryType") {
-    return "{\n" + indentLines(`[string]: ${formatConfigSchema(schema.codomain)}`, 2) + "\n}";
-  } else if (schema._tag === "ArrayType") {
-    return `Array<${formatConfigSchema(schema.type)}>`;
-  } else if (schema._tag === "UnionType") {
-    if (schema.name.startsWith("Nullable<")) {
-      return `Nullable<${formatConfigSchema(schema.types[0])}>`;
-    } else if (schema.name.startsWith("Optional<")) {
-      return `Optional<${formatConfigSchema(schema.types[0])}>`;
-    } else {
-      return schema.types.map((t) => formatConfigSchema(t)).join(" | ");
-    }
-  } else if (schema._tag === "IntersectionType") {
-    return schema.types.map((t) => formatConfigSchema(t)).join(" & ");
-  } else {
-    return schema.name;
   }
+  if (isZodRecord(schema)) {
+    return "{\n" + indentLines(`[string]: ${formatZodConfigSchema(schema._def.valueType)}`, 2) + "\n}";
+  }
+  if (isZodEffects(schema)) {
+    return formatZodConfigSchema(schema._def.schema);
+  }
+  if (isZodOptional(schema)) {
+    return `Optional<${formatZodConfigSchema(schema._def.innerType)}>`;
+  }
+  if (isZodArray(schema)) {
+    return `Array<${formatZodConfigSchema(schema._def.type)}>`;
+  }
+  if (isZodUnion(schema)) {
+    return schema._def.options.map((t) => formatZodConfigSchema(t)).join(" | ");
+  }
+  if (isZodNullable(schema)) {
+    return `Nullable<${formatZodConfigSchema(schema._def.innerType)}>`;
+  }
+  if (isZodDefault(schema)) {
+    return formatZodConfigSchema(schema._def.innerType);
+  }
+  if (isZodLiteral(schema)) {
+    return schema._def.value;
+  }
+  if (isZodIntersection(schema)) {
+    return [formatZodConfigSchema(schema._def.left), formatZodConfigSchema(schema._def.right)].join(" & ");
+  }
+  if (schema._def.typeName === "ZodString") {
+    return "string";
+  }
+  if (schema._def.typeName === "ZodNumber") {
+    return "number";
+  }
+  if (schema._def.typeName === "ZodBoolean") {
+    return "boolean";
+  }
+  if (schema._def.typeName === "ZodNever") {
+    return "never";
+  }
+  return "unknown";
 }
 
 export function initDocs(app: express.Express) {
@@ -67,7 +132,7 @@ export function initDocs(app: express.Express) {
     }));
 
     const defaultOptions = plugin.defaultOptions || {};
-    const configSchema = plugin.info?.configSchema && formatConfigSchema(plugin.info.configSchema);
+    const configSchema = plugin.info?.configSchema && formatZodConfigSchema(plugin.info.configSchema);
 
     res.json({
       name,

From adab5dd591af7df85619fa7adc3a95d07a12abf8 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:04:49 +0200
Subject: [PATCH 42/51] fix: revert dashboard tsconfig

There are a lot of errors with the stricter settings that the backend
uses and it's not worth it fixing the dashboard now when we're
rewriting it in the near future.
---
 dashboard/tsconfig.json | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index 34507dfc..1bd1fd32 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -1,11 +1,18 @@
 {
-  "extends": "../tsconfig.json",
   "compilerOptions": {
     "moduleResolution": "node",
     "module": "esnext",
     "target": "es2018",
+    "sourceMap": true,
+    "noImplicitAny": false,
+    "allowSyntheticDefaultImports": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "strict": false,
     "lib": ["esnext", "dom"],
     "baseUrl": ".",
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
     "allowJs": true,
     "paths": {
       "@shared/*": ["../shared/src/*"]

From 77ab2718e7a60cd5bb82e3eb9be86aa7566ad0ab Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:21:32 +0200
Subject: [PATCH 43/51] chore: resolve lint errors

---
 backend/src/index.ts                                |  2 +-
 .../src/plugins/ContextMenus/actions/userInfo.ts    |  2 +-
 backend/src/plugins/Logs/types.ts                   |  2 +-
 backend/src/plugins/Utility/UtilityPlugin.ts        |  4 ++--
 backend/src/plugins/Utility/commands/InfoCmd.ts     | 13 ++++++-------
 .../src/plugins/Utility/commands/MessageInfoCmd.ts  |  1 -
 backend/src/plugins/Utility/commands/RoleInfoCmd.ts |  2 +-
 .../src/plugins/Utility/commands/ServerInfoCmd.ts   |  2 +-
 .../plugins/Utility/commands/SnowflakeInfoCmd.ts    |  4 ++--
 backend/src/plugins/Utility/commands/UserInfoCmd.ts |  2 +-
 .../Utility/functions/getChannelInfoEmbed.ts        |  1 -
 .../Utility/functions/getMessageInfoEmbed.ts        |  4 ----
 .../plugins/Utility/functions/getRoleInfoEmbed.ts   |  1 -
 .../plugins/Utility/functions/getServerInfoEmbed.ts |  1 -
 .../Utility/functions/getSnowflakeInfoEmbed.ts      |  4 ----
 .../plugins/Utility/functions/getUserInfoEmbed.ts   |  4 ----
 backend/src/utils.ts                                |  7 -------
 17 files changed, 16 insertions(+), 40 deletions(-)

diff --git a/backend/src/index.ts b/backend/src/index.ts
index 2fe16c11..6dad14b1 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -203,7 +203,7 @@ if (env.DEBUG) {
 }
 
 logger.info("Connecting to database");
-connect().then(async (connection) => {
+connect().then(async () => {
   const client = new Client({
     partials: [Partials.User, Partials.Channel, Partials.GuildMember, Partials.Message, Partials.Reaction],
 
diff --git a/backend/src/plugins/ContextMenus/actions/userInfo.ts b/backend/src/plugins/ContextMenus/actions/userInfo.ts
index e445e1c8..a2d44d7a 100644
--- a/backend/src/plugins/ContextMenus/actions/userInfo.ts
+++ b/backend/src/plugins/ContextMenus/actions/userInfo.ts
@@ -16,7 +16,7 @@ export async function userInfoAction(
   const utility = pluginData.getPlugin(UtilityPlugin);
 
   if (userCfg.can_use && (await utility.hasPermission(executingMember, interaction.channelId, "can_userinfo"))) {
-    const embed = await utility.userInfo(interaction.targetId, interaction.user.id);
+    const embed = await utility.userInfo(interaction.targetId);
     if (!embed) {
       await interaction.followUp({ content: "Cannot info: internal error" });
       return;
diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts
index 2960c2ae..6b214c99 100644
--- a/backend/src/plugins/Logs/types.ts
+++ b/backend/src/plugins/Logs/types.ts
@@ -1,5 +1,5 @@
 import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub";
-import { ZodString, z } from "zod";
+import { z } from "zod";
 import { RegExpRunner } from "../../RegExpRunner";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildCases } from "../../data/GuildCases";
diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts
index efb892ec..5f44118e 100644
--- a/backend/src/plugins/Utility/UtilityPlugin.ts
+++ b/backend/src/plugins/Utility/UtilityPlugin.ts
@@ -169,8 +169,8 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()({
     },
 
     userInfo(pluginData) {
-      return (userId: Snowflake, requestMemberId?: Snowflake) => {
-        return getUserInfoEmbed(pluginData, userId, false, requestMemberId);
+      return (userId: Snowflake) => {
+        return getUserInfoEmbed(pluginData, userId, false);
       };
     },
 
diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts
index 63d0ae81..a430898e 100644
--- a/backend/src/plugins/Utility/commands/InfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/InfoCmd.ts
@@ -42,7 +42,7 @@ export const InfoCmd = utilityCmd({
       const channelId = getChannelId(value);
       const channel = channelId && pluginData.guild.channels.cache.get(channelId as Snowflake);
       if (channel) {
-        const embed = await getChannelInfoEmbed(pluginData, channelId!, message.author.id);
+        const embed = await getChannelInfoEmbed(pluginData, channelId!);
         if (embed) {
           message.channel.send({ embeds: [embed] });
           return;
@@ -54,7 +54,7 @@ export const InfoCmd = utilityCmd({
     if (userCfg.can_server) {
       const guild = await pluginData.client.guilds.fetch(value as Snowflake).catch(noop);
       if (guild) {
-        const embed = await getServerInfoEmbed(pluginData, value, message.author.id);
+        const embed = await getServerInfoEmbed(pluginData, value);
         if (embed) {
           message.channel.send({ embeds: [embed] });
           return;
@@ -66,7 +66,7 @@ export const InfoCmd = utilityCmd({
     if (userCfg.can_userinfo) {
       const user = await resolveUser(pluginData.client, value);
       if (user && userCfg.can_userinfo) {
-        const embed = await getUserInfoEmbed(pluginData, user.id, Boolean(args.compact), message.author.id);
+        const embed = await getUserInfoEmbed(pluginData, user.id, Boolean(args.compact));
         if (embed) {
           message.channel.send({ embeds: [embed] });
           return;
@@ -83,7 +83,6 @@ export const InfoCmd = utilityCmd({
             pluginData,
             messageTarget.channel.id,
             messageTarget.messageId,
-            message.author.id,
           );
           if (embed) {
             message.channel.send({ embeds: [embed] });
@@ -112,7 +111,7 @@ export const InfoCmd = utilityCmd({
     if (userCfg.can_server) {
       const serverPreview = await getGuildPreview(pluginData.client, value).catch(() => null);
       if (serverPreview) {
-        const embed = await getServerInfoEmbed(pluginData, value, message.author.id);
+        const embed = await getServerInfoEmbed(pluginData, value);
         if (embed) {
           message.channel.send({ embeds: [embed] });
           return;
@@ -125,7 +124,7 @@ export const InfoCmd = utilityCmd({
       const roleId = getRoleId(value);
       const role = roleId && pluginData.guild.roles.cache.get(roleId as Snowflake);
       if (role) {
-        const embed = await getRoleInfoEmbed(pluginData, role, message.author.id);
+        const embed = await getRoleInfoEmbed(pluginData, role);
         message.channel.send({ embeds: [embed] });
         return;
       }
@@ -145,7 +144,7 @@ export const InfoCmd = utilityCmd({
 
     // 9. Arbitrary ID
     if (isValidSnowflake(value) && userCfg.can_snowflake) {
-      const embed = await getSnowflakeInfoEmbed(pluginData, value, true, message.author.id);
+      const embed = await getSnowflakeInfoEmbed(value, true);
       message.channel.send({ embeds: [embed] });
       return;
     }
diff --git a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts
index 82096250..72df1f6c 100644
--- a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts
@@ -24,7 +24,6 @@ export const MessageInfoCmd = utilityCmd({
       pluginData,
       args.message.channel.id,
       args.message.messageId,
-      message.author.id,
     );
     if (!embed) {
       sendErrorMessage(pluginData, message.channel, "Unknown message");
diff --git a/backend/src/plugins/Utility/commands/RoleInfoCmd.ts b/backend/src/plugins/Utility/commands/RoleInfoCmd.ts
index b04b49a7..6f540b4b 100644
--- a/backend/src/plugins/Utility/commands/RoleInfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/RoleInfoCmd.ts
@@ -13,7 +13,7 @@ export const RoleInfoCmd = utilityCmd({
   },
 
   async run({ message, args, pluginData }) {
-    const embed = await getRoleInfoEmbed(pluginData, args.role, message.author.id);
+    const embed = await getRoleInfoEmbed(pluginData, args.role);
     message.channel.send({ embeds: [embed] });
   },
 });
diff --git a/backend/src/plugins/Utility/commands/ServerInfoCmd.ts b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts
index 30fb7efe..9d1d4ed6 100644
--- a/backend/src/plugins/Utility/commands/ServerInfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/ServerInfoCmd.ts
@@ -15,7 +15,7 @@ export const ServerInfoCmd = utilityCmd({
 
   async run({ message, pluginData, args }) {
     const serverId = args.serverId || pluginData.guild.id;
-    const serverInfoEmbed = await getServerInfoEmbed(pluginData, serverId, message.author.id);
+    const serverInfoEmbed = await getServerInfoEmbed(pluginData, serverId);
     if (!serverInfoEmbed) {
       sendErrorMessage(pluginData, message.channel, "Could not find information for that server");
       return;
diff --git a/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts b/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts
index 59f43893..bf0e859f 100644
--- a/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts
@@ -12,8 +12,8 @@ export const SnowflakeInfoCmd = utilityCmd({
     id: ct.anyId(),
   },
 
-  async run({ message, args, pluginData }) {
-    const embed = await getSnowflakeInfoEmbed(pluginData, args.id, false, message.author.id);
+  async run({ message, args }) {
+    const embed = await getSnowflakeInfoEmbed(args.id, false);
     message.channel.send({ embeds: [embed] });
   },
 });
diff --git a/backend/src/plugins/Utility/commands/UserInfoCmd.ts b/backend/src/plugins/Utility/commands/UserInfoCmd.ts
index c7ce8b48..d796bf36 100644
--- a/backend/src/plugins/Utility/commands/UserInfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/UserInfoCmd.ts
@@ -17,7 +17,7 @@ export const UserInfoCmd = utilityCmd({
 
   async run({ message, args, pluginData }) {
     const userId = args.user?.id || message.author.id;
-    const embed = await getUserInfoEmbed(pluginData, userId, args.compact, message.author.id);
+    const embed = await getUserInfoEmbed(pluginData, userId, args.compact);
     if (!embed) {
       sendErrorMessage(pluginData, message.channel, "User not found");
       return;
diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
index ee006f5f..33a04b8e 100644
--- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts
@@ -22,7 +22,6 @@ const FORUM_CHANNEL_ICON =
 export async function getChannelInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   channelId: string,
-  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const channel = pluginData.guild.channels.cache.get(channelId as Snowflake);
   if (!channel) {
diff --git a/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts b/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
index 1568a3e3..4b615cf0 100644
--- a/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getMessageInfoEmbed.ts
@@ -9,7 +9,6 @@ import {
   trimEmptyLines,
   trimLines,
 } from "../../../utils";
-import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
 import { UtilityPluginType } from "../types";
 
 const MESSAGE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/740685652152025088/message.png";
@@ -18,7 +17,6 @@ export async function getMessageInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   channelId: string,
   messageId: string,
-  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const message = await (pluginData.guild.channels.resolve(channelId as Snowflake) as TextChannel).messages
     .fetch(messageId as Snowflake)
@@ -27,8 +25,6 @@ export async function getMessageInfoEmbed(
     return null;
   }
 
-  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
-
   const embed: EmbedWith<"fields" | "author"> = {
     fields: [],
     author: {
diff --git a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
index fa3188ec..700546ca 100644
--- a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
@@ -9,7 +9,6 @@ const MENTION_ICON = "https://cdn.discordapp.com/attachments/705009450855039042/
 export async function getRoleInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   role: Role,
-  requestMemberId?: string,
 ): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author" | "color"> = {
     fields: [],
diff --git a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
index d0202db7..bbd1f78f 100644
--- a/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getServerInfoEmbed.ts
@@ -25,7 +25,6 @@ const prettifyFeature = (feature: string): string =>
 export async function getServerInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   serverId: string,
-  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const thisServer = serverId === pluginData.guild.id ? pluginData.guild : null;
   const [restGuild, guildPreview] = await Promise.all([
diff --git a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
index ef676fd1..7009241c 100644
--- a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
@@ -1,16 +1,12 @@
 import { APIEmbed } from "discord.js";
-import { GuildPluginData } from "knub";
 import { EmbedWith, preEmbedPadding } from "../../../utils";
 import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp";
-import { UtilityPluginType } from "../types";
 
 const SNOWFLAKE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/742020790471491668/snowflake.png";
 
 export async function getSnowflakeInfoEmbed(
-  pluginData: GuildPluginData<UtilityPluginType>,
   snowflake: string,
   showUnknownWarning = false,
-  requestMemberId?: string,
 ): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author"> = {
     fields: [],
diff --git a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
index ef8d2320..b568a833 100644
--- a/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getUserInfoEmbed.ts
@@ -13,7 +13,6 @@ import {
   trimLines,
   UnknownUser,
 } from "../../../utils";
-import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
 import { UtilityPluginType } from "../types";
 
 const MAX_ROLES_TO_DISPLAY = 15;
@@ -27,7 +26,6 @@ export async function getUserInfoEmbed(
   pluginData: GuildPluginData<UtilityPluginType>,
   userId: string,
   compact = false,
-  requestMemberId?: string,
 ): Promise<APIEmbed | null> {
   const user = await resolveUser(pluginData.client, userId);
   if (!user || user instanceof UnknownUser) {
@@ -40,8 +38,6 @@ export async function getUserInfoEmbed(
     fields: [],
   };
 
-  const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
-
   embed.author = {
     name: `${user.bot ? "Bot" : "User"}:  ${renderUsername(user.username, user.discriminator)}`,
   };
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index db08dc49..97f51301 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -150,13 +150,6 @@ export type GroupDMInvite = Invite & {
   type: typeof ChannelType.GroupDM;
 };
 
-function isBoundedString(str: unknown, min: number, max: number): str is string {
-  if (typeof str !== "string") {
-    return false;
-  }
-  return (str.length >= min && str.length <= max);
-}
-
 export function zBoundedCharacters(min: number, max: number) {
   return z.string().refine(str => {
     const len = [...str].length; // Unicode aware character split

From 873bf7eb9962a210c4e62b837f65e429378be388 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:23:13 +0200
Subject: [PATCH 44/51] chore: run prettier

---
 backend/src/data/GuildArchives.ts             |   2 +-
 backend/src/exportSchemas.ts                  |  12 +-
 backend/src/pluginUtils.ts                    |   8 +-
 backend/src/plugins/AutoDelete/types.ts       |   2 +-
 backend/src/plugins/Automod/AutomodPlugin.ts  |   4 +-
 backend/src/plugins/Automod/actions/alert.ts  |   2 +-
 backend/src/plugins/Automod/actions/ban.ts    |  11 +-
 .../plugins/Automod/actions/changePerms.ts    |   5 +-
 backend/src/plugins/Automod/actions/kick.ts   |   2 +-
 backend/src/plugins/Automod/actions/mute.ts   |  21 ++-
 backend/src/plugins/Automod/actions/reply.ts  |   2 +-
 backend/src/plugins/Automod/actions/warn.ts   |   2 +-
 backend/src/plugins/Automod/constants.ts      |   5 +-
 .../functions/createMessageSpamTrigger.ts     |   2 +-
 backend/src/plugins/Automod/helpers.ts        |   2 +-
 .../plugins/Automod/triggers/antiraidLevel.ts |   2 +-
 .../plugins/Automod/triggers/anyMessage.ts    |   2 +-
 .../Automod/triggers/matchAttachmentType.ts   |  48 +++---
 .../plugins/Automod/triggers/matchLinks.ts    |  20 ++-
 .../plugins/Automod/triggers/matchMimeType.ts |  46 +++---
 .../src/plugins/Automod/triggers/roleAdded.ts |   5 +-
 .../plugins/Automod/triggers/roleRemoved.ts   |   5 +-
 backend/src/plugins/Automod/types.ts          |  70 ++++----
 .../src/plugins/ContextMenus/actions/mute.ts  |   4 +-
 .../src/plugins/Counters/CountersPlugin.ts    |   5 +-
 backend/src/plugins/Counters/types.ts         | 133 ++++++++-------
 .../actions/setChannelPermissionOverrides.ts  |  18 +-
 backend/src/plugins/CustomEvents/types.ts     |   6 +-
 .../GuildAccessMonitorPlugin.ts               |   2 +-
 .../GuildInfoSaver/GuildInfoSaverPlugin.ts    |   2 +-
 backend/src/plugins/Logs/LogsPlugin.ts        |   2 +-
 .../Logs/logFunctions/logMessageDelete.ts     |   4 +-
 backend/src/plugins/Logs/types.ts             |  20 ++-
 backend/src/plugins/Logs/util/isLogIgnored.ts |   6 +-
 .../ModActions/functions/clearTempban.ts      |   4 +-
 backend/src/plugins/ReactionRoles/types.ts    |   5 +-
 backend/src/plugins/RoleButtons/types.ts      | 154 ++++++++++--------
 .../src/plugins/SelfGrantableRoles/types.ts   |   5 +-
 backend/src/plugins/Slowmode/types.ts         |   2 +-
 backend/src/plugins/Spam/types.ts             |  10 +-
 backend/src/plugins/Starboard/types.ts        |   6 +-
 .../src/plugins/Tags/commands/TagEvalCmd.ts   |   8 +-
 backend/src/plugins/Tags/types.ts             |  70 ++++----
 .../src/plugins/Tags/util/onMessageCreate.ts  |   4 +-
 .../plugins/Tags/util/renderTagFromString.ts  |   8 +-
 .../src/plugins/Utility/commands/InfoCmd.ts   |   6 +-
 .../Utility/commands/MessageInfoCmd.ts        |   6 +-
 .../Utility/functions/getRoleInfoEmbed.ts     |   5 +-
 .../functions/getSnowflakeInfoEmbed.ts        |   5 +-
 backend/src/plugins/Utility/search.ts         |  10 +-
 .../src/plugins/ZeppelinPluginBlueprint.ts    |   2 +-
 backend/src/utils.ts                          |  77 ++++++---
 backend/src/utils/formatZodIssue.ts           |   4 +-
 backend/src/validateActiveConfigs.ts          |   5 +-
 54 files changed, 462 insertions(+), 416 deletions(-)

diff --git a/backend/src/data/GuildArchives.ts b/backend/src/data/GuildArchives.ts
index 0451dfe0..4d74ccef 100644
--- a/backend/src/data/GuildArchives.ts
+++ b/backend/src/data/GuildArchives.ts
@@ -4,12 +4,12 @@ import { Repository } from "typeorm";
 import { TemplateSafeValueContainer, renderTemplate } from "../templateFormatter";
 import { renderUsername, trimLines } from "../utils";
 import { decrypt, encrypt } from "../utils/crypt";
+import { isDefaultSticker } from "../utils/isDefaultSticker";
 import { channelToTemplateSafeChannel, guildToTemplateSafeGuild } from "../utils/templateSafeObjects";
 import { BaseGuildRepository } from "./BaseGuildRepository";
 import { dataSource } from "./dataSource";
 import { ArchiveEntry } from "./entities/ArchiveEntry";
 import { SavedMessage } from "./entities/SavedMessage";
-import { isDefaultSticker } from "../utils/isDefaultSticker";
 
 const DEFAULT_EXPIRY_DAYS = 30;
 
diff --git a/backend/src/exportSchemas.ts b/backend/src/exportSchemas.ts
index 9fa5c3a5..54f55291 100644
--- a/backend/src/exportSchemas.ts
+++ b/backend/src/exportSchemas.ts
@@ -1,21 +1,21 @@
 import { z } from "zod";
-import { guildPlugins } from "./plugins/availablePlugins";
 import zodToJsonSchema from "zod-to-json-schema";
+import { guildPlugins } from "./plugins/availablePlugins";
 import { zZeppelinGuildConfig } from "./types";
 
 const pluginSchemaMap = guildPlugins.reduce((map, plugin) => {
-  if (! plugin.info) {
+  if (!plugin.info) {
     return map;
   }
   map[plugin.name] = plugin.info.configSchema;
   return map;
 }, {});
 
-const fullSchema = zZeppelinGuildConfig
-  .omit({ plugins: true })
-  .merge(z.strictObject({
+const fullSchema = zZeppelinGuildConfig.omit({ plugins: true }).merge(
+  z.strictObject({
     plugins: z.strictObject(pluginSchemaMap).partial(),
-  }));
+  }),
+);
 
 const jsonSchema = zodToJsonSchema(fullSchema);
 
diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts
index a2f744d9..31cbd77c 100644
--- a/backend/src/pluginUtils.ts
+++ b/backend/src/pluginUtils.ts
@@ -10,13 +10,7 @@ import {
   PermissionsBitField,
   TextBasedChannel,
 } from "discord.js";
-import {
-  AnyPluginData,
-  CommandContext,
-  ExtendedMatchParams,
-  GuildPluginData,
-  helpers
-} from "knub";
+import { AnyPluginData, CommandContext, ExtendedMatchParams, GuildPluginData, helpers } from "knub";
 import { logger } from "./logger";
 import { isStaff } from "./staff";
 import { TZeppelinKnub } from "./types";
diff --git a/backend/src/plugins/AutoDelete/types.ts b/backend/src/plugins/AutoDelete/types.ts
index 69100438..be6ff7ee 100644
--- a/backend/src/plugins/AutoDelete/types.ts
+++ b/backend/src/plugins/AutoDelete/types.ts
@@ -1,10 +1,10 @@
 import { BasePluginType } from "knub";
+import z from "zod";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
 import { SavedMessage } from "../../data/entities/SavedMessage";
 import { MINUTES, zDelayString } from "../../utils";
 import Timeout = NodeJS.Timeout;
-import z from "zod";
 
 export const MAX_DELAY = 5 * MINUTES;
 
diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts
index 9634c25d..29fcf9e6 100644
--- a/backend/src/plugins/Automod/AutomodPlugin.ts
+++ b/backend/src/plugins/Automod/AutomodPlugin.ts
@@ -1,9 +1,9 @@
 import { CooldownManager } from "knub";
+import { Queue } from "../../Queue";
 import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
 import { GuildArchives } from "../../data/GuildArchives";
 import { GuildLogs } from "../../data/GuildLogs";
 import { GuildSavedMessages } from "../../data/GuildSavedMessages";
-import { Queue } from "../../Queue";
 import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
 import { MINUTES, SECONDS } from "../../utils";
 import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap";
@@ -19,9 +19,9 @@ import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { AntiraidClearCmd } from "./commands/AntiraidClearCmd";
 import { SetAntiraidCmd } from "./commands/SetAntiraidCmd";
 import { ViewAntiraidCmd } from "./commands/ViewAntiraidCmd";
-import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger";
 import { RunAutomodOnJoinEvt, RunAutomodOnLeaveEvt } from "./events/RunAutomodOnJoinLeaveEvt";
 import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate";
+import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger";
 import { runAutomodOnMessage } from "./events/runAutomodOnMessage";
 import { runAutomodOnModAction } from "./events/runAutomodOnModAction";
 import {
diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts
index 65456218..d21fb4d5 100644
--- a/backend/src/plugins/Automod/actions/alert.ts
+++ b/backend/src/plugins/Automod/actions/alert.ts
@@ -16,7 +16,7 @@ import {
   zAllowedMentions,
   zBoundedCharacters,
   zNullishToUndefined,
-  zSnowflake
+  zSnowflake,
 } from "../../../utils";
 import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts
index 22cc8167..5bf5b3a2 100644
--- a/backend/src/plugins/Automod/actions/ban.ts
+++ b/backend/src/plugins/Automod/actions/ban.ts
@@ -1,10 +1,17 @@
 import z from "zod";
-import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils";
+import {
+  convertDelayStringToMS,
+  nonNullish,
+  unique,
+  zBoundedCharacters,
+  zDelayString,
+  zSnowflake,
+} from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
+import { zNotify } from "../constants";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../constants";
 
 const configSchema = z.strictObject({
   reason: zBoundedCharacters(0, 4000).nullable().default(null),
diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts
index 92fdbb84..7b7eba37 100644
--- a/backend/src/plugins/Automod/actions/changePerms.ts
+++ b/backend/src/plugins/Automod/actions/changePerms.ts
@@ -68,10 +68,7 @@ export const ChangePermsAction = automodAction({
   configSchema: z.strictObject({
     target: zBoundedCharacters(1, 2000),
     channel: zBoundedCharacters(1, 2000).nullable().default(null),
-    perms: z.record(
-      z.enum(allPermissionNames),
-      z.boolean().nullable(),
-    ),
+    perms: z.record(z.enum(allPermissionNames), z.boolean().nullable()),
   }),
 
   async apply({ pluginData, contexts, actionConfig }) {
diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts
index 412309cd..2161e2c0 100644
--- a/backend/src/plugins/Automod/actions/kick.ts
+++ b/backend/src/plugins/Automod/actions/kick.ts
@@ -2,9 +2,9 @@ import z from "zod";
 import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
+import { zNotify } from "../constants";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../constants";
 
 export const KickAction = automodAction({
   configSchema: z.strictObject({
diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts
index c2f8e186..ba932b7f 100644
--- a/backend/src/plugins/Automod/actions/mute.ts
+++ b/backend/src/plugins/Automod/actions/mute.ts
@@ -1,12 +1,19 @@
 import z from "zod";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
-import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils";
+import {
+  convertDelayStringToMS,
+  nonNullish,
+  unique,
+  zBoundedCharacters,
+  zDelayString,
+  zSnowflake,
+} from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
+import { zNotify } from "../constants";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../constants";
 
 export const MuteAction = automodAction({
   configSchema: z.strictObject({
@@ -14,8 +21,14 @@ export const MuteAction = automodAction({
     duration: zDelayString.nullable().default(null),
     notify: zNotify.nullable().default(null),
     notifyChannel: zSnowflake.nullable().default(null),
-    remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null),
-    restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null),
+    remove_roles_on_mute: z
+      .union([z.boolean(), z.array(zSnowflake)])
+      .nullable()
+      .default(null),
+    restore_roles_on_mute: z
+      .union([z.boolean(), z.array(zSnowflake)])
+      .nullable()
+      .default(null),
     postInCaseLog: z.boolean().nullable().default(null),
     hide_case: z.boolean().nullable().default(false),
   }),
diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts
index 02ff9545..e8b1fdf8 100644
--- a/backend/src/plugins/Automod/actions/reply.ts
+++ b/backend/src/plugins/Automod/actions/reply.ts
@@ -10,7 +10,7 @@ import {
   verboseChannelMention,
   zBoundedCharacters,
   zDelayString,
-  zMessageContent
+  zMessageContent,
 } from "../../../utils";
 import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts
index 7ccb3898..d8a7805b 100644
--- a/backend/src/plugins/Automod/actions/warn.ts
+++ b/backend/src/plugins/Automod/actions/warn.ts
@@ -2,9 +2,9 @@ import z from "zod";
 import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
+import { zNotify } from "../constants";
 import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
 import { automodAction } from "../helpers";
-import { zNotify } from "../constants";
 
 export const WarnAction = automodAction({
   configSchema: z.strictObject({
diff --git a/backend/src/plugins/Automod/constants.ts b/backend/src/plugins/Automod/constants.ts
index cc37f402..c2baae77 100644
--- a/backend/src/plugins/Automod/constants.ts
+++ b/backend/src/plugins/Automod/constants.ts
@@ -20,7 +20,4 @@ export enum RecentActionType {
   ThreadCreate,
 }
 
-export const zNotify = z.union([
-  z.literal("dm"),
-  z.literal("channel"),
-]);
+export const zNotify = z.union([z.literal("dm"), z.literal("channel")]);
diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
index 562ade6b..cb7eb288 100644
--- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
+++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts
@@ -1,3 +1,4 @@
+import z from "zod";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
 import { humanizeDurationShort } from "../../../humanizeDurationShort";
 import { getBaseUrl } from "../../../pluginUtils";
@@ -7,7 +8,6 @@ import { automodTrigger } from "../helpers";
 import { findRecentSpam } from "./findRecentSpam";
 import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions";
 import { getMessageSpamIdentifier } from "./getSpamIdentifier";
-import z from "zod";
 
 interface TMessageSpamMatchResultType {
   archiveId: string;
diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts
index d1c04d61..b4b9f764 100644
--- a/backend/src/plugins/Automod/helpers.ts
+++ b/backend/src/plugins/Automod/helpers.ts
@@ -1,7 +1,7 @@
 import { GuildPluginData } from "knub";
+import z, { ZodTypeAny } from "zod";
 import { Awaitable } from "../../utils/typeUtils";
 import { AutomodContext, AutomodPluginType } from "./types";
-import z, { ZodTypeAny } from "zod";
 
 interface BaseAutomodTriggerMatchResult {
   extraContexts?: AutomodContext[];
diff --git a/backend/src/plugins/Automod/triggers/antiraidLevel.ts b/backend/src/plugins/Automod/triggers/antiraidLevel.ts
index 1c5f7458..c1467bf8 100644
--- a/backend/src/plugins/Automod/triggers/antiraidLevel.ts
+++ b/backend/src/plugins/Automod/triggers/antiraidLevel.ts
@@ -1,5 +1,5 @@
-import { automodTrigger } from "../helpers";
 import z from "zod";
+import { automodTrigger } from "../helpers";
 
 interface AntiraidLevelTriggerResult {}
 
diff --git a/backend/src/plugins/Automod/triggers/anyMessage.ts b/backend/src/plugins/Automod/triggers/anyMessage.ts
index 93ce0abc..71c92019 100644
--- a/backend/src/plugins/Automod/triggers/anyMessage.ts
+++ b/backend/src/plugins/Automod/triggers/anyMessage.ts
@@ -1,7 +1,7 @@
 import { Snowflake } from "discord.js";
+import z from "zod";
 import { verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
-import z from "zod";
 
 interface AnyMessageResultType {}
 
diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
index 6dd49580..8c562eb1 100644
--- a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
+++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts
@@ -1,6 +1,6 @@
 import { escapeInlineCode, Snowflake } from "discord.js";
-import z from "zod";
 import { extname } from "path";
+import z from "zod";
 import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils";
 import { automodTrigger } from "../helpers";
 
@@ -9,28 +9,30 @@ interface MatchResultType {
   mode: "blacklist" | "whitelist";
 }
 
-const configSchema = z.strictObject({
-  filetype_blacklist: z.array(z.string().max(32)).max(255).default([]),
-  blacklist_enabled: z.boolean().default(false),
-  filetype_whitelist: z.array(z.string().max(32)).max(255).default([]),
-  whitelist_enabled: z.boolean().default(false),
-}).transform((parsed, ctx) => {
-  if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
-    ctx.addIssue({
-      code: z.ZodIssueCode.custom,
-      message: "Cannot have both blacklist and whitelist enabled",
-    });
-    return z.NEVER;
-  }
-  if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) {
-    ctx.addIssue({
-      code: z.ZodIssueCode.custom,
-      message: "Must have either blacklist or whitelist enabled",
-    });
-    return z.NEVER;
-  }
-  return parsed;
-});
+const configSchema = z
+  .strictObject({
+    filetype_blacklist: z.array(z.string().max(32)).max(255).default([]),
+    blacklist_enabled: z.boolean().default(false),
+    filetype_whitelist: z.array(z.string().max(32)).max(255).default([]),
+    whitelist_enabled: z.boolean().default(false),
+  })
+  .transform((parsed, ctx) => {
+    if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Cannot have both blacklist and whitelist enabled",
+      });
+      return z.NEVER;
+    }
+    if (!parsed.blacklist_enabled && !parsed.whitelist_enabled) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Must have either blacklist or whitelist enabled",
+      });
+      return z.NEVER;
+    }
+    return parsed;
+  });
 
 export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
   configSchema,
diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts
index fe3280fc..c43d8320 100644
--- a/backend/src/plugins/Automod/triggers/matchLinks.ts
+++ b/backend/src/plugins/Automod/triggers/matchLinks.ts
@@ -26,12 +26,20 @@ const configSchema = z.strictObject({
   include_subdomains: z.boolean().default(true),
   include_words: z.array(z.string().max(2000)).max(700).optional(),
   exclude_words: z.array(z.string().max(2000)).max(700).optional(),
-  include_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
-  exclude_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(),
-  phisherman: z.strictObject({
-    include_suspected: z.boolean().optional(),
-    include_verified: z.boolean().optional(),
-  }).optional(),
+  include_regex: z
+    .array(zRegex(z.string().max(2000)))
+    .max(512)
+    .optional(),
+  exclude_regex: z
+    .array(zRegex(z.string().max(2000)))
+    .max(512)
+    .optional(),
+  phisherman: z
+    .strictObject({
+      include_suspected: z.boolean().optional(),
+      include_verified: z.boolean().optional(),
+    })
+    .optional(),
   only_real_links: z.boolean().default(true),
   match_messages: z.boolean().default(true),
   match_embeds: z.boolean().default(true),
diff --git a/backend/src/plugins/Automod/triggers/matchMimeType.ts b/backend/src/plugins/Automod/triggers/matchMimeType.ts
index 8da9b2e3..86232916 100644
--- a/backend/src/plugins/Automod/triggers/matchMimeType.ts
+++ b/backend/src/plugins/Automod/triggers/matchMimeType.ts
@@ -8,28 +8,30 @@ interface MatchResultType {
   mode: "blacklist" | "whitelist";
 }
 
-const configSchema = z.strictObject({
-  mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]),
-  blacklist_enabled: z.boolean().default(false),
-  mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]),
-  whitelist_enabled: z.boolean().default(false),
-}).transform((parsed, ctx) => {
-  if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
-    ctx.addIssue({
-      code: z.ZodIssueCode.custom,
-      message: "Cannot have both blacklist and whitelist enabled",
-    });
-    return z.NEVER;
-  }
-  if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) {
-    ctx.addIssue({
-      code: z.ZodIssueCode.custom,
-      message: "Must have either blacklist or whitelist enabled",
-    });
-    return z.NEVER;
-  }
-  return parsed;
-});
+const configSchema = z
+  .strictObject({
+    mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]),
+    blacklist_enabled: z.boolean().default(false),
+    mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]),
+    whitelist_enabled: z.boolean().default(false),
+  })
+  .transform((parsed, ctx) => {
+    if (parsed.blacklist_enabled && parsed.whitelist_enabled) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Cannot have both blacklist and whitelist enabled",
+      });
+      return z.NEVER;
+    }
+    if (!parsed.blacklist_enabled && !parsed.whitelist_enabled) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: "Must have either blacklist or whitelist enabled",
+      });
+      return z.NEVER;
+    }
+    return parsed;
+  });
 
 export const MatchMimeTypeTrigger = automodTrigger<MatchResultType>()({
   configSchema,
diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts
index 47027b6b..ce1e0ab5 100644
--- a/backend/src/plugins/Automod/triggers/roleAdded.ts
+++ b/backend/src/plugins/Automod/triggers/roleAdded.ts
@@ -8,10 +8,7 @@ interface RoleAddedMatchResult {
   matchedRoleId: string;
 }
 
-const configSchema = z.union([
-  zSnowflake,
-  z.array(zSnowflake).max(255),
-]).default([]);
+const configSchema = z.union([zSnowflake, z.array(zSnowflake).max(255)]).default([]);
 
 export const RoleAddedTrigger = automodTrigger<RoleAddedMatchResult>()({
   configSchema,
diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts
index 26caf227..f11dafeb 100644
--- a/backend/src/plugins/Automod/triggers/roleRemoved.ts
+++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts
@@ -8,10 +8,7 @@ interface RoleAddedMatchResult {
   matchedRoleId: string;
 }
 
-const configSchema = z.union([
-  zSnowflake,
-  z.array(zSnowflake).max(255),
-]).default([]);
+const configSchema = z.union([zSnowflake, z.array(zSnowflake).max(255)]).default([]);
 
 export const RoleRemovedTrigger = automodTrigger<RoleAddedMatchResult>()({
   configSchema,
diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts
index 2cf2df2f..6cf88ad6 100644
--- a/backend/src/plugins/Automod/types.ts
+++ b/backend/src/plugins/Automod/types.ts
@@ -19,58 +19,62 @@ import { availableTriggers } from "./triggers/availableTriggers";
 import Timeout = NodeJS.Timeout;
 
 export type ZTriggersMapHelper = {
-  [TriggerName in keyof typeof availableTriggers]: typeof availableTriggers[TriggerName]["configSchema"];
+  [TriggerName in keyof typeof availableTriggers]: (typeof availableTriggers)[TriggerName]["configSchema"];
 };
-const zTriggersMap = z.strictObject(entries(availableTriggers).reduce((map, [triggerName, trigger]) => {
-  map[triggerName] = trigger.configSchema;
-  return map;
-}, {} as ZTriggersMapHelper)).partial();
+const zTriggersMap = z
+  .strictObject(
+    entries(availableTriggers).reduce((map, [triggerName, trigger]) => {
+      map[triggerName] = trigger.configSchema;
+      return map;
+    }, {} as ZTriggersMapHelper),
+  )
+  .partial();
 
 type ZActionsMapHelper = {
-  [ActionName in keyof typeof availableActions]: typeof availableActions[ActionName]["configSchema"];
+  [ActionName in keyof typeof availableActions]: (typeof availableActions)[ActionName]["configSchema"];
 };
-const zActionsMap = z.strictObject(entries(availableActions).reduce((map, [actionName, action]) => {
-  // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper
-  map[actionName] = action.configSchema;
-  return map;
-}, {} as ZActionsMapHelper)).partial();
+const zActionsMap = z
+  .strictObject(
+    entries(availableActions).reduce((map, [actionName, action]) => {
+      // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper
+      map[actionName] = action.configSchema;
+      return map;
+    }, {} as ZActionsMapHelper),
+  )
+  .partial();
 
 const zRule = z.strictObject({
   enabled: z.boolean().default(true),
   // Typed as "never" because you are not expected to supply this directly.
   // The transform instead picks it up from the property key and the output type is a string.
-  name: z.never().optional().transform((_, ctx) => {
-    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
-    if (! ruleName) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: "Automod rules must have names",
-      });
-      return z.NEVER;
-    }
-    return ruleName;
-  }),
+  name: z
+    .never()
+    .optional()
+    .transform((_, ctx) => {
+      const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+      if (!ruleName) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Automod rules must have names",
+        });
+        return z.NEVER;
+      }
+      return ruleName;
+    }),
   presets: z.array(z.string().max(100)).max(25).default([]),
   affects_bots: z.boolean().default(false),
   affects_self: z.boolean().default(false),
   cooldown: zDelayString.nullable().default(null),
   allow_further_rules: z.boolean().default(false),
   triggers: z.array(zTriggersMap),
-  actions: zActionsMap.refine(
-    (v) => ! (v.clean && v.start_thread),
-    {
-      message: "Cannot have both clean and start_thread active at the same time",
-    }
-  ),
+  actions: zActionsMap.refine((v) => !(v.clean && v.start_thread), {
+    message: "Cannot have both clean and start_thread active at the same time",
+  }),
 });
 export type TRule = z.infer<typeof zRule>;
 
 export const zAutomodConfig = z.strictObject({
-  rules: zBoundedRecord(
-    z.record(z.string().max(100), zRule),
-    0,
-    255,
-  ),
+  rules: zBoundedRecord(z.record(z.string().max(100), zRule), 0, 255),
   antiraid_levels: z.array(z.string().max(100)).max(10),
   can_set_antiraid: z.boolean(),
   can_view_antiraid: z.boolean(),
diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts
index c6eedbc6..16b48a4a 100644
--- a/backend/src/plugins/ContextMenus/actions/mute.ts
+++ b/backend/src/plugins/ContextMenus/actions/mute.ts
@@ -2,13 +2,13 @@ import { ContextMenuCommandInteraction } from "discord.js";
 import humanizeDuration from "humanize-duration";
 import { GuildPluginData } from "knub";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
+import { canActOn } from "../../../pluginUtils";
 import { convertDelayStringToMS } from "../../../utils";
 import { CaseArgs } from "../../Cases/types";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
 import { MutesPlugin } from "../../Mutes/MutesPlugin";
 import { ContextMenuPluginType } from "../types";
-import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
-import { canActOn } from "../../../pluginUtils";
 
 export async function muteAction(
   pluginData: GuildPluginData<ContextMenuPluginType>,
diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts
index 85be2cf5..1cc1ec25 100644
--- a/backend/src/plugins/Counters/CountersPlugin.ts
+++ b/backend/src/plugins/Counters/CountersPlugin.ts
@@ -1,10 +1,7 @@
 import { EventEmitter } from "events";
 import { PluginOptions } from "knub";
 import { GuildCounters } from "../../data/GuildCounters";
-import {
-  CounterTrigger,
-  parseCounterConditionString
-} from "../../data/entities/CounterTrigger";
+import { CounterTrigger, parseCounterConditionString } from "../../data/entities/CounterTrigger";
 import { mapToPublicFn } from "../../pluginUtils";
 import { MINUTES, convertDelayStringToMS, values } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts
index 783ec666..8e70c4ab 100644
--- a/backend/src/plugins/Counters/types.ts
+++ b/backend/src/plugins/Counters/types.ts
@@ -2,33 +2,45 @@ import { EventEmitter } from "events";
 import { BasePluginType } from "knub";
 import z from "zod";
 import { GuildCounters } from "../../data/GuildCounters";
-import { CounterTrigger, buildCounterConditionString, getReverseCounterComparisonOp, parseCounterConditionString } from "../../data/entities/CounterTrigger";
+import {
+  CounterTrigger,
+  buildCounterConditionString,
+  getReverseCounterComparisonOp,
+  parseCounterConditionString,
+} from "../../data/entities/CounterTrigger";
 import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils";
 import Timeout = NodeJS.Timeout;
 
 const MAX_COUNTERS = 5;
 const MAX_TRIGGERS_PER_COUNTER = 5;
 
-export const zTrigger = z.strictObject({
-  // Dummy type because name gets replaced by the property key in transform()
-  name: z.never().optional().transform(() => ""),
-  pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
-  condition: zBoundedCharacters(1, 64).refine(
-    (str) => parseCounterConditionString(str) !== null,
-    { message: "Invalid counter trigger condition" },
-  ),
-  reverse_condition: zBoundedCharacters(1, 64).refine(
-    (str) => parseCounterConditionString(str) !== null,
-    { message: "Invalid counter trigger reverse condition" },
-  ).optional(),
-})
+export const zTrigger = z
+  .strictObject({
+    // Dummy type because name gets replaced by the property key in transform()
+    name: z
+      .never()
+      .optional()
+      .transform(() => ""),
+    pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
+    condition: zBoundedCharacters(1, 64).refine((str) => parseCounterConditionString(str) !== null, {
+      message: "Invalid counter trigger condition",
+    }),
+    reverse_condition: zBoundedCharacters(1, 64)
+      .refine((str) => parseCounterConditionString(str) !== null, {
+        message: "Invalid counter trigger reverse condition",
+      })
+      .optional(),
+  })
   .transform((val, ctx) => {
     const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
 
     let reverseCondition = val.reverse_condition;
-    if (! reverseCondition) {
+    if (!reverseCondition) {
       const parsedCondition = parseCounterConditionString(val.condition)!;
-      reverseCondition = buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]);
+      reverseCondition = buildCounterConditionString(
+        getReverseCounterComparisonOp(parsedCondition[0]),
+        parsedCondition[1],
+      );
     }
 
     return {
@@ -38,68 +50,65 @@ export const zTrigger = z.strictObject({
     };
   });
 
-const zTriggerFromString = zBoundedCharacters(0, 100)
-  .transform((val, ctx) => {
-    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
-    const parsedCondition = parseCounterConditionString(val);
-    if (!parsedCondition) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: "Invalid counter trigger condition",
-      });
-      return z.NEVER;
-    }
-    return {
-      name: ruleName,
-      pretty_name: null,
-      condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),
-      reverse_condition: buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]),
-    };
-  });
+const zTriggerFromString = zBoundedCharacters(0, 100).transform((val, ctx) => {
+  const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+  const parsedCondition = parseCounterConditionString(val);
+  if (!parsedCondition) {
+    ctx.addIssue({
+      code: z.ZodIssueCode.custom,
+      message: "Invalid counter trigger condition",
+    });
+    return z.NEVER;
+  }
+  return {
+    name: ruleName,
+    pretty_name: null,
+    condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]),
+    reverse_condition: buildCounterConditionString(
+      getReverseCounterComparisonOp(parsedCondition[0]),
+      parsedCondition[1],
+    ),
+  };
+});
 
 const zTriggerInput = z.union([zTrigger, zTriggerFromString]);
 
 export const zCounter = z.strictObject({
   // Typed as "never" because you are not expected to supply this directly.
   // The transform instead picks it up from the property key and the output type is a string.
-  name: z.never().optional().transform((_, ctx) => {
-    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
-    if (! ruleName) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: "Counters must have names",
-      });
-      return z.NEVER;
-    }
-    return ruleName;
-  }),
+  name: z
+    .never()
+    .optional()
+    .transform((_, ctx) => {
+      const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+      if (!ruleName) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Counters must have names",
+        });
+        return z.NEVER;
+      }
+      return ruleName;
+    }),
   pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
   per_channel: z.boolean().default(false),
   per_user: z.boolean().default(false),
   initial_value: z.number().default(0),
-  triggers: zBoundedRecord(
-    z.record(
-      zBoundedCharacters(0, 100),
-      zTriggerInput,
-    ),
-    1,
-    MAX_TRIGGERS_PER_COUNTER,
-  ),
-  decay: z.strictObject({
-    amount: z.number(),
-    every: zDelayString,
-  }).nullable().default(null),
+  triggers: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zTriggerInput), 1, MAX_TRIGGERS_PER_COUNTER),
+  decay: z
+    .strictObject({
+      amount: z.number(),
+      every: zDelayString,
+    })
+    .nullable()
+    .default(null),
   can_view: z.boolean().default(false),
   can_edit: z.boolean().default(false),
   can_reset_all: z.boolean().default(false),
 });
 
 export const zCountersConfig = z.strictObject({
-  counters: zBoundedRecord(
-    z.record(zBoundedCharacters(0, 100), zCounter),
-    0,
-    MAX_COUNTERS,
-  ),
+  counters: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCounter), 0, MAX_COUNTERS),
   can_view: z.boolean(),
   can_edit: z.boolean(),
   can_reset_all: z.boolean(),
diff --git a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
index dbd7b932..b2e44d93 100644
--- a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
+++ b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts
@@ -9,14 +9,16 @@ import { CustomEventsPluginType, TCustomEvent } from "../types";
 export const zSetChannelPermissionOverridesAction = z.strictObject({
   type: z.literal("set_channel_permission_overrides"),
   channel: zSnowflake,
-  overrides: z.array(
-    z.strictObject({
-      type: z.union([z.literal("member"), z.literal("role")]),
-      id: zSnowflake,
-      allow: z.number(),
-      deny: z.number(),
-    }),
-  ).max(15),
+  overrides: z
+    .array(
+      z.strictObject({
+        type: z.union([z.literal("member"), z.literal("role")]),
+        id: zSnowflake,
+        allow: z.number(),
+        deny: z.number(),
+      }),
+    )
+    .max(15),
 });
 export type TSetChannelPermissionOverridesAction = z.infer<typeof zSetChannelPermissionOverridesAction>;
 
diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts
index 4572b1cf..6433b6d0 100644
--- a/backend/src/plugins/CustomEvents/types.ts
+++ b/backend/src/plugins/CustomEvents/types.ts
@@ -36,11 +36,7 @@ export const zCustomEvent = z.strictObject({
 export type TCustomEvent = z.infer<typeof zCustomEvent>;
 
 export const zCustomEventsConfig = z.strictObject({
-  events: zBoundedRecord(
-    z.record(zBoundedCharacters(0, 100), zCustomEvent),
-    0,
-    100,
-  ),
+  events: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zCustomEvent), 0, 100),
 });
 
 export interface CustomEventsPluginType extends BasePluginType {
diff --git a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
index b8639134..3c267aab 100644
--- a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
+++ b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts
@@ -1,10 +1,10 @@
 import { Guild } from "discord.js";
 import { BasePluginType, GlobalPluginData, globalPluginEventListener } from "knub";
+import z from "zod";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { Configs } from "../../data/Configs";
 import { env } from "../../env";
 import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint";
-import z from "zod";
 
 interface GuildAccessMonitorPluginType extends BasePluginType {
   state: {
diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
index a8d71e06..873711b3 100644
--- a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
+++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts
@@ -1,11 +1,11 @@
 import { Guild } from "discord.js";
 import { guildPluginEventListener } from "knub";
+import z from "zod";
 import { AllowedGuilds } from "../../data/AllowedGuilds";
 import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
 import { MINUTES } from "../../utils";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { GuildInfoSaverPluginType } from "./types";
-import z from "zod";
 
 export const GuildInfoSaverPlugin = zeppelinGuildPlugin<GuildInfoSaverPluginType>()({
   name: "guild_info_saver",
diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts
index 923ac623..c989c682 100644
--- a/backend/src/plugins/Logs/LogsPlugin.ts
+++ b/backend/src/plugins/Logs/LogsPlugin.ts
@@ -123,7 +123,7 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
       timestamp: FORMAT_NO_TIMESTAMP,
       ...DefaultLogMessages,
     },
-    ping_user: true, 
+    ping_user: true,
     allow_user_mentions: false,
     timestamp_format: "[<t:]X[>]",
     include_embed_timestamp: true,
diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
index 25c8c340..b56f7410 100644
--- a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
+++ b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts
@@ -32,7 +32,9 @@ export function logMessageDelete(pluginData: GuildPluginData<LogsPluginType>, da
   // See comment on FORMAT_NO_TIMESTAMP in types.ts
   const config = pluginData.config.get();
   const timestampFormat =
-    (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format ?? undefined;
+    (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ??
+    config.timestamp_format ??
+    undefined;
 
   return log(
     pluginData,
diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts
index 6b214c99..47557514 100644
--- a/backend/src/plugins/Logs/types.ts
+++ b/backend/src/plugins/Logs/types.ts
@@ -29,10 +29,12 @@ const MAX_BATCH_TIME = 5000;
 type ZLogFormatsHelper = {
   -readonly [K in keyof typeof LogType]: typeof zMessageContent;
 };
-export const zLogFormats = z.strictObject(keys(LogType).reduce((map, logType) => {
-  map[logType] = zMessageContent;
-  return map;
-}, {} as ZLogFormatsHelper));
+export const zLogFormats = z.strictObject(
+  keys(LogType).reduce((map, logType) => {
+    map[logType] = zMessageContent;
+    return map;
+  }, {} as ZLogFormatsHelper),
+);
 export type TLogFormats = z.infer<typeof zLogFormats>;
 
 const zLogChannel = z.strictObject({
@@ -58,10 +60,12 @@ export type TLogChannelMap = z.infer<typeof zLogChannelMap>;
 
 export const zLogsConfig = z.strictObject({
   channels: zLogChannelMap,
-  format: zLogFormats.merge(z.strictObject({
-    // Legacy/deprecated, use timestamp_format below instead
-    timestamp: zBoundedCharacters(0, 64).nullable(),
-  })),
+  format: zLogFormats.merge(
+    z.strictObject({
+      // Legacy/deprecated, use timestamp_format below instead
+      timestamp: zBoundedCharacters(0, 64).nullable(),
+    }),
+  ),
   // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
   ping_user: z.boolean(),
   allow_user_mentions: z.boolean(),
diff --git a/backend/src/plugins/Logs/util/isLogIgnored.ts b/backend/src/plugins/Logs/util/isLogIgnored.ts
index 71255f50..8d3e15c1 100644
--- a/backend/src/plugins/Logs/util/isLogIgnored.ts
+++ b/backend/src/plugins/Logs/util/isLogIgnored.ts
@@ -2,6 +2,10 @@ import { GuildPluginData } from "knub";
 import { LogType } from "../../../data/LogType";
 import { LogsPluginType } from "../types";
 
-export function isLogIgnored(pluginData: GuildPluginData<LogsPluginType>, type: keyof typeof LogType, ignoreId: string) {
+export function isLogIgnored(
+  pluginData: GuildPluginData<LogsPluginType>,
+  type: keyof typeof LogType,
+  ignoreId: string,
+) {
   return pluginData.state.guildLogs.isLogIgnored(type, ignoreId);
 }
diff --git a/backend/src/plugins/ModActions/functions/clearTempban.ts b/backend/src/plugins/ModActions/functions/clearTempban.ts
index 5173ca7e..1133d651 100644
--- a/backend/src/plugins/ModActions/functions/clearTempban.ts
+++ b/backend/src/plugins/ModActions/functions/clearTempban.ts
@@ -3,7 +3,9 @@ import humanizeDuration from "humanize-duration";
 import { GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { CaseTypes } from "../../../data/CaseTypes";
+import { LogType } from "../../../data/LogType";
 import { Tempban } from "../../../data/entities/Tempban";
+import { logger } from "../../../logger";
 import { resolveUser } from "../../../utils";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
@@ -11,8 +13,6 @@ import { IgnoredEventType, ModActionsPluginType } from "../types";
 import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
 import { ignoreEvent } from "./ignoreEvent";
 import { isBanned } from "./isBanned";
-import { LogType } from "../../../data/LogType";
-import { logger } from "../../../logger";
 
 export async function clearTempban(pluginData: GuildPluginData<ModActionsPluginType>, tempban: Tempban) {
   if (!(await isBanned(pluginData, tempban.user_id))) {
diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts
index a1c09f00..b955c6fa 100644
--- a/backend/src/plugins/ReactionRoles/types.ts
+++ b/backend/src/plugins/ReactionRoles/types.ts
@@ -22,10 +22,7 @@ export type PendingMemberRoleChanges = {
   }>;
 };
 
-const zReactionRolePair = z.union([
-  z.tuple([z.string(), z.string(), z.string()]),
-  z.tuple([z.string(), z.string()]),
-]);
+const zReactionRolePair = z.union([z.tuple([z.string(), z.string(), z.string()]), z.tuple([z.string(), z.string()])]);
 export type TReactionRolePair = z.infer<typeof zReactionRolePair>;
 
 export interface ReactionRolesPluginType extends BasePluginType {
diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts
index 3c94f8a8..95e46f79 100644
--- a/backend/src/plugins/RoleButtons/types.ts
+++ b/backend/src/plugins/RoleButtons/types.ts
@@ -11,89 +11,99 @@ const zRoleButtonOption = z.strictObject({
   label: z.string().nullable().default(null),
   emoji: z.string().nullable().default(null),
   // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle
-  style: z.union([
-    z.literal(ButtonStyle.Primary),
-    z.literal(ButtonStyle.Secondary),
-    z.literal(ButtonStyle.Success),
-    z.literal(ButtonStyle.Danger),
+  style: z
+    .union([
+      z.literal(ButtonStyle.Primary),
+      z.literal(ButtonStyle.Secondary),
+      z.literal(ButtonStyle.Success),
+      z.literal(ButtonStyle.Danger),
 
-    // The following are deprecated
-    z.literal("PRIMARY"),
-    z.literal("SECONDARY"),
-    z.literal("SUCCESS"),
-    z.literal("DANGER"),
-    // z.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
-  ]).nullable().default(null),
+      // The following are deprecated
+      z.literal("PRIMARY"),
+      z.literal("SECONDARY"),
+      z.literal("SUCCESS"),
+      z.literal("DANGER"),
+      // z.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available
+    ])
+    .nullable()
+    .default(null),
   start_new_row: z.boolean().default(false),
 });
 export type TRoleButtonOption = z.infer<typeof zRoleButtonOption>;
 
-const zRoleButtonsConfigItem = z.strictObject({
-  // Typed as "never" because you are not expected to supply this directly.
-  // The transform instead picks it up from the property key and the output type is a string.
-  name: z.never().optional().transform((_, ctx) => {
-    const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
-    if (! ruleName) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        message: "Role buttons must have names",
-      });
-      return z.NEVER;
-    }
-    return ruleName;
-  }),
-  message: z.union([
-    z.strictObject({
-      channel_id: zSnowflake,
-      message_id: zSnowflake,
-    }),
-    z.strictObject({
-      channel_id: zSnowflake,
-      content: zMessageContent,
-    }),
-  ]),
-  options: z.array(zRoleButtonOption).max(25),
-  exclusive: z.boolean().default(false),
-})
-  .refine((parsed) => {
-    try {
-      createButtonComponents(parsed);
-    } catch (err) {
-      if (err instanceof TooManyComponentsError) {
-        return false;
+const zRoleButtonsConfigItem = z
+  .strictObject({
+    // Typed as "never" because you are not expected to supply this directly.
+    // The transform instead picks it up from the property key and the output type is a string.
+    name: z
+      .never()
+      .optional()
+      .transform((_, ctx) => {
+        const ruleName = String(ctx.path[ctx.path.length - 2]).trim();
+        if (!ruleName) {
+          ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: "Role buttons must have names",
+          });
+          return z.NEVER;
+        }
+        return ruleName;
+      }),
+    message: z.union([
+      z.strictObject({
+        channel_id: zSnowflake,
+        message_id: zSnowflake,
+      }),
+      z.strictObject({
+        channel_id: zSnowflake,
+        content: zMessageContent,
+      }),
+    ]),
+    options: z.array(zRoleButtonOption).max(25),
+    exclusive: z.boolean().default(false),
+  })
+  .refine(
+    (parsed) => {
+      try {
+        createButtonComponents(parsed);
+      } catch (err) {
+        if (err instanceof TooManyComponentsError) {
+          return false;
+        }
+        throw err;
       }
-      throw err;
-    }
-    return true;
-  }, {
-    message: "Too many options; can only have max 5 buttons per row on max 5 rows."
-  });
+      return true;
+    },
+    {
+      message: "Too many options; can only have max 5 buttons per row on max 5 rows.",
+    },
+  );
 export type TRoleButtonsConfigItem = z.infer<typeof zRoleButtonsConfigItem>;
 
-export const zRoleButtonsConfig = z.strictObject({
-  buttons: zBoundedRecord(
-    z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem),
-    0,
-    100,
-  ),
-  can_reset: z.boolean(),
-})
-  .refine((parsed) => {
-    const seenMessages = new Set();
-    for (const button of Object.values(parsed.buttons)) {
-      if (button.message) {
-        if ("message_id" in button.message) {
-          if (seenMessages.has(button.message.message_id)) {
-            return false;
+export const zRoleButtonsConfig = z
+  .strictObject({
+    buttons: zBoundedRecord(z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem), 0, 100),
+    can_reset: z.boolean(),
+  })
+  .refine(
+    (parsed) => {
+      const seenMessages = new Set();
+      for (const button of Object.values(parsed.buttons)) {
+        if (button.message) {
+          if ("message_id" in button.message) {
+            if (seenMessages.has(button.message.message_id)) {
+              return false;
+            }
+            seenMessages.add(button.message.message_id);
           }
-          seenMessages.add(button.message.message_id);
         }
       }
-    }
-    return true;
-  }, {
-    message: "Can't target the same message with two sets of role buttons",
-  });
+      return true;
+    },
+    {
+      message: "Can't target the same message with two sets of role buttons",
+    },
+  );
 
 export interface RoleButtonsPluginType extends BasePluginType {
   config: z.infer<typeof zRoleButtonsConfig>;
diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts
index a1aed241..e3c7a786 100644
--- a/backend/src/plugins/SelfGrantableRoles/types.ts
+++ b/backend/src/plugins/SelfGrantableRoles/types.ts
@@ -4,9 +4,10 @@ import { zBoundedCharacters, zBoundedRecord } from "../../utils";
 
 const zRoleMap = z.record(
   zBoundedCharacters(1, 100),
-  z.array(zBoundedCharacters(1, 2000))
+  z
+    .array(zBoundedCharacters(1, 2000))
     .max(100)
-    .transform((parsed) => parsed.map(v => v.toLowerCase())),
+    .transform((parsed) => parsed.map((v) => v.toLowerCase())),
 );
 
 const zSelfGrantableRoleEntry = z.strictObject({
diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts
index c4fe44d5..7c8d5b56 100644
--- a/backend/src/plugins/Slowmode/types.ts
+++ b/backend/src/plugins/Slowmode/types.ts
@@ -7,7 +7,7 @@ import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel";
 
 export const zSlowmodeConfig = z.strictObject({
   use_native_slowmode: z.boolean(),
-  
+
   can_manage: z.boolean(),
   is_affected: z.boolean(),
 });
diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts
index 74ea872f..f71e4180 100644
--- a/backend/src/plugins/Spam/types.ts
+++ b/backend/src/plugins/Spam/types.ts
@@ -11,14 +11,8 @@ const zBaseSingleSpamConfig = z.strictObject({
   count: z.number(),
   mute: z.boolean().default(false),
   mute_time: z.number().nullable().default(null),
-  remove_roles_on_mute: z.union([
-    z.boolean(),
-    z.array(zSnowflake),
-  ]).default(false),
-  restore_roles_on_mute: z.union([
-    z.boolean(),
-    z.array(zSnowflake),
-  ]).default(false),
+  remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),
+  restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false),
   clean: z.boolean().default(false),
 });
 export type TBaseSingleSpamConfig = z.infer<typeof zBaseSingleSpamConfig>;
diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts
index 7dc2d8f5..bb8845fe 100644
--- a/backend/src/plugins/Starboard/types.ts
+++ b/backend/src/plugins/Starboard/types.ts
@@ -18,11 +18,7 @@ const zStarboardOpts = z.strictObject({
 export type TStarboardOpts = z.infer<typeof zStarboardOpts>;
 
 export const zStarboardConfig = z.strictObject({
-  boards: zBoundedRecord(
-    z.record(z.string(), zStarboardOpts),
-    0,
-    100,
-  ),
+  boards: zBoundedRecord(z.record(z.string(), zStarboardOpts), 0, 100),
   can_migrate: z.boolean(),
 });
 
diff --git a/backend/src/plugins/Tags/commands/TagEvalCmd.ts b/backend/src/plugins/Tags/commands/TagEvalCmd.ts
index 27cbe4f3..3515ef74 100644
--- a/backend/src/plugins/Tags/commands/TagEvalCmd.ts
+++ b/backend/src/plugins/Tags/commands/TagEvalCmd.ts
@@ -1,11 +1,11 @@
 import { MessageCreateOptions } from "discord.js";
 import { commandTypeHelpers as ct } from "../../../commandTypes";
+import { logger } from "../../../logger";
 import { sendErrorMessage } from "../../../pluginUtils";
 import { TemplateParseError } from "../../../templateFormatter";
 import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { tagsCmd } from "../types";
 import { renderTagBody } from "../util/renderTagBody";
-import { logger } from "../../../logger";
 
 export const TagEvalCmd = tagsCmd({
   trigger: "tag eval",
@@ -35,13 +35,11 @@ export const TagEvalCmd = tagsCmd({
 
       msg.channel.send(rendered);
     } catch (e) {
-      const errorMessage = e instanceof TemplateParseError
-        ? e.message
-        : "Internal error";
+      const errorMessage = e instanceof TemplateParseError ? e.message : "Internal error";
 
       sendErrorMessage(pluginData, msg.channel, `Failed to render tag: ${errorMessage}`);
 
-      if (! (e instanceof TemplateParseError)) {
+      if (!(e instanceof TemplateParseError)) {
         logger.warn(`Internal error evaluating tag in ${pluginData.guild.id}: ${e}`);
       }
 
diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts
index 441906fe..5ff7e9c7 100644
--- a/backend/src/plugins/Tags/types.ts
+++ b/backend/src/plugins/Tags/types.ts
@@ -9,52 +9,48 @@ import { zEmbedInput } from "../../utils";
 export const zTag = z.union([z.string(), zEmbedInput]);
 export type TTag = z.infer<typeof zTag>;
 
-export const zTagCategory = z.strictObject({
-  prefix: z.string().nullable().default(null),
-  delete_with_command: z.boolean().default(false),
+export const zTagCategory = z
+  .strictObject({
+    prefix: z.string().nullable().default(null),
+    delete_with_command: z.boolean().default(false),
 
-  user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag
-  user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category
-  global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag
-  allow_mentions: z.boolean().nullable().default(null),
-  global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category
-  auto_delete_command: z.boolean().nullable().default(null),
+    user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag
+    user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category
+    global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag
+    allow_mentions: z.boolean().nullable().default(null),
+    global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category
+    auto_delete_command: z.boolean().nullable().default(null),
 
-  tags: z.record(z.string(), zTag),
+    tags: z.record(z.string(), zTag),
 
-  can_use: z.boolean().nullable().default(null),
-})
-  .refine(
-    (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command),
-    {
-      message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
-    },
-  );
+    can_use: z.boolean().nullable().default(null),
+  })
+  .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), {
+    message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
+  });
 export type TTagCategory = z.infer<typeof zTagCategory>;
 
-export const zTagsConfig = z.strictObject({
-  prefix: z.string(),
-  delete_with_command: z.boolean(),
+export const zTagsConfig = z
+  .strictObject({
+    prefix: z.string(),
+    delete_with_command: z.boolean(),
 
-  user_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user, per tag
-  global_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Any user, per tag
-  user_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user
-  allow_mentions: z.boolean(), // Per user
-  global_cooldown: z.union([z.string(), z.number()]).nullable(), // Any tag use
-  auto_delete_command: z.boolean(), // Any tag
+    user_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user, per tag
+    global_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Any user, per tag
+    user_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user
+    allow_mentions: z.boolean(), // Per user
+    global_cooldown: z.union([z.string(), z.number()]).nullable(), // Any tag use
+    auto_delete_command: z.boolean(), // Any tag
 
-  categories: z.record(z.string(), zTagCategory),
+    categories: z.record(z.string(), zTagCategory),
 
-  can_create: z.boolean(),
-  can_use: z.boolean(),
-  can_list: z.boolean(),
-})
-.refine(
-  (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command),
-  {
+    can_create: z.boolean(),
+    can_use: z.boolean(),
+    can_list: z.boolean(),
+  })
+  .refine((parsed) => !(parsed.auto_delete_command && parsed.delete_with_command), {
     message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled",
-  },
-);
+  });
 
 export interface TagsPluginType extends BasePluginType {
   config: z.infer<typeof zTagsConfig>;
diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts
index aa7c8490..f06d3282 100644
--- a/backend/src/plugins/Tags/util/onMessageCreate.ts
+++ b/backend/src/plugins/Tags/util/onMessageCreate.ts
@@ -2,11 +2,11 @@ import { Snowflake, TextChannel } from "discord.js";
 import { GuildPluginData } from "knub";
 import { SavedMessage } from "../../../data/entities/SavedMessage";
 import { convertDelayStringToMS, resolveMember, zStrictMessageContent } from "../../../utils";
+import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions";
 import { messageIsEmpty } from "../../../utils/messageIsEmpty";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TagsPluginType } from "../types";
 import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString";
-import { erisAllowedMentionsToDjsMentionOptions } from "../../../utils/erisAllowedMentionsToDjsMentionOptions";
 
 export async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType>, msg: SavedMessage) {
   if (msg.is_bot) return;
@@ -85,7 +85,7 @@ export async function onMessageCreate(pluginData: GuildPluginData<TagsPluginType
   }
 
   const validated = zStrictMessageContent.safeParse(tagResult.renderedContent);
-  if (! validated.success) {
+  if (!validated.success) {
     pluginData.getPlugin(LogsPlugin).logBotAlert({
       body: `Rendering tag ${tagResult.tagName} resulted in an invalid message: ${validated.error.message}`,
     });
diff --git a/backend/src/plugins/Tags/util/renderTagFromString.ts b/backend/src/plugins/Tags/util/renderTagFromString.ts
index f1265858..10306777 100644
--- a/backend/src/plugins/Tags/util/renderTagFromString.ts
+++ b/backend/src/plugins/Tags/util/renderTagFromString.ts
@@ -1,13 +1,13 @@
 import { GuildMember } from "discord.js";
 import { GuildPluginData } from "knub";
 import { parseArguments } from "knub-command-manager";
+import { logger } from "../../../logger";
 import { TemplateParseError } from "../../../templateFormatter";
 import { StrictMessageContent, validateAndParseMessageContent } from "../../../utils";
 import { memberToTemplateSafeMember, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { TTag, TagsPluginType } from "../types";
 import { renderTagBody } from "./renderTagBody";
-import { logger } from "../../../logger";
 
 export async function renderTagFromString(
   pluginData: GuildPluginData<TagsPluginType>,
@@ -36,14 +36,12 @@ export async function renderTagFromString(
     return validateAndParseMessageContent(rendered);
   } catch (e) {
     const logs = pluginData.getPlugin(LogsPlugin);
-    const errorMessage = e instanceof TemplateParseError
-      ? e.message
-      : "Internal error";
+    const errorMessage = e instanceof TemplateParseError ? e.message : "Internal error";
     logs.logBotAlert({
       body: `Failed to render tag \`${prefix}${tagName}\`: ${errorMessage}`,
     });
 
-    if (! (e instanceof TemplateParseError)) {
+    if (!(e instanceof TemplateParseError)) {
       logger.warn(`Internal error rendering tag ${tagName} in ${pluginData.guild.id}: ${e}`);
     }
 
diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts
index a430898e..62f89bd6 100644
--- a/backend/src/plugins/Utility/commands/InfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/InfoCmd.ts
@@ -79,11 +79,7 @@ export const InfoCmd = utilityCmd({
       const messageTarget = await resolveMessageTarget(pluginData, value);
       if (messageTarget) {
         if (canReadChannel(messageTarget.channel, message.member)) {
-          const embed = await getMessageInfoEmbed(
-            pluginData,
-            messageTarget.channel.id,
-            messageTarget.messageId,
-          );
+          const embed = await getMessageInfoEmbed(pluginData, messageTarget.channel.id, messageTarget.messageId);
           if (embed) {
             message.channel.send({ embeds: [embed] });
             return;
diff --git a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts
index 72df1f6c..19164ca4 100644
--- a/backend/src/plugins/Utility/commands/MessageInfoCmd.ts
+++ b/backend/src/plugins/Utility/commands/MessageInfoCmd.ts
@@ -20,11 +20,7 @@ export const MessageInfoCmd = utilityCmd({
       return;
     }
 
-    const embed = await getMessageInfoEmbed(
-      pluginData,
-      args.message.channel.id,
-      args.message.messageId,
-    );
+    const embed = await getMessageInfoEmbed(pluginData, args.message.channel.id, args.message.messageId);
     if (!embed) {
       sendErrorMessage(pluginData, message.channel, "Unknown message");
       return;
diff --git a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
index 700546ca..dd19d9b2 100644
--- a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
@@ -6,10 +6,7 @@ import { UtilityPluginType } from "../types";
 
 const MENTION_ICON = "https://cdn.discordapp.com/attachments/705009450855039042/839284872152481792/mention.png";
 
-export async function getRoleInfoEmbed(
-  pluginData: GuildPluginData<UtilityPluginType>,
-  role: Role,
-): Promise<APIEmbed> {
+export async function getRoleInfoEmbed(pluginData: GuildPluginData<UtilityPluginType>, role: Role): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author" | "color"> = {
     fields: [],
     author: {
diff --git a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
index 7009241c..8449ba74 100644
--- a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
@@ -4,10 +4,7 @@ import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp";
 
 const SNOWFLAKE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/742020790471491668/snowflake.png";
 
-export async function getSnowflakeInfoEmbed(
-  snowflake: string,
-  showUnknownWarning = false,
-): Promise<APIEmbed> {
+export async function getSnowflakeInfoEmbed(snowflake: string, showUnknownWarning = false): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author"> = {
     fields: [],
     author: {
diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts
index dbaacf27..92b5b75f 100644
--- a/backend/src/plugins/Utility/search.ts
+++ b/backend/src/plugins/Utility/search.ts
@@ -14,7 +14,15 @@ import { ArgsFromSignatureOrArray, GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { RegExpRunner, allowTimeout } from "../../RegExpRunner";
 import { getBaseUrl, sendErrorMessage } from "../../pluginUtils";
-import { InvalidRegexError, MINUTES, inputPatternToRegExp, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils";
+import {
+  InvalidRegexError,
+  MINUTES,
+  inputPatternToRegExp,
+  multiSorter,
+  renderUserUsername,
+  sorter,
+  trimLines,
+} from "../../utils";
 import { asyncFilter } from "../../utils/async";
 import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions";
 import { banSearchSignature } from "./commands/BanSearchCmd";
diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts
index aa266abc..338182df 100644
--- a/backend/src/plugins/ZeppelinPluginBlueprint.ts
+++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts
@@ -7,8 +7,8 @@ import {
   GuildPluginBlueprint,
   GuildPluginData,
 } from "knub";
-import { TMarkdown } from "../types";
 import { ZodTypeAny } from "zod";
+import { TMarkdown } from "../types";
 
 /**
  * GUILD PLUGINS
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 97f51301..8790ab50 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -87,8 +87,10 @@ export function isDiscordAPIError(err: Error | string): err is DiscordAPIError {
 }
 
 // null | undefined -> undefined
-export function zNullishToUndefined<T extends z.ZodTypeAny>(type: T): ZodEffects<T, NonNullable<z.output<T>> | undefined> {
-  return type.transform(v => v ?? undefined);
+export function zNullishToUndefined<T extends z.ZodTypeAny>(
+  type: T,
+): ZodEffects<T, NonNullable<z.output<T>> | undefined> {
+  return type.transform((v) => v ?? undefined);
 }
 
 export function getScalarDifference<T extends object>(
@@ -151,15 +153,18 @@ export type GroupDMInvite = Invite & {
 };
 
 export function zBoundedCharacters(min: number, max: number) {
-  return z.string().refine(str => {
-    const len = [...str].length; // Unicode aware character split
-    return (len >= min && len <= max);
-  }, {
-    message: `String must be between ${min} and ${max} characters long`,
-  });
+  return z.string().refine(
+    (str) => {
+      const len = [...str].length; // Unicode aware character split
+      return len >= min && len <= max;
+    },
+    {
+      message: `String must be between ${min} and ${max} characters long`,
+    },
+  );
 }
 
-export const zSnowflake = z.string().refine(str => isSnowflake(str), {
+export const zSnowflake = z.string().refine((str) => isSnowflake(str), {
   message: "Invalid snowflake ID",
 });
 
@@ -188,7 +193,7 @@ export function zRegex<T extends ZodString>(zStr: T) {
       if (err instanceof InvalidRegexError) {
         ctx.addIssue({
           code: z.ZodIssueCode.custom,
-          message: "Invalid regex"
+          message: "Invalid regex",
         });
         return z.NEVER;
       }
@@ -343,8 +348,18 @@ function dropNullValuesRecursively(obj: any) {
  */
 export const zAllowedMentions = z.strictObject({
   everyone: zNullishToUndefined(z.boolean().nullable().optional()),
-  users: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()),
-  roles: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()),
+  users: zNullishToUndefined(
+    z
+      .union([z.boolean(), z.array(z.string())])
+      .nullable()
+      .optional(),
+  ),
+  roles: zNullishToUndefined(
+    z
+      .union([z.boolean(), z.array(z.string())])
+      .nullable()
+      .optional(),
+  ),
   replied_user: zNullishToUndefined(z.boolean().nullable().optional()),
 });
 
@@ -359,18 +374,28 @@ export function dropPropertiesByName(obj, propName) {
   }
 }
 
-export function zBoundedRecord<TRecord extends ZodRecord<any, any>>(record: TRecord, minKeys: number, maxKeys: number): ZodEffects<TRecord> {
-  return record.refine(data => {
-    const len = Object.keys(data).length;
-    return (len >= minKeys && len <= maxKeys);
-  }, {
-    message: `Object must have ${minKeys}-${maxKeys} keys`,
-  });
+export function zBoundedRecord<TRecord extends ZodRecord<any, any>>(
+  record: TRecord,
+  minKeys: number,
+  maxKeys: number,
+): ZodEffects<TRecord> {
+  return record.refine(
+    (data) => {
+      const len = Object.keys(data).length;
+      return len >= minKeys && len <= maxKeys;
+    },
+    {
+      message: `Object must have ${minKeys}-${maxKeys} keys`,
+    },
+  );
 }
 
-export const zDelayString = z.string().max(32).refine(str => convertDelayStringToMS(str) !== null, {
-  message: "Invalid delay string",
-});
+export const zDelayString = z
+  .string()
+  .max(32)
+  .refine((str) => convertDelayStringToMS(str) !== null, {
+    message: "Invalid delay string",
+  });
 
 // To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that.
 // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#The_ECMAScript_epoch_and_timestamps
@@ -1487,9 +1512,11 @@ export function renderUserUsername(user: User | UnknownUser): string {
   return renderUsername(user.username, user.discriminator);
 }
 
-type Entries<T> = Array<{
-  [Key in keyof T]-?: [Key, T[Key]];
-}[keyof T]>;
+type Entries<T> = Array<
+  {
+    [Key in keyof T]-?: [Key, T[Key]];
+  }[keyof T]
+>;
 
 export function entries<T extends object>(object: T) {
   return Object.entries(object) as Entries<T>;
diff --git a/backend/src/utils/formatZodIssue.ts b/backend/src/utils/formatZodIssue.ts
index 2b5c3d8e..93932f66 100644
--- a/backend/src/utils/formatZodIssue.ts
+++ b/backend/src/utils/formatZodIssue.ts
@@ -1,6 +1,6 @@
 import { ZodIssue } from "zod";
 
 export function formatZodIssue(issue: ZodIssue): string {
-    const path = issue.path.join("/");
-    return `${path}: ${issue.message}`;
+  const path = issue.path.join("/");
+  return `${path}: ${issue.message}`;
 }
diff --git a/backend/src/validateActiveConfigs.ts b/backend/src/validateActiveConfigs.ts
index c9e3414b..f985fd14 100644
--- a/backend/src/validateActiveConfigs.ts
+++ b/backend/src/validateActiveConfigs.ts
@@ -6,7 +6,10 @@ import { loadYamlSafely } from "./utils/loadYamlSafely";
 import { ObjectAliasError } from "./utils/validateNoObjectAliases";
 
 function writeError(key: string, error: string) {
-  const indented = error.split("\n").map(s => " ".repeat(64) + s).join("\n");
+  const indented = error
+    .split("\n")
+    .map((s) => " ".repeat(64) + s)
+    .join("\n");
   const prefix = `Invalid config ${key}:`;
   const prefixed = prefix + indented.slice(prefix.length);
   console.log(prefixed + "\n\n");

From 710bedd050b59e501d6209b9c3ca38e6f83e0353 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:38:02 +0200
Subject: [PATCH 45/51] chore: run prettier

---
 backend/src/plugins/Automod/actions/startThread.ts         | 7 +------
 backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts  | 5 +----
 .../src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts | 5 +----
 3 files changed, 3 insertions(+), 14 deletions(-)

diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 94e0e0e3..41648ad4 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -1,9 +1,4 @@
-import {
-  ChannelType,
-  GuildTextThreadCreateOptions,
-  ThreadAutoArchiveDuration,
-  ThreadChannel,
-} from "discord.js";
+import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js";
 import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils";
diff --git a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
index 700546ca..dd19d9b2 100644
--- a/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getRoleInfoEmbed.ts
@@ -6,10 +6,7 @@ import { UtilityPluginType } from "../types";
 
 const MENTION_ICON = "https://cdn.discordapp.com/attachments/705009450855039042/839284872152481792/mention.png";
 
-export async function getRoleInfoEmbed(
-  pluginData: GuildPluginData<UtilityPluginType>,
-  role: Role,
-): Promise<APIEmbed> {
+export async function getRoleInfoEmbed(pluginData: GuildPluginData<UtilityPluginType>, role: Role): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author" | "color"> = {
     fields: [],
     author: {
diff --git a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
index 7009241c..8449ba74 100644
--- a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
+++ b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts
@@ -4,10 +4,7 @@ import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp";
 
 const SNOWFLAKE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/742020790471491668/snowflake.png";
 
-export async function getSnowflakeInfoEmbed(
-  snowflake: string,
-  showUnknownWarning = false,
-): Promise<APIEmbed> {
+export async function getSnowflakeInfoEmbed(snowflake: string, showUnknownWarning = false): Promise<APIEmbed> {
   const embed: EmbedWith<"fields" | "author"> = {
     fields: [],
     author: {

From e42bbb953fe34bd8d6b4991c5979b6fc443d6909 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:47:59 +0200
Subject: [PATCH 46/51] fix: use lower casesPerPage

Temporary fix to make it unlikely to go over the 4096 character limit

Needs a proper fix in the future where we pre-render the case
summaries and paginate based on their length.
---
 backend/src/plugins/ModActions/commands/CasesModCmd.ts  | 2 +-
 backend/src/plugins/ModActions/commands/CasesUserCmd.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/backend/src/plugins/ModActions/commands/CasesModCmd.ts b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
index 67363fe5..306c1ad1 100644
--- a/backend/src/plugins/ModActions/commands/CasesModCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesModCmd.ts
@@ -12,7 +12,7 @@ const opts = {
   mod: ct.userId({ option: true }),
 };
 
-const casesPerPage = 10;
+const casesPerPage = 5;
 
 export const CasesModCmd = modActionsCmd({
   trigger: ["cases", "modlogs", "infractions"],
diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 08c6f2a6..1fd8973b 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -28,7 +28,7 @@ const opts = {
   unbans: ct.switchOption({ def: false, shortcut: "ub" }),
 };
 
-const casesPerPage = 10;
+const casesPerPage = 5;
 
 export const CasesUserCmd = modActionsCmd({
   trigger: ["cases", "modlogs"],

From 90c7024b053e393b975579222107b5ef4b02f9d4 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:50:57 +0200
Subject: [PATCH 47/51] chore: run prettier

---
 backend/src/plugins/ModActions/commands/CasesUserCmd.ts | 9 +--------
 1 file changed, 1 insertion(+), 8 deletions(-)

diff --git a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
index 1fd8973b..08e586c8 100644
--- a/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
+++ b/backend/src/plugins/ModActions/commands/CasesUserCmd.ts
@@ -3,14 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { sendErrorMessage } from "../../../pluginUtils";
 import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
-import {
-  UnknownUser,
-  chunkArray,
-  emptyEmbedValue,
-  renderUsername,
-  resolveMember,
-  resolveUser,
-} from "../../../utils";
+import { UnknownUser, chunkArray, emptyEmbedValue, renderUsername, resolveMember, resolveUser } from "../../../utils";
 import { asyncMap } from "../../../utils/async";
 import { createPaginatedMessage } from "../../../utils/createPaginatedMessage.js";
 import { getGuildPrefix } from "../../../utils/getGuildPrefix";

From 6840fb464632aafd1359020df5f8a8b40cde0243 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 14:57:57 +0200
Subject: [PATCH 48/51] fix: clamp counter values in config

---
 backend/src/data/GuildCounters.ts                 | 9 ++++++---
 backend/src/plugins/Automod/actions/setCounter.ts | 3 ++-
 backend/src/plugins/Counters/types.ts             | 4 ++--
 3 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/backend/src/data/GuildCounters.ts b/backend/src/data/GuildCounters.ts
index 0c8fde5b..18c9b5e6 100644
--- a/backend/src/data/GuildCounters.ts
+++ b/backend/src/data/GuildCounters.ts
@@ -12,7 +12,8 @@ import { CounterValue } from "./entities/CounterValue";
 const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS;
 const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS;
 
-const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
+export const MIN_COUNTER_VALUE = 0;
+export const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
 
 const decayQueue = new Queue();
 
@@ -115,7 +116,9 @@ export class GuildCounters extends BaseGuildRepository {
     userId = userId || "0";
 
     const rawUpdate =
-      change >= 0 ? `value = LEAST(value + ${change}, ${MAX_COUNTER_VALUE})` : `value = GREATEST(value ${change}, 0)`;
+      change >= 0
+        ? `value = LEAST(value + ${change}, ${MAX_COUNTER_VALUE})`
+        : `value = GREATEST(value ${change}, ${MIN_COUNTER_VALUE})`;
 
     await this.counterValues.query(
       `
@@ -173,7 +176,7 @@ export class GuildCounters extends BaseGuildRepository {
 
       const rawUpdate =
         decayAmountToApply >= 0
-          ? `GREATEST(value - ${decayAmountToApply}, 0)`
+          ? `GREATEST(value - ${decayAmountToApply}, ${MIN_COUNTER_VALUE})`
           : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`;
 
       // Using an UPDATE with ORDER BY in an attempt to avoid deadlocks from simultaneous decays
diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts
index dea4bdd6..604d9bf4 100644
--- a/backend/src/plugins/Automod/actions/setCounter.ts
+++ b/backend/src/plugins/Automod/actions/setCounter.ts
@@ -1,4 +1,5 @@
 import z from "zod";
+import { MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../../data/GuildCounters";
 import { zBoundedCharacters } from "../../../utils";
 import { CountersPlugin } from "../../Counters/CountersPlugin";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
@@ -7,7 +8,7 @@ import { automodAction } from "../helpers";
 export const SetCounterAction = automodAction({
   configSchema: z.strictObject({
     counter: zBoundedCharacters(0, 100),
-    value: z.number(),
+    value: z.number().min(MIN_COUNTER_VALUE).max(MAX_COUNTER_VALUE),
   }),
 
   async apply({ pluginData, contexts, actionConfig, ruleName }) {
diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts
index 8e70c4ab..ef322e7b 100644
--- a/backend/src/plugins/Counters/types.ts
+++ b/backend/src/plugins/Counters/types.ts
@@ -1,7 +1,7 @@
 import { EventEmitter } from "events";
 import { BasePluginType } from "knub";
 import z from "zod";
-import { GuildCounters } from "../../data/GuildCounters";
+import { GuildCounters, MAX_COUNTER_VALUE, MIN_COUNTER_VALUE } from "../../data/GuildCounters";
 import {
   CounterTrigger,
   buildCounterConditionString,
@@ -93,7 +93,7 @@ export const zCounter = z.strictObject({
   pretty_name: zBoundedCharacters(0, 100).nullable().default(null),
   per_channel: z.boolean().default(false),
   per_user: z.boolean().default(false),
-  initial_value: z.number().default(0),
+  initial_value: z.number().min(MIN_COUNTER_VALUE).max(MAX_COUNTER_VALUE).default(0),
   triggers: zBoundedRecord(z.record(zBoundedCharacters(0, 100), zTriggerInput), 1, MAX_TRIGGERS_PER_COUNTER),
   decay: z
     .strictObject({

From 008ac1bec0b86a0c0f0c708c5502bd9bcc2d274a Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 15:04:39 +0200
Subject: [PATCH 49/51] fix: missing imports

---
 backend/src/plugins/Automod/triggers/roleAdded.ts   |  2 +-
 backend/src/plugins/Automod/triggers/roleRemoved.ts |  2 +-
 backend/src/plugins/Utility/search.ts               | 10 +++++++++-
 3 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts
index e2a5767b..5a9ee3af 100644
--- a/backend/src/plugins/Automod/triggers/roleAdded.ts
+++ b/backend/src/plugins/Automod/triggers/roleAdded.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
 import z from "zod";
-import { zSnowflake } from "../../../utils";
+import { renderUsername, zSnowflake } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts
index a5659ecb..82479c0e 100644
--- a/backend/src/plugins/Automod/triggers/roleRemoved.ts
+++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts
@@ -1,6 +1,6 @@
 import { Snowflake } from "discord.js";
 import z from "zod";
-import { zSnowflake } from "../../../utils";
+import { renderUsername, zSnowflake } from "../../../utils";
 import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
 import { automodTrigger } from "../helpers";
 
diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts
index 105afc86..4f5d08ef 100644
--- a/backend/src/plugins/Utility/search.ts
+++ b/backend/src/plugins/Utility/search.ts
@@ -14,7 +14,15 @@ import { ArgsFromSignatureOrArray, GuildPluginData } from "knub";
 import moment from "moment-timezone";
 import { RegExpRunner, allowTimeout } from "../../RegExpRunner";
 import { getBaseUrl, sendErrorMessage } from "../../pluginUtils";
-import { MINUTES, multiSorter, renderUsername, sorter, trimLines } from "../../utils";
+import {
+  InvalidRegexError,
+  MINUTES,
+  inputPatternToRegExp,
+  multiSorter,
+  renderUsername,
+  sorter,
+  trimLines,
+} from "../../utils";
 import { asyncFilter } from "../../utils/async";
 import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions";
 import { banSearchSignature } from "./commands/BanSearchCmd";

From 2ce5082018118fafd5cd860b62d692d7da6cc688 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 15:05:35 +0200
Subject: [PATCH 50/51] fix: shorter than 6 character invite codes

Fixes ZDEV-108
---
 backend/src/utils.ts | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index 49a03c27..174a720f 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -557,11 +557,12 @@ export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] {
 }
 
 export function parseInviteCodeInput(str: string): string {
-  if (str.match(/^[a-z0-9]{6,}$/i)) {
-    return str;
+  const parsedInviteCodes = getInviteCodesInString(str);
+  if (parsedInviteCodes.length) {
+    return parsedInviteCodes[0];
   }
 
-  return getInviteCodesInString(str)[0];
+  return str;
 }
 
 export function isNotNull<T>(value: T): value is Exclude<T, null | undefined> {

From ffa9eeb3f53762fa9014f2fe5cd7bdfa8c867138 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Sat, 27 Jan 2024 16:01:48 +0200
Subject: [PATCH 51/51] feat: handle template errors

Fixes ZDEV-20
---
 backend/src/RecoverablePluginError.ts         |  2 +
 .../plugins/Automod/actions/changePerms.ts    | 55 +++++++++++-----
 backend/src/plugins/Automod/actions/reply.ts  | 21 ++++--
 .../plugins/Automod/actions/startThread.ts    | 23 +++++--
 .../CustomEvents/CustomEventsPlugin.ts        |  2 +
 .../CustomEvents/actions/addRoleAction.ts     |  6 +-
 .../CustomEvents/actions/createCaseAction.ts  | 11 ++--
 .../CustomEvents/actions/messageAction.ts     |  6 +-
 .../actions/moveToVoiceChannelAction.ts       | 11 +++-
 .../CustomEvents/catchTemplateError.ts        | 13 ++++
 .../plugins/ModActions/functions/banUserId.ts | 66 ++++++++++++-------
 .../ModActions/functions/kickMember.ts        | 33 ++++++----
 .../ModActions/functions/warnMember.ts        | 33 ++++++----
 .../src/plugins/Mutes/functions/muteUser.ts   | 43 +++++++-----
 14 files changed, 231 insertions(+), 94 deletions(-)
 create mode 100644 backend/src/plugins/CustomEvents/catchTemplateError.ts

diff --git a/backend/src/RecoverablePluginError.ts b/backend/src/RecoverablePluginError.ts
index 46a86ae5..64fed618 100644
--- a/backend/src/RecoverablePluginError.ts
+++ b/backend/src/RecoverablePluginError.ts
@@ -11,6 +11,7 @@ export enum ERRORS {
   MUTE_ROLE_ABOVE_ZEP,
   USER_ABOVE_ZEP,
   USER_NOT_MODERATABLE,
+  TEMPLATE_PARSE_ERROR,
 }
 
 export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = {
@@ -24,6 +25,7 @@ export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = {
   [ERRORS.MUTE_ROLE_ABOVE_ZEP]: "Specified mute role is above Zeppelin in the role hierarchy",
   [ERRORS.USER_ABOVE_ZEP]: "Cannot mute user, specified user is above Zeppelin in the role hierarchy",
   [ERRORS.USER_NOT_MODERATABLE]: "Cannot mute user, specified user is not moderatable",
+  [ERRORS.TEMPLATE_PARSE_ERROR]: "Template parse error",
 };
 
 export class RecoverablePluginError extends Error {
diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts
index 7b7eba37..351b1637 100644
--- a/backend/src/plugins/Automod/actions/changePerms.ts
+++ b/backend/src/plugins/Automod/actions/changePerms.ts
@@ -1,13 +1,14 @@
 import { PermissionsBitField, PermissionsString } from "discord.js";
 import { U } from "ts-toolbelt";
 import z from "zod";
-import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { isValidSnowflake, keys, noop, zBoundedCharacters } from "../../../utils";
 import {
   guildToTemplateSafeGuild,
   savedMessageToTemplateSafeSavedMessage,
   userToTemplateSafeUser,
 } from "../../../utils/templateSafeObjects";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 type LegacyPermMap = Record<string, keyof (typeof PermissionsBitField)["Flags"]>;
@@ -71,30 +72,52 @@ export const ChangePermsAction = automodAction({
     perms: z.record(z.enum(allPermissionNames), z.boolean().nullable()),
   }),
 
-  async apply({ pluginData, contexts, actionConfig }) {
+  async apply({ pluginData, contexts, actionConfig, ruleName }) {
     const user = contexts.find((c) => c.user)?.user;
     const message = contexts.find((c) => c.message)?.message;
 
-    const renderTarget = async (str: string) =>
-      renderTemplate(
-        str,
+    let target: string;
+    try {
+      target = await renderTemplate(
+        actionConfig.target,
         new TemplateSafeValueContainer({
           user: user ? userToTemplateSafeUser(user) : null,
           guild: guildToTemplateSafeGuild(pluginData.guild),
           message: message ? savedMessageToTemplateSafeSavedMessage(message) : null,
         }),
       );
-    const renderChannel = async (str: string) =>
-      renderTemplate(
-        str,
-        new TemplateSafeValueContainer({
-          user: user ? userToTemplateSafeUser(user) : null,
-          guild: guildToTemplateSafeGuild(pluginData.guild),
-          message: message ? savedMessageToTemplateSafeSavedMessage(message) : null,
-        }),
-      );
-    const target = await renderTarget(actionConfig.target);
-    const channelId = actionConfig.channel ? await renderChannel(actionConfig.channel) : null;
+    } catch (err) {
+      if (err instanceof TemplateParseError) {
+        pluginData.getPlugin(LogsPlugin).logBotAlert({
+          body: `Error in target format of automod rule ${ruleName}: ${err.message}`,
+        });
+        return;
+      }
+      throw err;
+    }
+
+    let channelId: string | null = null;
+    if (actionConfig.channel) {
+      try {
+        channelId = await renderTemplate(
+          actionConfig.channel,
+          new TemplateSafeValueContainer({
+            user: user ? userToTemplateSafeUser(user) : null,
+            guild: guildToTemplateSafeGuild(pluginData.guild),
+            message: message ? savedMessageToTemplateSafeSavedMessage(message) : null,
+          }),
+        );
+      } catch (err) {
+        if (err instanceof TemplateParseError) {
+          pluginData.getPlugin(LogsPlugin).logBotAlert({
+            body: `Error in channel format of automod rule ${ruleName}: ${err.message}`,
+          });
+          return;
+        }
+        throw err;
+      }
+    }
+
     const role = pluginData.guild.roles.resolve(target);
     if (!role) {
       const member = await pluginData.guild.members.fetch(target).catch(noop);
diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts
index e8b1fdf8..0628e26f 100644
--- a/backend/src/plugins/Automod/actions/reply.ts
+++ b/backend/src/plugins/Automod/actions/reply.ts
@@ -1,6 +1,6 @@
 import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js";
 import z from "zod";
-import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import {
   convertDelayStringToMS,
   noop,
@@ -58,10 +58,21 @@ export const ReplyAction = automodAction({
           }),
         );
 
-      const formatted =
-        typeof actionConfig === "string"
-          ? await renderReplyText(actionConfig)
-          : ((await renderRecursively(actionConfig.text, renderReplyText)) as MessageCreateOptions);
+      let formatted: string | MessageCreateOptions;
+      try {
+        formatted =
+          typeof actionConfig === "string"
+            ? await renderReplyText(actionConfig)
+            : ((await renderRecursively(actionConfig.text, renderReplyText)) as MessageCreateOptions);
+      } catch (err) {
+        if (err instanceof TemplateParseError) {
+          pluginData.getPlugin(LogsPlugin).logBotAlert({
+            body: `Error in reply format of automod rule \`${ruleName}\`: ${err.message}`,
+          });
+          return;
+        }
+        throw err;
+      }
 
       if (formatted) {
         const channel = pluginData.guild.channels.cache.get(channelId as Snowflake) as GuildTextBasedChannel;
diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts
index 41648ad4..a521d72f 100644
--- a/backend/src/plugins/Automod/actions/startThread.ts
+++ b/backend/src/plugins/Automod/actions/startThread.ts
@@ -1,8 +1,9 @@
 import { ChannelType, GuildTextThreadCreateOptions, ThreadAutoArchiveDuration, ThreadChannel } from "discord.js";
 import z from "zod";
-import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils";
 import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
 import { automodAction } from "../helpers";
 
 const validThreadAutoArchiveDurations: ThreadAutoArchiveDuration[] = [
@@ -21,7 +22,7 @@ export const StartThreadAction = automodAction({
     limit_per_channel: z.number().nullable().default(5),
   }),
 
-  async apply({ pluginData, contexts, actionConfig }) {
+  async apply({ pluginData, contexts, actionConfig, ruleName }) {
     // check if the message still exists, we don't want to create threads for deleted messages
     const threads = contexts.filter((c) => {
       if (!c.message || !c.user) return false;
@@ -48,15 +49,25 @@ export const StartThreadAction = automodAction({
       const channel = pluginData.guild.channels.cache.get(threadContext.message!.channel_id);
       if (!channel || !("threads" in channel) || channel.isThreadOnly()) continue;
 
-      const renderThreadName = async (str: string) =>
-        renderTemplate(
-          str,
+      let threadName: string;
+      try {
+        threadName = await renderTemplate(
+          actionConfig.name ?? "{user.renderedUsername}'s thread",
           new TemplateSafeValueContainer({
             user: userToTemplateSafeUser(threadContext.user!),
             msg: savedMessageToTemplateSafeSavedMessage(threadContext.message!),
           }),
         );
-      const threadName = await renderThreadName(actionConfig.name ?? "{user.renderedUsername}'s thread");
+      } catch (err) {
+        if (err instanceof TemplateParseError) {
+          pluginData.getPlugin(LogsPlugin).logBotAlert({
+            body: `Error in thread name format of automod rule ${ruleName}: ${err.message}`,
+          });
+          return;
+        }
+        throw err;
+      }
+
       const threadOptions: GuildTextThreadCreateOptions<unknown> = {
         name: threadName,
         autoArchiveDuration: autoArchive,
diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
index e17cd96b..82c5e612 100644
--- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
+++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts
@@ -11,6 +11,7 @@ import {
   messageToTemplateSafeMessage,
   userToTemplateSafeUser,
 } from "../../utils/templateSafeObjects";
+import { LogsPlugin } from "../Logs/LogsPlugin";
 import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
 import { runEvent } from "./functions/runEvent";
 import { CustomEventsPluginType, zCustomEventsConfig } from "./types";
@@ -25,6 +26,7 @@ export const CustomEventsPlugin = zeppelinGuildPlugin<CustomEventsPluginType>()(
   name: "custom_events",
   showInDocs: false,
 
+  dependencies: () => [LogsPlugin],
   configParser: (input) => zCustomEventsConfig.parse(input),
   defaultOptions,
 
diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
index f0870958..cd647687 100644
--- a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts
@@ -4,6 +4,7 @@ import { canActOn } from "../../../pluginUtils";
 import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
 import { resolveMember, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
+import { catchTemplateError } from "../catchTemplateError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
 export const zAddRoleAction = z.strictObject({
@@ -20,7 +21,10 @@ export async function addRoleAction(
   event: TCustomEvent,
   eventData: any,
 ) {
-  const targetId = await renderTemplate(action.target, values, false);
+  const targetId = await catchTemplateError(
+    () => renderTemplate(action.target, values, false),
+    "Invalid target format",
+  );
   const target = await resolveMember(pluginData.client, pluginData.guild, targetId);
   if (!target) throw new ActionError(`Unknown target member: ${targetId}`);
 
diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
index e894a446..a5e624fa 100644
--- a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts
@@ -5,6 +5,7 @@ import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFor
 import { zBoundedCharacters, zSnowflake } from "../../../utils";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { ActionError } from "../ActionError";
+import { catchTemplateError } from "../catchTemplateError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
 export const zCreateCaseAction = z.strictObject({
@@ -23,10 +24,12 @@ export async function createCaseAction(
   event: TCustomEvent,
   eventData: any, // eslint-disable-line @typescript-eslint/no-unused-vars
 ) {
-  const modId = await renderTemplate(action.mod, values, false);
-  const targetId = await renderTemplate(action.target, values, false);
-
-  const reason = await renderTemplate(action.reason, values, false);
+  const modId = await catchTemplateError(() => renderTemplate(action.mod, values, false), "Invalid mod format");
+  const targetId = await catchTemplateError(
+    () => renderTemplate(action.target, values, false),
+    "Invalid target format",
+  );
+  const reason = await catchTemplateError(() => renderTemplate(action.reason, values, false), "Invalid reason format");
 
   if (CaseTypes[action.case_type] == null) {
     throw new ActionError(`Invalid case type: ${action.type}`);
diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts
index 40eee4b8..27780265 100644
--- a/backend/src/plugins/CustomEvents/actions/messageAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts
@@ -4,6 +4,7 @@ import z from "zod";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { zBoundedCharacters, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
+import { catchTemplateError } from "../catchTemplateError";
 import { CustomEventsPluginType } from "../types";
 
 export const zMessageAction = z.strictObject({
@@ -18,7 +19,10 @@ export async function messageAction(
   action: TMessageAction,
   values: TemplateSafeValueContainer,
 ) {
-  const targetChannelId = await renderTemplate(action.channel, values, false);
+  const targetChannelId = await catchTemplateError(
+    () => renderTemplate(action.channel, values, false),
+    "Invalid channel format",
+  );
   const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake);
   if (!targetChannel) throw new ActionError("Unknown target channel");
   if (!(targetChannel instanceof TextChannel)) throw new ActionError("Target channel is not a text channel");
diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
index d42059f5..40adcb58 100644
--- a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
+++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts
@@ -5,6 +5,7 @@ import { canActOn } from "../../../pluginUtils";
 import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { resolveMember, zSnowflake } from "../../../utils";
 import { ActionError } from "../ActionError";
+import { catchTemplateError } from "../catchTemplateError";
 import { CustomEventsPluginType, TCustomEvent } from "../types";
 
 export const zMoveToVoiceChannelAction = z.strictObject({
@@ -21,7 +22,10 @@ export async function moveToVoiceChannelAction(
   event: TCustomEvent,
   eventData: any,
 ) {
-  const targetId = await renderTemplate(action.target, values, false);
+  const targetId = await catchTemplateError(
+    () => renderTemplate(action.target, values, false),
+    "Invalid target format",
+  );
   const target = await resolveMember(pluginData.client, pluginData.guild, targetId);
   if (!target) throw new ActionError("Unknown target member");
 
@@ -29,7 +33,10 @@ export async function moveToVoiceChannelAction(
     throw new ActionError("Missing permissions");
   }
 
-  const targetChannelId = await renderTemplate(action.channel, values, false);
+  const targetChannelId = await catchTemplateError(
+    () => renderTemplate(action.channel, values, false),
+    "Invalid channel format",
+  );
   const targetChannel = pluginData.guild.channels.cache.get(targetChannelId as Snowflake);
   if (!targetChannel) throw new ActionError("Unknown target channel");
   if (!(targetChannel instanceof VoiceChannel)) throw new ActionError("Target channel is not a voice channel");
diff --git a/backend/src/plugins/CustomEvents/catchTemplateError.ts b/backend/src/plugins/CustomEvents/catchTemplateError.ts
new file mode 100644
index 00000000..015a4298
--- /dev/null
+++ b/backend/src/plugins/CustomEvents/catchTemplateError.ts
@@ -0,0 +1,13 @@
+import { TemplateParseError } from "../../templateFormatter";
+import { ActionError } from "./ActionError";
+
+export function catchTemplateError(fn: () => Promise<string>, errorText: string): Promise<string> {
+  try {
+    return fn();
+  } catch (err) {
+    if (err instanceof TemplateParseError) {
+      throw new ActionError(`${errorText}: ${err.message}`);
+    }
+    throw err;
+  }
+}
diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts
index d9d1454b..65686c81 100644
--- a/backend/src/plugins/ModActions/functions/banUserId.ts
+++ b/backend/src/plugins/ModActions/functions/banUserId.ts
@@ -5,7 +5,7 @@ import { CaseTypes } from "../../../data/CaseTypes";
 import { LogType } from "../../../data/LogType";
 import { registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop";
 import { logger } from "../../../logger";
-import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import {
   DAYS,
   SECONDS,
@@ -52,30 +52,52 @@ export async function banUserId(
 
     if (contactMethods.length) {
       if (!banTime && config.ban_message) {
-        const banMessage = await renderTemplate(
-          config.ban_message,
-          new TemplateSafeValueContainer({
-            guildName: pluginData.guild.name,
-            reason,
-            moderator: banOptions.caseArgs?.modId
-              ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId))
-              : null,
-          }),
-        );
+        let banMessage: string;
+        try {
+          banMessage = await renderTemplate(
+            config.ban_message,
+            new TemplateSafeValueContainer({
+              guildName: pluginData.guild.name,
+              reason,
+              moderator: banOptions.caseArgs?.modId
+                ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId))
+                : null,
+            }),
+          );
+        } catch (err) {
+          if (err instanceof TemplateParseError) {
+            return {
+              status: "failed",
+              error: `Invalid ban_message format: ${err.message}`,
+            };
+          }
+          throw err;
+        }
 
         notifyResult = await notifyUser(member.user, banMessage, contactMethods);
       } else if (banTime && config.tempban_message) {
-        const banMessage = await renderTemplate(
-          config.tempban_message,
-          new TemplateSafeValueContainer({
-            guildName: pluginData.guild.name,
-            reason,
-            moderator: banOptions.caseArgs?.modId
-              ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId))
-              : null,
-            banTime: humanizeDuration(banTime),
-          }),
-        );
+        let banMessage: string;
+        try {
+          banMessage = await renderTemplate(
+            config.tempban_message,
+            new TemplateSafeValueContainer({
+              guildName: pluginData.guild.name,
+              reason,
+              moderator: banOptions.caseArgs?.modId
+                ? userToTemplateSafeUser(await resolveUser(pluginData.client, banOptions.caseArgs.modId))
+                : null,
+              banTime: humanizeDuration(banTime),
+            }),
+          );
+        } catch (err) {
+          if (err instanceof TemplateParseError) {
+            return {
+              status: "failed",
+              error: `Invalid tempban_message format: ${err.message}`,
+            };
+          }
+          throw err;
+        }
 
         notifyResult = await notifyUser(member.user, banMessage, contactMethods);
       } else {
diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts
index d54dfd15..6dc26e35 100644
--- a/backend/src/plugins/ModActions/functions/kickMember.ts
+++ b/backend/src/plugins/ModActions/functions/kickMember.ts
@@ -2,7 +2,7 @@ import { GuildMember } from "discord.js";
 import { GuildPluginData } from "knub";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { LogType } from "../../../data/LogType";
-import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
+import { renderTemplate, TemplateParseError, TemplateSafeValueContainer } from "../../../templateFormatter";
 import { createUserNotificationError, notifyUser, resolveUser, ucfirst, UserNotificationResult } from "../../../utils";
 import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
@@ -31,16 +31,27 @@ export async function kickMember(
 
     if (contactMethods.length) {
       if (config.kick_message) {
-        const kickMessage = await renderTemplate(
-          config.kick_message,
-          new TemplateSafeValueContainer({
-            guildName: pluginData.guild.name,
-            reason,
-            moderator: kickOptions.caseArgs?.modId
-              ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId))
-              : null,
-          }),
-        );
+        let kickMessage: string;
+        try {
+          kickMessage = await renderTemplate(
+            config.kick_message,
+            new TemplateSafeValueContainer({
+              guildName: pluginData.guild.name,
+              reason,
+              moderator: kickOptions.caseArgs?.modId
+                ? userToTemplateSafeUser(await resolveUser(pluginData.client, kickOptions.caseArgs.modId))
+                : null,
+            }),
+          );
+        } catch (err) {
+          if (err instanceof TemplateParseError) {
+            return {
+              status: "failed",
+              error: `Invalid kick_message format: ${err.message}`,
+            };
+          }
+          throw err;
+        }
 
         notifyResult = await notifyUser(member.user, kickMessage, contactMethods);
       } else {
diff --git a/backend/src/plugins/ModActions/functions/warnMember.ts b/backend/src/plugins/ModActions/functions/warnMember.ts
index 9bc0fda9..9aba15f4 100644
--- a/backend/src/plugins/ModActions/functions/warnMember.ts
+++ b/backend/src/plugins/ModActions/functions/warnMember.ts
@@ -1,7 +1,7 @@
 import { GuildMember, Snowflake } from "discord.js";
 import { GuildPluginData } from "knub";
 import { CaseTypes } from "../../../data/CaseTypes";
-import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import { UserNotificationResult, createUserNotificationError, notifyUser, resolveUser, ucfirst } from "../../../utils";
 import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { waitForButtonConfirm } from "../../../utils/waitForInteraction";
@@ -20,16 +20,27 @@ export async function warnMember(
 
   let notifyResult: UserNotificationResult;
   if (config.warn_message) {
-    const warnMessage = await renderTemplate(
-      config.warn_message,
-      new TemplateSafeValueContainer({
-        guildName: pluginData.guild.name,
-        reason,
-        moderator: warnOptions.caseArgs?.modId
-          ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId))
-          : null,
-      }),
-    );
+    let warnMessage: string;
+    try {
+      warnMessage = await renderTemplate(
+        config.warn_message,
+        new TemplateSafeValueContainer({
+          guildName: pluginData.guild.name,
+          reason,
+          moderator: warnOptions.caseArgs?.modId
+            ? userToTemplateSafeUser(await resolveUser(pluginData.client, warnOptions.caseArgs.modId))
+            : null,
+        }),
+      );
+    } catch (err) {
+      if (err instanceof TemplateParseError) {
+        return {
+          status: "failed",
+          error: `Invalid warn_message format: ${err.message}`,
+        };
+      }
+      throw err;
+    }
     const contactMethods = warnOptions?.contactMethods
       ? warnOptions.contactMethods
       : getDefaultContactMethods(pluginData, "warn");
diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts
index 575f96fb..fcc27307 100644
--- a/backend/src/plugins/Mutes/functions/muteUser.ts
+++ b/backend/src/plugins/Mutes/functions/muteUser.ts
@@ -9,7 +9,7 @@ import { Case } from "../../../data/entities/Case";
 import { Mute } from "../../../data/entities/Mute";
 import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop";
 import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
-import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
+import { TemplateParseError, TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
 import {
   UserNotificationMethod,
   UserNotificationResult,
@@ -61,9 +61,10 @@ export async function muteUser(
   const member = await resolveMember(pluginData.client, pluginData.guild, user.id, true); // Grab the fresh member so we don't have stale role info
   const config = await pluginData.config.getMatchingConfig({ member, userId });
 
+  const logs = pluginData.getPlugin(LogsPlugin);
+
   let rolesToRestore: string[] = [];
   if (member) {
-    const logs = pluginData.getPlugin(LogsPlugin);
     // remove and store any roles to be removed/restored
     const currentUserRoles = [...member.roles.cache.keys()];
     let newRoles: string[] = currentUserRoles;
@@ -187,19 +188,31 @@ export async function muteUser(
     ? config.timed_mute_message
     : config.mute_message;
 
-  const muteMessage =
-    template &&
-    (await renderTemplate(
-      template,
-      new TemplateSafeValueContainer({
-        guildName: pluginData.guild.name,
-        reason: reason || "None",
-        time: timeUntilUnmuteStr,
-        moderator: muteOptions.caseArgs?.modId
-          ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId))
-          : null,
-      }),
-    ));
+  let muteMessage: string | null = null;
+  try {
+    muteMessage =
+      template &&
+      (await renderTemplate(
+        template,
+        new TemplateSafeValueContainer({
+          guildName: pluginData.guild.name,
+          reason: reason || "None",
+          time: timeUntilUnmuteStr,
+          moderator: muteOptions.caseArgs?.modId
+            ? userToTemplateSafeUser(await resolveUser(pluginData.client, muteOptions.caseArgs.modId))
+            : null,
+        }),
+      ));
+  } catch (err) {
+    if (err instanceof TemplateParseError) {
+      logs.logBotAlert({
+        body: `Invalid mute message format. The mute was still applied: ${err.message}`,
+      });
+    } else {
+      lock.unlock();
+      throw err;
+    }
+  }
 
   if (muteMessage && member) {
     let contactMethods: UserNotificationMethod[] = [];