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] 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[] = [];